From 95ccc974f92394cc13d6d586efd0fb26d52b3ada Mon Sep 17 00:00:00 2001 From: Sylvain Tremblay Date: Tue, 28 Apr 2026 07:59:05 -0400 Subject: [PATCH] refactor(fleet-operator): replace ScorePayload with ReconcileScore in Deployment CRD Removes the hand-typed ScorePayload struct and its custom schemars schema function. DeploymentSpec.score is now typed as the strongly typed ReconcileScore enum already used by the agent, eliminating duplication and ensuring the CRD schema is derived automatically. - Add JsonSchema derive to PodmanService, PodmanV0Score, ReconcileScore - Enable podman feature on harmony dependency in operator - Re-export ReconcileScore/PodmanV0Score/PodmanService from crd module - Update harmony_apply_deployment and fleet_load_test examples - Remove TODO comment from harmony_apply_deployment Wire format is unchanged (externally tagged {type, data}), so the operator -> NATS KV -> agent path remains fully backward compatible. --- examples/fleet_load_test/src/main.rs | 23 +++--- examples/harmony_apply_deployment/src/main.rs | 15 +--- fleet/harmony-fleet-operator/Cargo.toml | 4 +- fleet/harmony-fleet-operator/src/crd.rs | 70 +------------------ fleet/harmony-fleet-operator/src/lib.rs | 2 +- .../src/domain/topology/container_runtime.rs | 5 +- harmony/src/modules/podman/score.rs | 7 +- 7 files changed, 28 insertions(+), 98 deletions(-) diff --git a/examples/fleet_load_test/src/main.rs b/examples/fleet_load_test/src/main.rs index 0761f3dd..273a46c0 100644 --- a/examples/fleet_load_test/src/main.rs +++ b/examples/fleet_load_test/src/main.rs @@ -29,7 +29,8 @@ use async_nats::jetstream::{self, kv}; use chrono::Utc; use clap::Parser; use harmony_fleet_operator::crd::{ - Deployment, DeploymentSpec, Rollout, RolloutStrategy, ScorePayload, + Deployment, DeploymentSpec, PodmanService, PodmanV0Score, ReconcileScore, Rollout, + RolloutStrategy, }; use harmony_reconciler_contracts::{ BUCKET_DEVICE_HEARTBEAT, BUCKET_DEVICE_INFO, BUCKET_DEVICE_STATE, DeploymentName, @@ -415,16 +416,16 @@ async fn apply_one_cr( // the desired-state here. The aggregator still writes KV // for each matched device; that's wire noise we accept // as part of the realism. - score: ScorePayload { - type_: "PodmanV0".to_string(), - data: serde_json::json!({ - "services": [{ - "name": group.cr_name, - "image": "docker.io/library/nginx:alpine", - "ports": ["8080:80"], - }], - }), - }, + score: ReconcileScore::PodmanV0(PodmanV0Score { + services: vec![PodmanService { + name: group.cr_name.clone(), + image: "docker.io/library/nginx:alpine".to_string(), + ports: vec!["8080:80".to_string()], + env: vec![], + volumes: vec![], + restart_policy: Default::default(), + }], + }), rollout: Rollout { strategy: RolloutStrategy::Immediate, }, diff --git a/examples/harmony_apply_deployment/src/main.rs b/examples/harmony_apply_deployment/src/main.rs index 976cf599..01c299d7 100644 --- a/examples/harmony_apply_deployment/src/main.rs +++ b/examples/harmony_apply_deployment/src/main.rs @@ -38,11 +38,9 @@ use anyhow::{Context, Result}; use clap::Parser; -use harmony::modules::podman::{PodmanService, PodmanV0Score}; +use harmony::modules::podman::{PodmanService, PodmanV0Score, ReconcileScore}; use harmony::topology::{RestartPolicy, VolumeMount}; -use harmony_fleet_operator::crd::{ - Deployment, DeploymentSpec, Rollout, RolloutStrategy, ScorePayload, -}; +use harmony_fleet_operator::crd::{Deployment, DeploymentSpec, Rollout, RolloutStrategy}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use kube::Client; use kube::api::{Api, DeleteParams, Patch, PatchParams}; @@ -209,14 +207,7 @@ fn build_cr(cli: &Cli) -> Deployment { }], }; - let payload = ScorePayload { - type_: "PodmanV0".to_string(), - // `ScorePayload::data` is `serde_json::Value` by design - // (opaque payload routed to the agent). Serialize the typed - // score through serde_json — the agent's `ReconcileScore` enum - // accepts exactly this shape via `#[serde(tag, content)]`. - data: serde_json::to_value(&score).expect("PodmanV0Score is JSON-clean"), - }; + let payload = ReconcileScore::PodmanV0(score); let mut match_labels = BTreeMap::new(); if cli.selectors.is_empty() { diff --git a/fleet/harmony-fleet-operator/Cargo.toml b/fleet/harmony-fleet-operator/Cargo.toml index 778b584e..ac8883d3 100644 --- a/fleet/harmony-fleet-operator/Cargo.toml +++ b/fleet/harmony-fleet-operator/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" rust-version = "1.85" [dependencies] -harmony = { path = "../../harmony" } +harmony = { path = "../../harmony", features = ["podman"] } harmony-fleet-auth = { path = "../harmony-fleet-auth" } harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" } toml = { workspace = true } @@ -22,4 +22,4 @@ tracing-subscriber = { workspace = true } anyhow.workspace = true clap.workspace = true futures-util = { workspace = true } -thiserror.workspace = true \ No newline at end of file +thiserror.workspace = true diff --git a/fleet/harmony-fleet-operator/src/crd.rs b/fleet/harmony-fleet-operator/src/crd.rs index 0399af82..049d1d99 100644 --- a/fleet/harmony-fleet-operator/src/crd.rs +++ b/fleet/harmony-fleet-operator/src/crd.rs @@ -2,11 +2,10 @@ use harmony_reconciler_contracts::InventorySnapshot; use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use kube::CustomResource; use schemars::JsonSchema; -use schemars::schema::{ - InstanceType, ObjectValidation, Schema, SchemaObject, SingleOrVec, StringValidation, -}; use serde::{Deserialize, Serialize}; +pub use harmony::modules::podman::{PodmanService, PodmanV0Score, ReconcileScore}; + /// Deployment intent. Targets devices by label selector — identical /// to the pattern K8s itself uses for DaemonSet nodeSelector, Service /// pod selector, etc. The operator resolves the selector against @@ -26,73 +25,10 @@ pub struct DeploymentSpec { /// Which devices this deployment targets. matches against /// `Device.metadata.labels`. pub target_selector: LabelSelector, - #[schemars(schema_with = "score_payload_schema")] - pub score: ScorePayload, + pub score: ReconcileScore, pub rollout: Rollout, } -#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)] -pub struct ScorePayload { - #[serde(rename = "type")] - pub type_: String, - pub data: serde_json::Value, -} - -/// Hand-rolled schema for `ScorePayload` so we can attach two apiserver -/// concessions that `schemars` can't derive: -/// -/// 1. `x-kubernetes-preserve-unknown-fields: true` on `data` — the payload -/// is routed opaquely; its shape is enforced on-device by the agent's -/// typed `ReconcileScore` deserialization, not by the apiserver. -/// 2. An `x-kubernetes-validations` CEL rule on the enclosing `score` object -/// requiring `type` to be a valid Rust identifier, so typos (`"pdoman"`) -/// are rejected at `kubectl apply` time rather than silently reaching -/// the agent. -fn score_payload_schema(_: &mut schemars::r#gen::SchemaGenerator) -> Schema { - let type_schema = Schema::Object(SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), - string: Some(Box::new(StringValidation { - min_length: Some(1), - ..Default::default() - })), - ..Default::default() - }); - - let mut data_schema = SchemaObject::default(); - data_schema.extensions.insert( - "x-kubernetes-preserve-unknown-fields".to_string(), - serde_json::Value::Bool(true), - ); - - let object = ObjectValidation { - required: ["type".to_string(), "data".to_string()] - .into_iter() - .collect(), - properties: [ - ("type".to_string(), type_schema), - ("data".to_string(), Schema::Object(data_schema)), - ] - .into_iter() - .collect(), - ..Default::default() - }; - - let mut obj = SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Object))), - object: Some(Box::new(object)), - ..Default::default() - }; - obj.extensions.insert( - "x-kubernetes-validations".to_string(), - serde_json::json!([{ - "rule": "self.type.matches('^[A-Za-z_][A-Za-z0-9_]*$')", - "message": "score.type must be a valid Rust identifier matching the struct name of the score variant (e.g. PodmanV0)" - }]), - ); - - Schema::Object(obj) -} - #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)] pub struct Rollout { pub strategy: RolloutStrategy, diff --git a/fleet/harmony-fleet-operator/src/lib.rs b/fleet/harmony-fleet-operator/src/lib.rs index fa88ae2f..430d15b4 100644 --- a/fleet/harmony-fleet-operator/src/lib.rs +++ b/fleet/harmony-fleet-operator/src/lib.rs @@ -4,7 +4,7 @@ //! The CRD type definitions are exposed here as a library so external //! consumers — tooling that applies CRs, tests, documentation generators //! — can import the typed `Deployment`, `DeploymentSpec`, -//! `ScorePayload`, etc. without duplicating them. +//! `ReconcileScore`, etc. without duplicating them. pub mod chart; pub mod crd; diff --git a/harmony/src/domain/topology/container_runtime.rs b/harmony/src/domain/topology/container_runtime.rs index 821b5abe..9e9fbd1d 100644 --- a/harmony/src/domain/topology/container_runtime.rs +++ b/harmony/src/domain/topology/container_runtime.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::executors::ExecutorError; @@ -76,7 +77,7 @@ impl ContainerSpec { /// A single host-path → container-path bind mount. Bind mounts are the only /// volume kind supported in v0 — they cover ~95% of compose use cases and /// don't depend on a runtime-managed volume namespace. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, JsonSchema, Eq, Serialize, Deserialize)] pub struct VolumeMount { /// Absolute path on the host. pub host_path: String, @@ -90,7 +91,7 @@ pub struct VolumeMount { /// Restart policy for a managed container. Names follow podman/docker /// conventions so docker-compose translation is mechanical. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, JsonSchema, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "kebab-case")] pub enum RestartPolicy { /// Don't restart on exit. diff --git a/harmony/src/modules/podman/score.rs b/harmony/src/modules/podman/score.rs index d9888098..0d3de8c0 100644 --- a/harmony/src/modules/podman/score.rs +++ b/harmony/src/modules/podman/score.rs @@ -7,6 +7,7 @@ //! independent of any particular product (IoT fleet, OKD fleet, etc.) //! — callers serialize them over whatever transport they like. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::{ @@ -22,7 +23,7 @@ use super::interpret::PodmanV0Interpret; /// Wire-compatible with prior releases: the new `env`, `volumes`, and /// `restart_policy` fields all default to empty / `unless-stopped` so older /// Deployment CRs without them deserialize unchanged. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct PodmanService { pub name: String, pub image: String, @@ -42,7 +43,7 @@ pub struct PodmanService { } /// v0 Score for podman-based workloads. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct PodmanV0Score { pub services: Vec, } @@ -69,7 +70,7 @@ impl PodmanV0Score { /// 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)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[serde(tag = "type", content = "data")] pub enum ReconcileScore { PodmanV0(PodmanV0Score), -- 2.39.5