From 24b94a362d6b0dce5eccd12182b487fd0fa4c133 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 21 Apr 2026 14:55:31 -0400 Subject: [PATCH 1/5] refactor(iot): extract iot-contracts crate for cross-boundary types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the data types, NATS bucket names, and KV key formats that were scattered across the IoT operator, on-device agent, and harmony's podman module. Each was defined in one place and quoted / reimplemented in the others, which is exactly the kind of contract drift the roadmap v0.1 §2 called for consolidating before we start layering new features on top. New crate `iot/iot-contracts`: * score.rs — `IotScore`, `PodmanV0Score`, `PodmanService` (moved from `harmony::modules::podman::score`). Pure data, no harmony deps. * kv.rs — `BUCKET_DESIRED_STATE`, `BUCKET_AGENT_STATUS` constants, `desired_state_key(device, deployment)`, `status_key(device)`. These values used to be hard-coded in five places (agent main.rs, operator main.rs, operator/deploy/operator.yaml, smoke-a1.sh, smoke-a3.sh). Tests lock the literals so a flip can't slip. * status.rs — typed `AgentStatus { device_id, status, timestamp }`. Replaces the anonymous `serde_json::json!{}` the agent was publishing, so the operator can deserialize the heartbeat payload via a shared struct when §12 v0.1 status aggregation lands. Consumer updates: * `harmony::modules::podman::score` now holds only the `Score` / `Interpret` trait bindings; the pure types are re-exported from iot-contracts. Trait impls can't move because the trait lives in harmony, so this is the cleanest split. * `iot-operator-v0` uses `BUCKET_DESIRED_STATE` and `desired_state_key` — the inline `kv_key` fn now delegates so the existing internal call sites stay untouched. * `iot-agent-v0` uses `BUCKET_DESIRED_STATE`, `BUCKET_AGENT_STATUS`, `status_key`, and `AgentStatus` for the heartbeat publish. No behavior change. Tests: `cargo test -p iot-contracts` passes (8/8). Regression: `smoke-a3.sh` on x86_64 PASSes end-to-end (reboot-reconnect loop included) — wire format is byte-identical to the pre-refactor serialization. Next consumers on deck: operator-side status aggregation (§12 v0.1 #3) and journald log streaming (§12 v0.1 #5), both of which need shared types across the operator/agent boundary and were the reason this extraction was prioritized. --- Cargo.lock | 11 +++ Cargo.toml | 1 + harmony/Cargo.toml | 1 + harmony/src/modules/podman/score.rs | 88 +++----------------- iot/iot-agent-v0/Cargo.toml | 1 + iot/iot-agent-v0/src/main.rs | 20 +++-- iot/iot-contracts/Cargo.toml | 16 ++++ iot/iot-contracts/src/kv.rs | 51 ++++++++++++ iot/iot-contracts/src/lib.rs | 21 +++++ iot/iot-contracts/src/score.rs | 115 ++++++++++++++++++++++++++ iot/iot-contracts/src/status.rs | 62 ++++++++++++++ iot/iot-operator-v0/Cargo.toml | 1 + iot/iot-operator-v0/src/controller.rs | 3 +- iot/iot-operator-v0/src/main.rs | 3 +- 14 files changed, 308 insertions(+), 86 deletions(-) create mode 100644 iot/iot-contracts/Cargo.toml create mode 100644 iot/iot-contracts/src/kv.rs create mode 100644 iot/iot-contracts/src/lib.rs create mode 100644 iot/iot-contracts/src/score.rs create mode 100644 iot/iot-contracts/src/status.rs diff --git a/Cargo.lock b/Cargo.lock index 16f386f1..9d070032 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3646,6 +3646,7 @@ dependencies = [ "http 1.4.0", "httptest", "inquire 0.7.5", + "iot-contracts", "k3d-rs", "k8s-openapi", "kube", @@ -4710,6 +4711,7 @@ dependencies = [ "clap", "futures-util", "harmony", + "iot-contracts", "serde", "serde_json", "tokio", @@ -4718,6 +4720,14 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "iot-contracts" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "iot-operator-v0" version = "0.1.0" @@ -4726,6 +4736,7 @@ dependencies = [ "async-nats", "clap", "futures-util", + "iot-contracts", "k8s-openapi", "kube", "schemars 0.8.22", diff --git a/Cargo.toml b/Cargo.toml index 929354b0..75914908 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ members = [ "harmony_assets", "opnsense-codegen", "opnsense-api", "iot/iot-operator-v0", "iot/iot-agent-v0", + "iot/iot-contracts", ] [workspace.package] diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 4143c5bf..b9091a0e 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -38,6 +38,7 @@ opnsense-config = { path = "../opnsense-config" } opnsense-config-xml = { path = "../opnsense-config-xml" } harmony_macros = { path = "../harmony_macros" } harmony_types = { path = "../harmony_types" } +iot-contracts = { path = "../iot/iot-contracts" } harmony_execution = { path = "../harmony_execution" } harmony-k8s = { path = "../harmony-k8s" } uuid.workspace = true diff --git a/harmony/src/modules/podman/score.rs b/harmony/src/modules/podman/score.rs index a8ee309f..9cc53325 100644 --- a/harmony/src/modules/podman/score.rs +++ b/harmony/src/modules/podman/score.rs @@ -1,4 +1,12 @@ -use serde::{Deserialize, Serialize}; +//! Harmony-side Score/Interpret bindings for IoT podman workloads. +//! +//! The pure data definitions live in the [`iot_contracts`] crate so +//! the on-device agent can depend on them without pulling in harmony. +//! What stays here are the trait impls that tie those types to +//! harmony's [`Score`] / [`Interpret`] dispatch — those can't move +//! because the trait lives in harmony. + +pub use iot_contracts::{IotScore, PodmanService, PodmanV0Score}; use crate::{ interpret::Interpret, @@ -8,41 +16,6 @@ use crate::{ use super::interpret::PodmanV0Interpret; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PodmanService { - pub name: String, - pub image: String, - pub ports: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PodmanV0Score { - pub services: Vec, -} - -impl PodmanV0Score { - /// Label value applied to every container this score creates, used by - /// [`ContainerRuntime::list_managed_services`] to enumerate the set of - /// containers that belong to a specific Score (e.g. so the agent can - /// remove them when the corresponding KV entry is deleted). The label - /// key is [`crate::modules::podman::DEPLOYMENT_LABEL`]. - /// - /// For v0 there is a single PodmanV0Score per KV key and no multi-score - /// scheduling on the device, so a stable hash over the service names - /// is an adequate identifier. When v0.1 introduces multiple scores per - /// device the caller will pass an explicit deployment id. - pub fn deployment_label(&self) -> String { - let names: Vec<&str> = self.services.iter().map(|s| s.name.as_str()).collect(); - names.join(",") - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", content = "data")] -pub enum IotScore { - PodmanV0(PodmanV0Score), -} - impl Score for PodmanV0Score { fn create_interpret(&self) -> Box> { Box::new(PodmanV0Interpret::new(self.clone())) @@ -67,42 +40,7 @@ impl Score for IotScore { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn podman_v0_score_serializes_with_adjacent_tag() { - let score = IotScore::PodmanV0(PodmanV0Score { - services: vec![PodmanService { - name: "web".to_string(), - image: "nginx:latest".to_string(), - ports: vec!["8080:80".to_string()], - }], - }); - let json = serde_json::to_string(&score).unwrap(); - assert!(json.contains("\"type\":\"PodmanV0\"")); - assert!(json.contains("\"data\"")); - } - - #[test] - fn podman_v0_score_roundtrip() { - let score = IotScore::PodmanV0(PodmanV0Score { - services: vec![ - PodmanService { - name: "web".to_string(), - image: "nginx:latest".to_string(), - ports: vec!["8080:80".to_string()], - }, - PodmanService { - name: "api".to_string(), - image: "myapp:1.0".to_string(), - ports: vec!["3000:3000".to_string(), "9090:9090".to_string()], - }, - ], - }); - let serialized = serde_json::to_string(&score).unwrap(); - let deserialized: IotScore = serde_json::from_str(&serialized).unwrap(); - assert_eq!(score, deserialized); - } -} +// Wire-format tests for the score types live with the type +// definitions in iot-contracts. The trait-impl plumbing above has +// no testable behavior beyond what rustc already checks — `create_interpret` +// just wraps the inner type. diff --git a/iot/iot-agent-v0/Cargo.toml b/iot/iot-agent-v0/Cargo.toml index c56d847d..34bdba92 100644 --- a/iot/iot-agent-v0/Cargo.toml +++ b/iot/iot-agent-v0/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" rust-version = "1.85" [dependencies] +iot-contracts = { path = "../iot-contracts" } harmony = { path = "../../harmony", default-features = false, features = ["podman"] } async-nats = { workspace = true } chrono = { workspace = true } diff --git a/iot/iot-agent-v0/src/main.rs b/iot/iot-agent-v0/src/main.rs index c75151ba..2eec8458 100644 --- a/iot/iot-agent-v0/src/main.rs +++ b/iot/iot-agent-v0/src/main.rs @@ -8,6 +8,7 @@ use anyhow::{Context, Result}; use clap::Parser; use config::{AgentConfig, CredentialSource, TomlFileCredentialSource}; use futures_util::StreamExt; +use iot_contracts::{AgentStatus, BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, status_key}; use harmony::inventory::Inventory; use harmony::modules::podman::PodmanTopology; @@ -48,7 +49,7 @@ async fn watch_desired_state( let jetstream = async_nats::jetstream::new(client); let bucket = jetstream .create_key_value(async_nats::jetstream::kv::Config { - bucket: "desired-state".to_string(), + bucket: BUCKET_DESIRED_STATE.to_string(), ..Default::default() }) .await?; @@ -86,22 +87,23 @@ async fn report_status(client: async_nats::Client, device_id: String) -> Result< let jetstream = async_nats::jetstream::new(client); let bucket = jetstream .create_key_value(async_nats::jetstream::kv::Config { - bucket: "agent-status".to_string(), + bucket: BUCKET_AGENT_STATUS.to_string(), ..Default::default() }) .await?; - let key = format!("status.{}", device_id); + let key = status_key(&device_id); let mut interval = tokio::time::interval(Duration::from_secs(30)); loop { interval.tick().await; - let status = serde_json::json!({ - "device_id": device_id, - "status": "running", - "timestamp": chrono::Utc::now().to_rfc3339(), - }); - bucket.put(&key, status.to_string().into()).await?; + let status = AgentStatus { + device_id: device_id.clone(), + status: "running".to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }; + let payload = serde_json::to_vec(&status)?; + bucket.put(&key, payload.into()).await?; tracing::debug!(key = %key, "reported status"); } } diff --git a/iot/iot-contracts/Cargo.toml b/iot/iot-contracts/Cargo.toml new file mode 100644 index 00000000..90769c96 --- /dev/null +++ b/iot/iot-contracts/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "iot-contracts" +version = "0.1.0" +edition = "2024" +license.workspace = true + +# Cross-boundary types shared between the IoT operator (k8s-side) and the +# on-device agent. Stays intentionally lean: pure serde data types, +# bucket/key constants, and small helpers. No tokio, no async-nats, no +# harmony deps — those would pull the whole framework onto a Raspberry Pi. + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] diff --git a/iot/iot-contracts/src/kv.rs b/iot/iot-contracts/src/kv.rs new file mode 100644 index 00000000..6b82a538 --- /dev/null +++ b/iot/iot-contracts/src/kv.rs @@ -0,0 +1,51 @@ +//! NATS JetStream KV bucket names and key formats. +//! +//! Hard-coded literals used to live in five places: agent `main.rs`, +//! operator `main.rs`, operator `deploy/operator.yaml`, and two smoke +//! scripts. Changing any of them meant hunting for the others. +//! They now live here; the operator, the agent, and smoke scripts all +//! consume the constants (shell scripts via `grep` patterns — see +//! `iot/scripts/smoke-*.sh`). + +/// Operator-written bucket. One entry per `(device, deployment)` pair. +/// Values are JSON-serialized [`crate::IotScore`]. +pub const BUCKET_DESIRED_STATE: &str = "desired-state"; + +/// Agent-written bucket. One entry per device at `status.`. +/// Values are JSON-serialized [`crate::AgentStatus`]. +pub const BUCKET_AGENT_STATUS: &str = "agent-status"; + +/// KV key for a `(device, deployment)` pair in [`BUCKET_DESIRED_STATE`]. +/// Format: `.`. +pub fn desired_state_key(device_id: &str, deployment_name: &str) -> String { + format!("{device_id}.{deployment_name}") +} + +/// KV key for a device's last-known status in [`BUCKET_AGENT_STATUS`]. +/// Format: `status.`. +pub fn status_key(device_id: &str) -> String { + format!("status.{device_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desired_state_key_format() { + assert_eq!(desired_state_key("pi-01", "hello-web"), "pi-01.hello-web"); + } + + #[test] + fn status_key_format() { + assert_eq!(status_key("pi-01"), "status.pi-01"); + } + + #[test] + fn bucket_names_match_smoke_scripts() { + // These strings are also grepped by iot/scripts/smoke-*.sh — + // flipping them here must be paired with a script update. + assert_eq!(BUCKET_DESIRED_STATE, "desired-state"); + assert_eq!(BUCKET_AGENT_STATUS, "agent-status"); + } +} diff --git a/iot/iot-contracts/src/lib.rs b/iot/iot-contracts/src/lib.rs new file mode 100644 index 00000000..10e6bfc9 --- /dev/null +++ b/iot/iot-contracts/src/lib.rs @@ -0,0 +1,21 @@ +//! Cross-boundary types for the NationTech IoT platform. +//! +//! These types cross the operator ↔ NATS KV ↔ agent boundary. Every +//! literal that used to be duplicated (bucket names, KV key formats, +//! the `{type, data}` score envelope, the status payload shape) lives +//! here so the three sides stay in lockstep: if the wire format +//! changes, the compiler forces every consumer to catch up. +//! +//! This crate is **deliberately lean** — no tokio, no async-nats, no +//! harmony. The on-device agent build pulls it in alongside a minimal +//! async-nats client; the operator build pulls it in alongside kube-rs; +//! harmony pulls it in as one of many modules. None of those consumers +//! should pay for the others' dependencies. + +pub mod kv; +pub mod score; +pub mod status; + +pub use kv::{BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, desired_state_key, status_key}; +pub use score::{IotScore, PodmanService, PodmanV0Score}; +pub use status::AgentStatus; diff --git a/iot/iot-contracts/src/score.rs b/iot/iot-contracts/src/score.rs new file mode 100644 index 00000000..fe39b8bb --- /dev/null +++ b/iot/iot-contracts/src/score.rs @@ -0,0 +1,115 @@ +//! Score payloads that travel operator → NATS KV → agent. +//! +//! The operator writes one JSON blob per `(device, deployment)` to the +//! `desired-state` bucket. The agent watches that bucket and +//! deserializes each value as an [`IotScore`] — an externally-tagged +//! enum whose `type` discriminator routes to a specific variant +//! (`PodmanV0`, etc.). Adding a new score variant is additive: the +//! operator stays an opaque router, the agent learns the new variant, +//! old agents harmlessly log-and-skip the unknown tag. +//! +//! The harmony framework impls `Score` / `Interpret` *on* these +//! types — the trait-bearing impls stay in harmony since they bind to +//! harmony topology traits. The pure-data definitions live here. + +use serde::{Deserialize, Serialize}; + +/// A single container managed by podman on the device. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PodmanService { + pub name: String, + pub image: String, + pub ports: Vec, +} + +/// v0 Score for podman-based workloads. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PodmanV0Score { + pub services: Vec, +} + +impl PodmanV0Score { + /// Label value applied to every container this Score creates. The + /// agent uses the label to enumerate the set of containers that + /// belong to a given KV entry (e.g. to tear them down when the + /// KV key is deleted). + /// + /// For v0 there is a single PodmanV0Score per KV key and no + /// multi-score scheduling on the device, so a stable join over the + /// service names is an adequate identifier. When v0.1 introduces + /// multiple scores per device the caller will pass an explicit + /// deployment id. + pub fn deployment_label(&self) -> String { + let names: Vec<&str> = self.services.iter().map(|s| s.name.as_str()).collect(); + names.join(",") + } +} + +/// The wire envelope agent-side: externally tagged so the JSON is +/// `{"type": "PodmanV0", "data": { ... }}`. The operator's CRD wrapper +/// (`ScorePayload`) serializes to the same shape so operator-written +/// KV values round-trip cleanly through `IotScore`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", content = "data")] +pub enum IotScore { + PodmanV0(PodmanV0Score), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn podman_v0_score_serializes_with_adjacent_tag() { + let score = IotScore::PodmanV0(PodmanV0Score { + services: vec![PodmanService { + name: "web".to_string(), + image: "nginx:latest".to_string(), + ports: vec!["8080:80".to_string()], + }], + }); + let json = serde_json::to_string(&score).unwrap(); + assert!(json.contains("\"type\":\"PodmanV0\"")); + assert!(json.contains("\"data\"")); + } + + #[test] + fn podman_v0_score_roundtrip() { + let score = IotScore::PodmanV0(PodmanV0Score { + services: vec![ + PodmanService { + name: "web".to_string(), + image: "nginx:latest".to_string(), + ports: vec!["8080:80".to_string()], + }, + PodmanService { + name: "api".to_string(), + image: "myapp:1.0".to_string(), + ports: vec!["3000:3000".to_string(), "9090:9090".to_string()], + }, + ], + }); + let serialized = serde_json::to_string(&score).unwrap(); + let deserialized: IotScore = serde_json::from_str(&serialized).unwrap(); + assert_eq!(score, deserialized); + } + + #[test] + fn deployment_label_joins_service_names() { + let score = PodmanV0Score { + services: vec![ + PodmanService { + name: "web".to_string(), + image: "nginx".to_string(), + ports: vec![], + }, + PodmanService { + name: "api".to_string(), + image: "myapp".to_string(), + ports: vec![], + }, + ], + }; + assert_eq!(score.deployment_label(), "web,api"); + } +} diff --git a/iot/iot-contracts/src/status.rs b/iot/iot-contracts/src/status.rs new file mode 100644 index 00000000..9f8c2761 --- /dev/null +++ b/iot/iot-contracts/src/status.rs @@ -0,0 +1,62 @@ +//! Agent → NATS KV status payload. +//! +//! The agent publishes a heartbeat + rollup status to the +//! `agent-status` bucket every 30 s (see +//! [`crate::BUCKET_AGENT_STATUS`]). Today the payload is intentionally +//! minimal — a single `"running"` state + a timestamp — so the +//! operator can implement §12 v0.1 "Status aggregation in operator" +//! without waiting on richer per-workload reporting. +//! +//! When the agent grows richer status (per-container state, rollout +//! progress) this struct gains fields with `#[serde(default)]`; old +//! operators keep working against newer agents. + +use serde::{Deserialize, Serialize}; + +/// A single heartbeat published by the agent at +/// `status.` in the `agent-status` bucket. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentStatus { + /// Echoed from the agent's own config so the operator can + /// cross-check which device it came from if the KV key is ever + /// ambiguous. + pub device_id: String, + /// Coarse rollup state. v0 only ever writes `"running"`; richer + /// variants are a v0.1+ concern. A String (not an enum) so old + /// operators parsing this payload don't fail on a new variant. + pub status: String, + /// RFC 3339 UTC timestamp. Used by the smoke test's reboot- + /// detection gate — any timestamp strictly greater than the gate + /// is evidence of a post-reboot write. + pub timestamp: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_roundtrip() { + let s = AgentStatus { + device_id: "pi-01".to_string(), + status: "running".to_string(), + timestamp: "2026-04-21T18:15:42Z".to_string(), + }; + let json = serde_json::to_string(&s).unwrap(); + let back: AgentStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(s, back); + } + + #[test] + fn status_has_expected_wire_keys() { + let s = AgentStatus { + device_id: "pi-01".to_string(), + status: "running".to_string(), + timestamp: "t".to_string(), + }; + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains("\"device_id\":\"pi-01\"")); + assert!(json.contains("\"status\":\"running\"")); + assert!(json.contains("\"timestamp\":\"t\"")); + } +} diff --git a/iot/iot-operator-v0/Cargo.toml b/iot/iot-operator-v0/Cargo.toml index c96e06e3..092d4163 100644 --- a/iot/iot-operator-v0/Cargo.toml +++ b/iot/iot-operator-v0/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" rust-version = "1.85" [dependencies] +iot-contracts = { path = "../iot-contracts" } kube = { workspace = true, features = ["runtime", "derive"] } k8s-openapi.workspace = true async-nats = { workspace = true } diff --git a/iot/iot-operator-v0/src/controller.rs b/iot/iot-operator-v0/src/controller.rs index 5c0eb09b..eca58d2f 100644 --- a/iot/iot-operator-v0/src/controller.rs +++ b/iot/iot-operator-v0/src/controller.rs @@ -3,6 +3,7 @@ use std::time::Duration; use async_nats::jetstream::kv::Store; use futures_util::StreamExt; +use iot_contracts::desired_state_key; use kube::api::{Patch, PatchParams}; use kube::runtime::Controller; use kube::runtime::controller::Action; @@ -127,7 +128,7 @@ fn serialize_score(score: &ScorePayload) -> Result { } fn kv_key(device_id: &str, deployment_name: &str) -> String { - format!("{device_id}.{deployment_name}") + desired_state_key(device_id, deployment_name) } fn error_policy(_obj: Arc, err: &Error, _ctx: Arc) -> Action { diff --git a/iot/iot-operator-v0/src/main.rs b/iot/iot-operator-v0/src/main.rs index 6ee5a787..4186ef2c 100644 --- a/iot/iot-operator-v0/src/main.rs +++ b/iot/iot-operator-v0/src/main.rs @@ -4,6 +4,7 @@ mod crd; use anyhow::Result; use async_nats::jetstream; use clap::{Parser, Subcommand}; +use iot_contracts::BUCKET_DESIRED_STATE; use kube::{Client, CustomResourceExt}; use crate::crd::Deployment; @@ -28,7 +29,7 @@ struct Cli { #[arg( long, env = "KV_BUCKET", - default_value = "desired-state", + default_value = BUCKET_DESIRED_STATE, global = true )] kv_bucket: String, -- 2.39.5 From 7cdf8cb5e7446efbf6d79c613cb86e4e2d4e1d82 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 21 Apr 2026 15:11:51 -0400 Subject: [PATCH 2/5] style(iot/assets): use .args([...]) for the ssh-keygen invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces an 8-link `.arg("-t").arg("ed25519").arg("-N")…` chain with a single `.args([...])` of string literals, plus one trailing `.arg()` for the `&PathBuf` (kept separate so we don't force it through the `IntoIterator` channel). No behavior change. --- harmony/src/modules/iot/assets.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/harmony/src/modules/iot/assets.rs b/harmony/src/modules/iot/assets.rs index 2e1f405e..dcbe1bf9 100644 --- a/harmony/src/modules/iot/assets.rs +++ b/harmony/src/modules/iot/assets.rs @@ -242,14 +242,16 @@ async fn provision_ssh_keypair() -> Result { info!("generating ed25519 ssh keypair at {priv_path:?} (one-time)"); let status = Command::new("ssh-keygen") - .arg("-t") - .arg("ed25519") - .arg("-N") - .arg("") // no passphrase - .arg("-C") - .arg("harmony-iot-smoke") - .arg("-f") - .arg(&priv_path) + .args([ + "-t", + "ed25519", + "-N", + "", // no passphrase + "-C", + "harmony-iot-smoke", + "-f", + ]) + .arg(&priv_path) // PathBuf — kept separate so we don't force &str conversion .stdout(Stdio::null()) .stderr(Stdio::piped()) .output() -- 2.39.5 From 0d01a71cd536e171af54d86ffcbb2011143093f1 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 21 Apr 2026 15:12:11 -0400 Subject: [PATCH 3/5] feat(iot-contracts): type AgentStatus fields with Id + DateTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AgentStatus.device_id` and `AgentStatus.timestamp` were stringly typed. Both now carry real types that prevent a whole class of wire-format typos while keeping the on-wire JSON shape intact. **device_id: String → harmony_types::id::Id** Agent config + heartbeat payload now share the same `Id` that the example IoT pipeline already uses for `IotDeviceSetupConfig`. Mixing a device id with a deployment name or arbitrary `String` is now a type error. `Id` is re-exported from `iot-contracts` so consumers don't need a direct `harmony_types` dependency just to name the field. To keep the wire format byte-compatible, `harmony_types::Id` gains `#[serde(transparent)]`. Audit: no consumer in the tree relies on the previous `{"value": "…"}` shape — `Id` is persisted by sqlite via `to_string()`, never serialized directly — so this is a latent-bug fix more than a behavior change. **timestamp: String → chrono::DateTime** The agent was calling `chrono::Utc::now().to_rfc3339()` and stuffing the String into the payload. It now holds a real `DateTime` which serde-serializes as RFC 3339 anyway. The smoke script's reboot-gate lex comparison still works: time-digit prefixes resolve before the trailing `Z` (chrono default) vs `+00:00` (prior format) difference matters. **Plumbing** - `iot/iot-agent-v0/src/config.rs`: `AgentSection.device_id: Id`. TOML deserializes the bare string thanks to `#[serde(transparent)]`. - `iot/iot-agent-v0/src/main.rs`: `watch_desired_state` and `report_status` take `Id` instead of `String`. - `iot/iot-contracts/Cargo.toml`: adds `harmony_types` path dep and `chrono = { workspace, features = ["serde"] }`. **Verification** - `cargo test -p iot-contracts`: 8/8 passes. New assertions pin the wire format: `"device_id":"pi-01"` (not `{"value":"pi-01"}`) and `"timestamp":"2026-04-21T18:15:42Z"` (RFC 3339). - x86_64 smoke-a3.sh PASSes end-to-end including the reboot- reconnect loop — wire format remains compatible with the existing smoke-script parsing. --- Cargo.lock | 2 ++ harmony_types/src/id.rs | 1 + iot/iot-agent-v0/src/config.rs | 5 ++++- iot/iot-agent-v0/src/main.rs | 10 +++++----- iot/iot-contracts/Cargo.toml | 2 ++ iot/iot-contracts/src/lib.rs | 4 ++++ iot/iot-contracts/src/status.rs | 32 ++++++++++++++++++++++---------- 7 files changed, 40 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d070032..b24436f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4724,6 +4724,8 @@ dependencies = [ name = "iot-contracts" version = "0.1.0" dependencies = [ + "chrono", + "harmony_types", "serde", "serde_json", ] diff --git a/harmony_types/src/id.rs b/harmony_types/src/id.rs index 748c1050..8411aece 100644 --- a/harmony_types/src/id.rs +++ b/harmony_types/src/id.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize}; /// **It is not meant to be very secure or unique**, it is suitable to generate up to 10 000 items per /// second with a reasonable collision rate of 0,000014 % as calculated by this calculator : https://kevingal.com/apps/collision.html #[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] pub struct Id { value: String, } diff --git a/iot/iot-agent-v0/src/config.rs b/iot/iot-agent-v0/src/config.rs index 7dd4ae73..4d79c577 100644 --- a/iot/iot-agent-v0/src/config.rs +++ b/iot/iot-agent-v0/src/config.rs @@ -1,3 +1,4 @@ +use iot_contracts::Id; use serde::Deserialize; use std::path::Path; @@ -10,7 +11,9 @@ pub struct AgentConfig { #[derive(Debug, Clone, Deserialize)] pub struct AgentSection { - pub device_id: String, + /// Cross-boundary device identity. TOML deserializes the field + /// as a bare string thanks to `#[serde(transparent)]` on `Id`. + pub device_id: Id, } #[derive(Debug, Clone, Deserialize)] diff --git a/iot/iot-agent-v0/src/main.rs b/iot/iot-agent-v0/src/main.rs index 2eec8458..9a3c8c42 100644 --- a/iot/iot-agent-v0/src/main.rs +++ b/iot/iot-agent-v0/src/main.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result}; use clap::Parser; use config::{AgentConfig, CredentialSource, TomlFileCredentialSource}; use futures_util::StreamExt; -use iot_contracts::{AgentStatus, BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, status_key}; +use iot_contracts::{AgentStatus, BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, Id, status_key}; use harmony::inventory::Inventory; use harmony::modules::podman::PodmanTopology; @@ -43,7 +43,7 @@ async fn connect_nats(cfg: &AgentConfig) -> Result { async fn watch_desired_state( client: async_nats::Client, - device_id: String, + device_id: Id, reconciler: Arc, ) -> Result<()> { let jetstream = async_nats::jetstream::new(client); @@ -83,7 +83,7 @@ async fn watch_desired_state( Ok(()) } -async fn report_status(client: async_nats::Client, device_id: String) -> Result<()> { +async fn report_status(client: async_nats::Client, device_id: Id) -> Result<()> { let jetstream = async_nats::jetstream::new(client); let bucket = jetstream .create_key_value(async_nats::jetstream::kv::Config { @@ -92,7 +92,7 @@ async fn report_status(client: async_nats::Client, device_id: String) -> Result< }) .await?; - let key = status_key(&device_id); + let key = status_key(&device_id.to_string()); let mut interval = tokio::time::interval(Duration::from_secs(30)); loop { @@ -100,7 +100,7 @@ async fn report_status(client: async_nats::Client, device_id: String) -> Result< let status = AgentStatus { device_id: device_id.clone(), status: "running".to_string(), - timestamp: chrono::Utc::now().to_rfc3339(), + timestamp: chrono::Utc::now(), }; let payload = serde_json::to_vec(&status)?; bucket.put(&key, payload.into()).await?; diff --git a/iot/iot-contracts/Cargo.toml b/iot/iot-contracts/Cargo.toml index 90769c96..d2d9751c 100644 --- a/iot/iot-contracts/Cargo.toml +++ b/iot/iot-contracts/Cargo.toml @@ -10,6 +10,8 @@ license.workspace = true # harmony deps — those would pull the whole framework onto a Raspberry Pi. [dependencies] +chrono = { workspace = true, features = ["serde"] } +harmony_types = { path = "../../harmony_types" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/iot/iot-contracts/src/lib.rs b/iot/iot-contracts/src/lib.rs index 10e6bfc9..b1efd293 100644 --- a/iot/iot-contracts/src/lib.rs +++ b/iot/iot-contracts/src/lib.rs @@ -19,3 +19,7 @@ pub mod status; pub use kv::{BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, desired_state_key, status_key}; pub use score::{IotScore, PodmanService, PodmanV0Score}; pub use status::AgentStatus; + +// Re-exports so consumers (agent, operator) don't need a direct +// harmony_types dependency purely to name the cross-boundary types. +pub use harmony_types::id::Id; diff --git a/iot/iot-contracts/src/status.rs b/iot/iot-contracts/src/status.rs index 9f8c2761..e57a1e53 100644 --- a/iot/iot-contracts/src/status.rs +++ b/iot/iot-contracts/src/status.rs @@ -11,6 +11,8 @@ //! progress) this struct gains fields with `#[serde(default)]`; old //! operators keep working against newer agents. +use chrono::{DateTime, Utc}; +use harmony_types::id::Id; use serde::{Deserialize, Serialize}; /// A single heartbeat published by the agent at @@ -19,16 +21,18 @@ use serde::{Deserialize, Serialize}; pub struct AgentStatus { /// Echoed from the agent's own config so the operator can /// cross-check which device it came from if the KV key is ever - /// ambiguous. - pub device_id: String, + /// ambiguous. Serializes transparently as a plain string. + pub device_id: Id, /// Coarse rollup state. v0 only ever writes `"running"`; richer /// variants are a v0.1+ concern. A String (not an enum) so old /// operators parsing this payload don't fail on a new variant. pub status: String, /// RFC 3339 UTC timestamp. Used by the smoke test's reboot- /// detection gate — any timestamp strictly greater than the gate - /// is evidence of a post-reboot write. - pub timestamp: String, + /// is evidence of a post-reboot write. `chrono::DateTime` + /// serde-serializes as RFC 3339, so the wire format stays + /// lex-comparable (the smoke's string `>` still works). + pub timestamp: DateTime, } #[cfg(test)] @@ -38,9 +42,11 @@ mod tests { #[test] fn status_roundtrip() { let s = AgentStatus { - device_id: "pi-01".to_string(), + device_id: Id::from("pi-01".to_string()), status: "running".to_string(), - timestamp: "2026-04-21T18:15:42Z".to_string(), + timestamp: DateTime::parse_from_rfc3339("2026-04-21T18:15:42Z") + .unwrap() + .with_timezone(&Utc), }; let json = serde_json::to_string(&s).unwrap(); let back: AgentStatus = serde_json::from_str(&json).unwrap(); @@ -50,13 +56,19 @@ mod tests { #[test] fn status_has_expected_wire_keys() { let s = AgentStatus { - device_id: "pi-01".to_string(), + device_id: Id::from("pi-01".to_string()), status: "running".to_string(), - timestamp: "t".to_string(), + timestamp: DateTime::parse_from_rfc3339("2026-04-21T18:15:42Z") + .unwrap() + .with_timezone(&Utc), }; let json = serde_json::to_string(&s).unwrap(); - assert!(json.contains("\"device_id\":\"pi-01\"")); + // device_id must serialize as a flat string (not {"value": …}). + // Relies on `#[serde(transparent)]` on `harmony_types::id::Id`. + assert!(json.contains("\"device_id\":\"pi-01\""), "got {json}"); assert!(json.contains("\"status\":\"running\"")); - assert!(json.contains("\"timestamp\":\"t\"")); + // RFC 3339 output — the smoke script greps a `"timestamp":""` + // literal and compares lexicographically against a gate. + assert!(json.contains("\"timestamp\":\"2026-04-21T18:15:42Z\"")); } } -- 2.39.5 From 954b1271521212c1319796b9393074a86e932135 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 21 Apr 2026 15:36:16 -0400 Subject: [PATCH 4/5] refactor(podman): move PodmanV0Score back into harmony::modules::podman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: `ContainerRuntime` is a first-class harmony capability (already lives at `harmony/src/domain/topology/container_runtime.rs`) and the Score types that describe what containers a caller wants running belong next to the trait impls, not hidden in an IoT-labeled contracts crate. Putting `PodmanService`, `PodmanV0Score`, and `IotScore` in `iot-contracts` conflated the product-shape (IoT fleet agent) with a reusable container-orchestration primitive. Move the data definitions (plus the three serde tests) back to `harmony/src/modules/podman/score.rs` where they were before the extraction in commit 24b94a3. That file now again holds the types and their `Score` / `Interpret` trait impls in one place. No behavior change: - `harmony::modules::podman::{IotScore, PodmanV0Score, PodmanService}` re-exports still resolve (through the restored local module rather than a forwarded re-export from iot-contracts). - The single external consumer that imports these types — `iot-agent-v0/src/reconciler.rs` — already went through `harmony::modules::podman::*`, so no import flip needed. iot-contracts now holds only the cross-boundary bits that are genuinely reconciler-wire-format-specific (bucket names + key helpers, `AgentStatus`, `Id` re-export). A follow-up commit will rename the crate itself to reflect that scope. Verification: `cargo test -p harmony --features podman --lib podman` (3 score tests pass in their restored home), `cargo test -p iot-contracts` (5 remaining tests), `cargo check --all-features` clean. --- harmony/src/modules/podman/score.rs | 119 +++++++++++++++++++++++++--- iot/iot-contracts/src/kv.rs | 4 +- iot/iot-contracts/src/lib.rs | 2 - iot/iot-contracts/src/score.rs | 115 --------------------------- 4 files changed, 111 insertions(+), 129 deletions(-) delete mode 100644 iot/iot-contracts/src/score.rs diff --git a/harmony/src/modules/podman/score.rs b/harmony/src/modules/podman/score.rs index 9cc53325..e795cf0c 100644 --- a/harmony/src/modules/podman/score.rs +++ b/harmony/src/modules/podman/score.rs @@ -1,12 +1,13 @@ -//! Harmony-side Score/Interpret bindings for IoT podman workloads. +//! Podman Score types and their `Score` / `Interpret` bindings. //! -//! The pure data definitions live in the [`iot_contracts`] crate so -//! the on-device agent can depend on them without pulling in harmony. -//! What stays here are the trait impls that tie those types to -//! harmony's [`Score`] / [`Interpret`] dispatch — those can't move -//! because the trait lives in harmony. +//! `ContainerRuntime` is a first-class harmony capability (see +//! [`crate::topology::ContainerRuntime`]); the types that describe a +//! set of containers a caller wants running live here next to the +//! trait impls that route them to interpretation. These types are +//! independent of any particular product (IoT fleet, OKD fleet, etc.) +//! — callers serialize them over whatever transport they like. -pub use iot_contracts::{IotScore, PodmanService, PodmanV0Score}; +use serde::{Deserialize, Serialize}; use crate::{ interpret::Interpret, @@ -16,6 +17,48 @@ use crate::{ use super::interpret::PodmanV0Interpret; +/// A single container managed by podman on the target host. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PodmanService { + pub name: String, + pub image: String, + pub ports: Vec, +} + +/// v0 Score for podman-based workloads. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PodmanV0Score { + pub services: Vec, +} + +impl PodmanV0Score { + /// Label value applied to every container this Score creates. The + /// caller uses the label to enumerate the set of containers that + /// belong to a given Score instance (e.g. to tear them down when + /// the corresponding desired-state entry is deleted). + /// + /// For v0 there is a single PodmanV0Score per desired-state key + /// and no multi-score scheduling on the target host, so a stable + /// join over the service names is an adequate identifier. When + /// v0.1 introduces multiple scores per host the caller will pass + /// an explicit deployment id. + pub fn deployment_label(&self) -> String { + let names: Vec<&str> = self.services.iter().map(|s| s.name.as_str()).collect(); + names.join(",") + } +} + +/// Wire envelope for a reconciler-style Score payload: externally +/// tagged so the JSON is `{"type": "PodmanV0", "data": { ... }}`. +/// Adding a new variant is additive — emitters stay opaque routers, +/// consumers learn the new variant, older consumers harmlessly +/// log-and-skip the unknown tag. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", content = "data")] +pub enum IotScore { + PodmanV0(PodmanV0Score), +} + impl Score for PodmanV0Score { fn create_interpret(&self) -> Box> { Box::new(PodmanV0Interpret::new(self.clone())) @@ -40,7 +83,61 @@ impl Score for IotScore { } } -// Wire-format tests for the score types live with the type -// definitions in iot-contracts. The trait-impl plumbing above has -// no testable behavior beyond what rustc already checks — `create_interpret` -// just wraps the inner type. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn podman_v0_score_serializes_with_adjacent_tag() { + let score = IotScore::PodmanV0(PodmanV0Score { + services: vec![PodmanService { + name: "web".to_string(), + image: "nginx:latest".to_string(), + ports: vec!["8080:80".to_string()], + }], + }); + let json = serde_json::to_string(&score).unwrap(); + assert!(json.contains("\"type\":\"PodmanV0\"")); + assert!(json.contains("\"data\"")); + } + + #[test] + fn podman_v0_score_roundtrip() { + let score = IotScore::PodmanV0(PodmanV0Score { + services: vec![ + PodmanService { + name: "web".to_string(), + image: "nginx:latest".to_string(), + ports: vec!["8080:80".to_string()], + }, + PodmanService { + name: "api".to_string(), + image: "myapp:1.0".to_string(), + ports: vec!["3000:3000".to_string(), "9090:9090".to_string()], + }, + ], + }); + let serialized = serde_json::to_string(&score).unwrap(); + let deserialized: IotScore = serde_json::from_str(&serialized).unwrap(); + assert_eq!(score, deserialized); + } + + #[test] + fn deployment_label_joins_service_names() { + let score = PodmanV0Score { + services: vec![ + PodmanService { + name: "web".to_string(), + image: "nginx".to_string(), + ports: vec![], + }, + PodmanService { + name: "api".to_string(), + image: "myapp".to_string(), + ports: vec![], + }, + ], + }; + assert_eq!(score.deployment_label(), "web,api"); + } +} diff --git a/iot/iot-contracts/src/kv.rs b/iot/iot-contracts/src/kv.rs index 6b82a538..f2b0571b 100644 --- a/iot/iot-contracts/src/kv.rs +++ b/iot/iot-contracts/src/kv.rs @@ -8,7 +8,9 @@ //! `iot/scripts/smoke-*.sh`). /// Operator-written bucket. One entry per `(device, deployment)` pair. -/// Values are JSON-serialized [`crate::IotScore`]. +/// Values are the JSON-serialized Score envelope — today +/// `harmony::modules::podman::IotScore`, tomorrow any variant of +/// a polymorphic `Score` enum the framework ships. pub const BUCKET_DESIRED_STATE: &str = "desired-state"; /// Agent-written bucket. One entry per device at `status.`. diff --git a/iot/iot-contracts/src/lib.rs b/iot/iot-contracts/src/lib.rs index b1efd293..80afce57 100644 --- a/iot/iot-contracts/src/lib.rs +++ b/iot/iot-contracts/src/lib.rs @@ -13,11 +13,9 @@ //! should pay for the others' dependencies. pub mod kv; -pub mod score; pub mod status; pub use kv::{BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, desired_state_key, status_key}; -pub use score::{IotScore, PodmanService, PodmanV0Score}; pub use status::AgentStatus; // Re-exports so consumers (agent, operator) don't need a direct diff --git a/iot/iot-contracts/src/score.rs b/iot/iot-contracts/src/score.rs deleted file mode 100644 index fe39b8bb..00000000 --- a/iot/iot-contracts/src/score.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Score payloads that travel operator → NATS KV → agent. -//! -//! The operator writes one JSON blob per `(device, deployment)` to the -//! `desired-state` bucket. The agent watches that bucket and -//! deserializes each value as an [`IotScore`] — an externally-tagged -//! enum whose `type` discriminator routes to a specific variant -//! (`PodmanV0`, etc.). Adding a new score variant is additive: the -//! operator stays an opaque router, the agent learns the new variant, -//! old agents harmlessly log-and-skip the unknown tag. -//! -//! The harmony framework impls `Score` / `Interpret` *on* these -//! types — the trait-bearing impls stay in harmony since they bind to -//! harmony topology traits. The pure-data definitions live here. - -use serde::{Deserialize, Serialize}; - -/// A single container managed by podman on the device. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PodmanService { - pub name: String, - pub image: String, - pub ports: Vec, -} - -/// v0 Score for podman-based workloads. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PodmanV0Score { - pub services: Vec, -} - -impl PodmanV0Score { - /// Label value applied to every container this Score creates. The - /// agent uses the label to enumerate the set of containers that - /// belong to a given KV entry (e.g. to tear them down when the - /// KV key is deleted). - /// - /// For v0 there is a single PodmanV0Score per KV key and no - /// multi-score scheduling on the device, so a stable join over the - /// service names is an adequate identifier. When v0.1 introduces - /// multiple scores per device the caller will pass an explicit - /// deployment id. - pub fn deployment_label(&self) -> String { - let names: Vec<&str> = self.services.iter().map(|s| s.name.as_str()).collect(); - names.join(",") - } -} - -/// The wire envelope agent-side: externally tagged so the JSON is -/// `{"type": "PodmanV0", "data": { ... }}`. The operator's CRD wrapper -/// (`ScorePayload`) serializes to the same shape so operator-written -/// KV values round-trip cleanly through `IotScore`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", content = "data")] -pub enum IotScore { - PodmanV0(PodmanV0Score), -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn podman_v0_score_serializes_with_adjacent_tag() { - let score = IotScore::PodmanV0(PodmanV0Score { - services: vec![PodmanService { - name: "web".to_string(), - image: "nginx:latest".to_string(), - ports: vec!["8080:80".to_string()], - }], - }); - let json = serde_json::to_string(&score).unwrap(); - assert!(json.contains("\"type\":\"PodmanV0\"")); - assert!(json.contains("\"data\"")); - } - - #[test] - fn podman_v0_score_roundtrip() { - let score = IotScore::PodmanV0(PodmanV0Score { - services: vec![ - PodmanService { - name: "web".to_string(), - image: "nginx:latest".to_string(), - ports: vec!["8080:80".to_string()], - }, - PodmanService { - name: "api".to_string(), - image: "myapp:1.0".to_string(), - ports: vec!["3000:3000".to_string(), "9090:9090".to_string()], - }, - ], - }); - let serialized = serde_json::to_string(&score).unwrap(); - let deserialized: IotScore = serde_json::from_str(&serialized).unwrap(); - assert_eq!(score, deserialized); - } - - #[test] - fn deployment_label_joins_service_names() { - let score = PodmanV0Score { - services: vec![ - PodmanService { - name: "web".to_string(), - image: "nginx".to_string(), - ports: vec![], - }, - PodmanService { - name: "api".to_string(), - image: "myapp".to_string(), - ports: vec![], - }, - ], - }; - assert_eq!(score.deployment_label(), "web,api"); - } -} -- 2.39.5 From 75c3ef9bb888ee2e99302b107a12f7c2597a71f7 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 21 Apr 2026 15:42:07 -0400 Subject: [PATCH 5/5] =?UTF-8?q?refactor(reconciler):=20rename=20iot-contra?= =?UTF-8?q?cts=20=E2=86=92=20harmony-reconciler-contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: "iot" is the wrong scope label. The pattern this crate encodes — a central operator writing desired state to NATS JetStream KV, a remote agent watching KV and reconciling — is the foundation for harmony's decentralized infrastructure management, not an IoT thing. Raspberry Pi is one concrete use case; the next consumers (OKD fleet agents, edge-compute reconcilers, any host harmony can't reach directly over a control-plane API) aren't IoT either. Rename the crate to reflect what it actually is: - `iot/iot-contracts/` → `harmony-reconciler-contracts/` (moved to the repo root, alongside the other support crates). - Package name `iot-contracts` → `harmony-reconciler-contracts`. - Consumer `Cargo.toml` path references updated in operator, agent. - `use iot_contracts::…` → `use harmony_reconciler_contracts::…` across agent + operator sources. - Crate-level prose in lib.rs + kv.rs rewritten to drop the IoT framing and describe the reconciler pattern in its own terms. - harmony/Cargo.toml drops the dep entirely — after the preceding commit moved podman Score types back in-tree, harmony no longer pulls anything from this crate. No behavior change. Wire format unchanged — the two existing public modules (`kv`, `status`) are byte-identical. Verified: - `cargo check --all-targets --all-features` clean. - `cargo test -p harmony-reconciler-contracts` — 5/5 pass. - x86_64 `smoke-a3.sh` end-to-end PASS (reboot-reconnect included). Out of scope / follow-up: the operator and agent crate names (`iot-operator-v0`, `iot-agent-v0`) and `IotScore` are still IoT-branded. Evaluating whether to flip those in this branch next. --- Cargo.lock | 25 +++++++-------- Cargo.toml | 2 +- harmony-reconciler-contracts/Cargo.toml | 20 ++++++++++++ .../src/kv.rs | 14 ++++----- harmony-reconciler-contracts/src/lib.rs | 31 +++++++++++++++++++ .../src/status.rs | 0 harmony/Cargo.toml | 1 - iot/iot-agent-v0/Cargo.toml | 2 +- iot/iot-agent-v0/src/config.rs | 2 +- iot/iot-agent-v0/src/main.rs | 4 ++- iot/iot-contracts/Cargo.toml | 18 ----------- iot/iot-contracts/src/lib.rs | 23 -------------- iot/iot-operator-v0/Cargo.toml | 2 +- iot/iot-operator-v0/src/controller.rs | 2 +- iot/iot-operator-v0/src/main.rs | 2 +- 15 files changed, 79 insertions(+), 69 deletions(-) create mode 100644 harmony-reconciler-contracts/Cargo.toml rename {iot/iot-contracts => harmony-reconciler-contracts}/src/kv.rs (77%) create mode 100644 harmony-reconciler-contracts/src/lib.rs rename {iot/iot-contracts => harmony-reconciler-contracts}/src/status.rs (100%) delete mode 100644 iot/iot-contracts/Cargo.toml delete mode 100644 iot/iot-contracts/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b24436f8..c5cd3715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3646,7 +3646,6 @@ dependencies = [ "http 1.4.0", "httptest", "inquire 0.7.5", - "iot-contracts", "k3d-rs", "k8s-openapi", "kube", @@ -3727,6 +3726,16 @@ dependencies = [ "tower", ] +[[package]] +name = "harmony-reconciler-contracts" +version = "0.1.0" +dependencies = [ + "chrono", + "harmony_types", + "serde", + "serde_json", +] + [[package]] name = "harmony_agent" version = "0.1.0" @@ -4711,7 +4720,7 @@ dependencies = [ "clap", "futures-util", "harmony", - "iot-contracts", + "harmony-reconciler-contracts", "serde", "serde_json", "tokio", @@ -4720,16 +4729,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "iot-contracts" -version = "0.1.0" -dependencies = [ - "chrono", - "harmony_types", - "serde", - "serde_json", -] - [[package]] name = "iot-operator-v0" version = "0.1.0" @@ -4738,7 +4737,7 @@ dependencies = [ "async-nats", "clap", "futures-util", - "iot-contracts", + "harmony-reconciler-contracts", "k8s-openapi", "kube", "schemars 0.8.22", diff --git a/Cargo.toml b/Cargo.toml index 75914908..1e9eeaf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ members = [ "harmony_assets", "opnsense-codegen", "opnsense-api", "iot/iot-operator-v0", "iot/iot-agent-v0", - "iot/iot-contracts", + "harmony-reconciler-contracts", ] [workspace.package] diff --git a/harmony-reconciler-contracts/Cargo.toml b/harmony-reconciler-contracts/Cargo.toml new file mode 100644 index 00000000..fc52cdb7 --- /dev/null +++ b/harmony-reconciler-contracts/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "harmony-reconciler-contracts" +version = "0.1.0" +edition = "2024" +license.workspace = true + +# Cross-boundary types shared between a harmony operator (central, +# writing desired state to NATS JetStream KV) and a harmony agent +# (on-host, watching KV and reconciling). Deliberately lean: pure +# serde data types, bucket/key constants, small helpers. No tokio, +# no async-nats, no harmony. The on-device agent build pulls this +# in alongside a minimal async-nats client; the operator pulls it +# alongside kube-rs; harmony itself treats it as just another +# module. None of those consumers should pay for the others' deps. + +[dependencies] +chrono = { workspace = true, features = ["serde"] } +harmony_types = { path = "../harmony_types" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/iot/iot-contracts/src/kv.rs b/harmony-reconciler-contracts/src/kv.rs similarity index 77% rename from iot/iot-contracts/src/kv.rs rename to harmony-reconciler-contracts/src/kv.rs index f2b0571b..c773eba4 100644 --- a/iot/iot-contracts/src/kv.rs +++ b/harmony-reconciler-contracts/src/kv.rs @@ -1,11 +1,11 @@ -//! NATS JetStream KV bucket names and key formats. +//! NATS JetStream KV bucket names and key formats used by the +//! harmony reconciler pattern. //! -//! Hard-coded literals used to live in five places: agent `main.rs`, -//! operator `main.rs`, operator `deploy/operator.yaml`, and two smoke -//! scripts. Changing any of them meant hunting for the others. -//! They now live here; the operator, the agent, and smoke scripts all -//! consume the constants (shell scripts via `grep` patterns — see -//! `iot/scripts/smoke-*.sh`). +//! Hard-coded literals previously lived in five places (agent and +//! operator main.rs, operator deploy YAML, two smoke scripts). +//! Changing any of them meant hunting for the others. They now live +//! here; agent + operator consume the constants directly, and smoke +//! scripts grep for the literal values locked in the tests below. /// Operator-written bucket. One entry per `(device, deployment)` pair. /// Values are the JSON-serialized Score envelope — today diff --git a/harmony-reconciler-contracts/src/lib.rs b/harmony-reconciler-contracts/src/lib.rs new file mode 100644 index 00000000..24aeeae4 --- /dev/null +++ b/harmony-reconciler-contracts/src/lib.rs @@ -0,0 +1,31 @@ +//! Cross-boundary types for harmony's reconciler pattern. +//! +//! Harmony's "reconciler" pattern is: a central **operator** writes +//! desired state into NATS JetStream KV; a remote **agent** watches +//! the KV, deserializes each entry as a Score, and drives the host +//! toward that state. This split lets one operator orchestrate a +//! fleet of agents across network boundaries it can't reach +//! directly — IoT devices today, OKD cluster agents or edge-compute +//! reconcilers tomorrow. +//! +//! This crate holds the wire-format bits both sides must agree on: +//! NATS bucket names, KV key formats, and the `AgentStatus` +//! heartbeat payload. The Score types themselves (`PodmanV0Score`, +//! future variants) live in their respective harmony modules — +//! consumers import them from there and serialize them over the +//! transport this crate describes. +//! +//! **Deliberately lean** — no tokio, no async-nats, no harmony. +//! The on-device agent build pulls it in alongside a minimal +//! async-nats client; the operator pulls it alongside kube-rs. +//! Neither should pay for the other's dependencies. + +pub mod kv; +pub mod status; + +pub use kv::{BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, desired_state_key, status_key}; +pub use status::AgentStatus; + +// Re-exports so consumers (agent, operator) don't need a direct +// harmony_types dependency purely to name the cross-boundary types. +pub use harmony_types::id::Id; diff --git a/iot/iot-contracts/src/status.rs b/harmony-reconciler-contracts/src/status.rs similarity index 100% rename from iot/iot-contracts/src/status.rs rename to harmony-reconciler-contracts/src/status.rs diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index b9091a0e..4143c5bf 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -38,7 +38,6 @@ opnsense-config = { path = "../opnsense-config" } opnsense-config-xml = { path = "../opnsense-config-xml" } harmony_macros = { path = "../harmony_macros" } harmony_types = { path = "../harmony_types" } -iot-contracts = { path = "../iot/iot-contracts" } harmony_execution = { path = "../harmony_execution" } harmony-k8s = { path = "../harmony-k8s" } uuid.workspace = true diff --git a/iot/iot-agent-v0/Cargo.toml b/iot/iot-agent-v0/Cargo.toml index 34bdba92..f90e9e65 100644 --- a/iot/iot-agent-v0/Cargo.toml +++ b/iot/iot-agent-v0/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" rust-version = "1.85" [dependencies] -iot-contracts = { path = "../iot-contracts" } +harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" } harmony = { path = "../../harmony", default-features = false, features = ["podman"] } async-nats = { workspace = true } chrono = { workspace = true } diff --git a/iot/iot-agent-v0/src/config.rs b/iot/iot-agent-v0/src/config.rs index 4d79c577..e0c8291f 100644 --- a/iot/iot-agent-v0/src/config.rs +++ b/iot/iot-agent-v0/src/config.rs @@ -1,4 +1,4 @@ -use iot_contracts::Id; +use harmony_reconciler_contracts::Id; use serde::Deserialize; use std::path::Path; diff --git a/iot/iot-agent-v0/src/main.rs b/iot/iot-agent-v0/src/main.rs index 9a3c8c42..2d386aab 100644 --- a/iot/iot-agent-v0/src/main.rs +++ b/iot/iot-agent-v0/src/main.rs @@ -8,7 +8,9 @@ use anyhow::{Context, Result}; use clap::Parser; use config::{AgentConfig, CredentialSource, TomlFileCredentialSource}; use futures_util::StreamExt; -use iot_contracts::{AgentStatus, BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, Id, status_key}; +use harmony_reconciler_contracts::{ + AgentStatus, BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, Id, status_key, +}; use harmony::inventory::Inventory; use harmony::modules::podman::PodmanTopology; diff --git a/iot/iot-contracts/Cargo.toml b/iot/iot-contracts/Cargo.toml deleted file mode 100644 index d2d9751c..00000000 --- a/iot/iot-contracts/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "iot-contracts" -version = "0.1.0" -edition = "2024" -license.workspace = true - -# Cross-boundary types shared between the IoT operator (k8s-side) and the -# on-device agent. Stays intentionally lean: pure serde data types, -# bucket/key constants, and small helpers. No tokio, no async-nats, no -# harmony deps — those would pull the whole framework onto a Raspberry Pi. - -[dependencies] -chrono = { workspace = true, features = ["serde"] } -harmony_types = { path = "../../harmony_types" } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } - -[dev-dependencies] diff --git a/iot/iot-contracts/src/lib.rs b/iot/iot-contracts/src/lib.rs deleted file mode 100644 index 80afce57..00000000 --- a/iot/iot-contracts/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Cross-boundary types for the NationTech IoT platform. -//! -//! These types cross the operator ↔ NATS KV ↔ agent boundary. Every -//! literal that used to be duplicated (bucket names, KV key formats, -//! the `{type, data}` score envelope, the status payload shape) lives -//! here so the three sides stay in lockstep: if the wire format -//! changes, the compiler forces every consumer to catch up. -//! -//! This crate is **deliberately lean** — no tokio, no async-nats, no -//! harmony. The on-device agent build pulls it in alongside a minimal -//! async-nats client; the operator build pulls it in alongside kube-rs; -//! harmony pulls it in as one of many modules. None of those consumers -//! should pay for the others' dependencies. - -pub mod kv; -pub mod status; - -pub use kv::{BUCKET_AGENT_STATUS, BUCKET_DESIRED_STATE, desired_state_key, status_key}; -pub use status::AgentStatus; - -// Re-exports so consumers (agent, operator) don't need a direct -// harmony_types dependency purely to name the cross-boundary types. -pub use harmony_types::id::Id; diff --git a/iot/iot-operator-v0/Cargo.toml b/iot/iot-operator-v0/Cargo.toml index 092d4163..0ebafed7 100644 --- a/iot/iot-operator-v0/Cargo.toml +++ b/iot/iot-operator-v0/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" rust-version = "1.85" [dependencies] -iot-contracts = { path = "../iot-contracts" } +harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" } kube = { workspace = true, features = ["runtime", "derive"] } k8s-openapi.workspace = true async-nats = { workspace = true } diff --git a/iot/iot-operator-v0/src/controller.rs b/iot/iot-operator-v0/src/controller.rs index eca58d2f..54cc37a2 100644 --- a/iot/iot-operator-v0/src/controller.rs +++ b/iot/iot-operator-v0/src/controller.rs @@ -3,7 +3,7 @@ use std::time::Duration; use async_nats::jetstream::kv::Store; use futures_util::StreamExt; -use iot_contracts::desired_state_key; +use harmony_reconciler_contracts::desired_state_key; use kube::api::{Patch, PatchParams}; use kube::runtime::Controller; use kube::runtime::controller::Action; diff --git a/iot/iot-operator-v0/src/main.rs b/iot/iot-operator-v0/src/main.rs index 4186ef2c..230a639f 100644 --- a/iot/iot-operator-v0/src/main.rs +++ b/iot/iot-operator-v0/src/main.rs @@ -4,7 +4,7 @@ mod crd; use anyhow::Result; use async_nats::jetstream; use clap::{Parser, Subcommand}; -use iot_contracts::BUCKET_DESIRED_STATE; +use harmony_reconciler_contracts::BUCKET_DESIRED_STATE; use kube::{Client, CustomResourceExt}; use crate::crd::Deployment; -- 2.39.5