refactor(fleet-operator): replace ScorePayload with ReconcileScore in Deployment CRD [NationTech/Team#186] #278

Merged
johnride merged 1 commits from fix/refactorScorePayload into feat/iot-walking-skeleton 2026-05-05 14:04:44 +00:00
7 changed files with 28 additions and 98 deletions

View File

@@ -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,
},

View File

@@ -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() {

View File

@@ -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
thiserror.workspace = true

View File

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

View File

@@ -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;

View File

@@ -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.

View File

@@ -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<PodmanService>,
}
@@ -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),