refactor(fleet-operator): replace ScorePayload with ReconcileScore in Deployment CRD [NationTech/Team#186] #278
@@ -29,7 +29,8 @@ use async_nats::jetstream::{self, kv};
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use harmony_fleet_operator::crd::{
|
use harmony_fleet_operator::crd::{
|
||||||
Deployment, DeploymentSpec, Rollout, RolloutStrategy, ScorePayload,
|
Deployment, DeploymentSpec, PodmanService, PodmanV0Score, ReconcileScore, Rollout,
|
||||||
|
RolloutStrategy,
|
||||||
};
|
};
|
||||||
use harmony_reconciler_contracts::{
|
use harmony_reconciler_contracts::{
|
||||||
BUCKET_DEVICE_HEARTBEAT, BUCKET_DEVICE_INFO, BUCKET_DEVICE_STATE, DeploymentName,
|
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
|
// the desired-state here. The aggregator still writes KV
|
||||||
// for each matched device; that's wire noise we accept
|
// for each matched device; that's wire noise we accept
|
||||||
// as part of the realism.
|
// as part of the realism.
|
||||||
score: ScorePayload {
|
score: ReconcileScore::PodmanV0(PodmanV0Score {
|
||||||
type_: "PodmanV0".to_string(),
|
services: vec![PodmanService {
|
||||||
data: serde_json::json!({
|
name: group.cr_name.clone(),
|
||||||
"services": [{
|
image: "docker.io/library/nginx:alpine".to_string(),
|
||||||
"name": group.cr_name,
|
ports: vec!["8080:80".to_string()],
|
||||||
"image": "docker.io/library/nginx:alpine",
|
env: vec![],
|
||||||
"ports": ["8080:80"],
|
volumes: vec![],
|
||||||
}],
|
restart_policy: Default::default(),
|
||||||
}),
|
}],
|
||||||
},
|
}),
|
||||||
rollout: Rollout {
|
rollout: Rollout {
|
||||||
strategy: RolloutStrategy::Immediate,
|
strategy: RolloutStrategy::Immediate,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,11 +38,9 @@
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use harmony::modules::podman::{PodmanService, PodmanV0Score};
|
use harmony::modules::podman::{PodmanService, PodmanV0Score, ReconcileScore};
|
||||||
use harmony::topology::{RestartPolicy, VolumeMount};
|
use harmony::topology::{RestartPolicy, VolumeMount};
|
||||||
use harmony_fleet_operator::crd::{
|
use harmony_fleet_operator::crd::{Deployment, DeploymentSpec, Rollout, RolloutStrategy};
|
||||||
Deployment, DeploymentSpec, Rollout, RolloutStrategy, ScorePayload,
|
|
||||||
};
|
|
||||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
|
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
|
||||||
use kube::Client;
|
use kube::Client;
|
||||||
use kube::api::{Api, DeleteParams, Patch, PatchParams};
|
use kube::api::{Api, DeleteParams, Patch, PatchParams};
|
||||||
@@ -209,14 +207,7 @@ fn build_cr(cli: &Cli) -> Deployment {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = ScorePayload {
|
let payload = ReconcileScore::PodmanV0(score);
|
||||||
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 mut match_labels = BTreeMap::new();
|
let mut match_labels = BTreeMap::new();
|
||||||
if cli.selectors.is_empty() {
|
if cli.selectors.is_empty() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ edition = "2024"
|
|||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
harmony = { path = "../../harmony" }
|
harmony = { path = "../../harmony", features = ["podman"] }
|
||||||
harmony-fleet-auth = { path = "../harmony-fleet-auth" }
|
harmony-fleet-auth = { path = "../harmony-fleet-auth" }
|
||||||
harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" }
|
harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
@@ -22,4 +22,4 @@ tracing-subscriber = { workspace = true }
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ use harmony_reconciler_contracts::InventorySnapshot;
|
|||||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
|
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
|
||||||
use kube::CustomResource;
|
use kube::CustomResource;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use schemars::schema::{
|
|
||||||
InstanceType, ObjectValidation, Schema, SchemaObject, SingleOrVec, StringValidation,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use harmony::modules::podman::{PodmanService, PodmanV0Score, ReconcileScore};
|
||||||
|
|
||||||
/// Deployment intent. Targets devices by label selector — identical
|
/// Deployment intent. Targets devices by label selector — identical
|
||||||
/// to the pattern K8s itself uses for DaemonSet nodeSelector, Service
|
/// to the pattern K8s itself uses for DaemonSet nodeSelector, Service
|
||||||
/// pod selector, etc. The operator resolves the selector against
|
/// pod selector, etc. The operator resolves the selector against
|
||||||
@@ -26,73 +25,10 @@ pub struct DeploymentSpec {
|
|||||||
/// Which devices this deployment targets. matches against
|
/// Which devices this deployment targets. matches against
|
||||||
/// `Device.metadata.labels`.
|
/// `Device.metadata.labels`.
|
||||||
pub target_selector: LabelSelector,
|
pub target_selector: LabelSelector,
|
||||||
#[schemars(schema_with = "score_payload_schema")]
|
pub score: ReconcileScore,
|
||||||
pub score: ScorePayload,
|
|
||||||
pub rollout: Rollout,
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)]
|
||||||
pub struct Rollout {
|
pub struct Rollout {
|
||||||
pub strategy: RolloutStrategy,
|
pub strategy: RolloutStrategy,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! The CRD type definitions are exposed here as a library so external
|
//! The CRD type definitions are exposed here as a library so external
|
||||||
//! consumers — tooling that applies CRs, tests, documentation generators
|
//! consumers — tooling that applies CRs, tests, documentation generators
|
||||||
//! — can import the typed `Deployment`, `DeploymentSpec`,
|
//! — can import the typed `Deployment`, `DeploymentSpec`,
|
||||||
//! `ScorePayload`, etc. without duplicating them.
|
//! `ReconcileScore`, etc. without duplicating them.
|
||||||
|
|
||||||
pub mod chart;
|
pub mod chart;
|
||||||
pub mod crd;
|
pub mod crd;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::executors::ExecutorError;
|
use crate::executors::ExecutorError;
|
||||||
@@ -76,7 +77,7 @@ impl ContainerSpec {
|
|||||||
/// A single host-path → container-path bind mount. Bind mounts are the only
|
/// 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
|
/// volume kind supported in v0 — they cover ~95% of compose use cases and
|
||||||
/// don't depend on a runtime-managed volume namespace.
|
/// 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 {
|
pub struct VolumeMount {
|
||||||
/// Absolute path on the host.
|
/// Absolute path on the host.
|
||||||
pub host_path: String,
|
pub host_path: String,
|
||||||
@@ -90,7 +91,7 @@ pub struct VolumeMount {
|
|||||||
|
|
||||||
/// Restart policy for a managed container. Names follow podman/docker
|
/// Restart policy for a managed container. Names follow podman/docker
|
||||||
/// conventions so docker-compose translation is mechanical.
|
/// 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")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum RestartPolicy {
|
pub enum RestartPolicy {
|
||||||
/// Don't restart on exit.
|
/// Don't restart on exit.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! independent of any particular product (IoT fleet, OKD fleet, etc.)
|
//! independent of any particular product (IoT fleet, OKD fleet, etc.)
|
||||||
//! — callers serialize them over whatever transport they like.
|
//! — callers serialize them over whatever transport they like.
|
||||||
|
|
||||||
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -22,7 +23,7 @@ use super::interpret::PodmanV0Interpret;
|
|||||||
/// Wire-compatible with prior releases: the new `env`, `volumes`, and
|
/// Wire-compatible with prior releases: the new `env`, `volumes`, and
|
||||||
/// `restart_policy` fields all default to empty / `unless-stopped` so older
|
/// `restart_policy` fields all default to empty / `unless-stopped` so older
|
||||||
/// Deployment CRs without them deserialize unchanged.
|
/// 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 struct PodmanService {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub image: String,
|
pub image: String,
|
||||||
@@ -42,7 +43,7 @@ pub struct PodmanService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// v0 Score for podman-based workloads.
|
/// 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 struct PodmanV0Score {
|
||||||
pub services: Vec<PodmanService>,
|
pub services: Vec<PodmanService>,
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,7 @@ impl PodmanV0Score {
|
|||||||
/// Adding a new variant is additive — emitters stay opaque routers,
|
/// Adding a new variant is additive — emitters stay opaque routers,
|
||||||
/// consumers learn the new variant, older consumers harmlessly
|
/// consumers learn the new variant, older consumers harmlessly
|
||||||
/// log-and-skip the unknown tag.
|
/// log-and-skip the unknown tag.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||||
#[serde(tag = "type", content = "data")]
|
#[serde(tag = "type", content = "data")]
|
||||||
pub enum ReconcileScore {
|
pub enum ReconcileScore {
|
||||||
PodmanV0(PodmanV0Score),
|
PodmanV0(PodmanV0Score),
|
||||||
|
|||||||
Reference in New Issue
Block a user