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),