240 lines
8.4 KiB
Rust
240 lines
8.4 KiB
Rust
//! Typed-Rust applier for the harmony fleet `Deployment` CR.
|
|
//!
|
|
//! Builds a `Deployment` CR via the typed `DeploymentSpec` +
|
|
//! `PodmanV0Score` + `kube::Api`, then either applies it directly
|
|
//! through the kube client or prints it to stdout so the user can
|
|
//! pipe into `kubectl apply -f -`.
|
|
//!
|
|
//! The CRD is domain-agnostic — it's "declarative reconcile intent
|
|
//! for a set of devices matched by label selector," which is the
|
|
//! same shape whether the fleet is Pi podman, OKD clusters, or
|
|
//! KVM VMs. The name `harmony_apply_deployment` reflects that
|
|
//! (not `iot_`-anything), in line with the review call to position
|
|
//! the operator as a generic fleet/reconcile tool.
|
|
//!
|
|
//! The CRD types live in `harmony::modules::fleet::operator`; the score types
|
|
//! live in `harmony::modules::podman` (PodmanV0 being the first
|
|
//! reconciler variant — future variants drop in alongside).
|
|
//!
|
|
//! Typical demo-driver usage:
|
|
//!
|
|
//! # apply an nginx deployment
|
|
//! cargo run -q -p example_harmony_apply_deployment -- \
|
|
//! --target-device fleet-smoke-vm-arm \
|
|
//! --image nginx:latest
|
|
//!
|
|
//! # print the CR JSON (lets the user kubectl-apply it manually)
|
|
//! cargo run -q -p example_harmony_apply_deployment -- \
|
|
//! --target-device fleet-smoke-vm-arm \
|
|
//! --image nginx:latest --print | kubectl apply -f -
|
|
//!
|
|
//! # upgrade the same deployment to a newer image
|
|
//! cargo run -q -p example_harmony_apply_deployment -- \
|
|
//! --target-device fleet-smoke-vm-arm \
|
|
//! --image nginx:1.26
|
|
//!
|
|
//! # delete the deployment
|
|
//! cargo run -q -p example_harmony_apply_deployment -- --delete
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::Parser;
|
|
use harmony::modules::fleet::operator::crd::{
|
|
Deployment, DeploymentSpec, Rollout, RolloutStrategy,
|
|
};
|
|
use harmony::modules::podman::{PodmanService, PodmanV0Score, ReconcileScore};
|
|
use harmony::topology::{EnvVar, RestartPolicy, VolumeMount};
|
|
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
|
|
use kube::Client;
|
|
use kube::api::{Api, DeleteParams, Patch, PatchParams};
|
|
use std::collections::BTreeMap;
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(
|
|
name = "harmony_apply_deployment",
|
|
about = "Build + apply a harmony fleet Deployment CR from typed Rust (no yaml)"
|
|
)]
|
|
struct Cli {
|
|
/// Kubernetes namespace for the Deployment CR.
|
|
#[arg(long, default_value = "fleet-demo")]
|
|
namespace: String,
|
|
/// Deployment CR name. Also used as the KV key suffix and
|
|
/// podman container name on the device.
|
|
#[arg(long, default_value = "hello-world")]
|
|
name: String,
|
|
/// Shortcut: if set, picks a single device by id. Shorthand for
|
|
/// `--selector device-id=<target_device>` — the agent publishes
|
|
/// a `device-id=<id>` label on its DeviceInfo by default so this
|
|
/// works without any cluster-side label pre-wiring.
|
|
#[arg(long, default_value = "fleet-smoke-vm")]
|
|
target_device: String,
|
|
/// Repeatable `key=value` label selector. Takes precedence over
|
|
/// `--target-device` when provided. All pairs AND together.
|
|
#[arg(long = "selector", value_name = "KEY=VALUE")]
|
|
selectors: Vec<String>,
|
|
/// Container image to run.
|
|
#[arg(long, default_value = "docker.io/library/nginx:latest")]
|
|
image: String,
|
|
/// `host:container` port mapping exposed on the device.
|
|
#[arg(long, default_value = "8080:80")]
|
|
port: String,
|
|
/// Repeatable `KEY=VALUE` env var injected into the container.
|
|
#[arg(long = "env", value_name = "KEY=VALUE")]
|
|
envs: Vec<String>,
|
|
/// Repeatable bind-mount in `host_path:container_path[:ro]` form.
|
|
/// Append `:ro` for read-only.
|
|
#[arg(long = "volume", value_name = "HOST:CONTAINER[:ro]")]
|
|
volumes: Vec<String>,
|
|
/// Container restart policy.
|
|
#[arg(long, value_enum, default_value_t = CliRestart::UnlessStopped)]
|
|
restart: CliRestart,
|
|
/// Delete the Deployment CR instead of applying it.
|
|
#[arg(long)]
|
|
delete: bool,
|
|
/// Print the CR as JSON to stdout instead of applying it.
|
|
/// Useful for piping into `kubectl apply -f -`.
|
|
#[arg(long)]
|
|
print: bool,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
let cr = build_cr(&cli);
|
|
|
|
if cli.print {
|
|
println!("{}", serde_json::to_string_pretty(&cr)?);
|
|
return Ok(());
|
|
}
|
|
|
|
let client = Client::try_default()
|
|
.await
|
|
.context("building kube client (is KUBECONFIG set?)")?;
|
|
let api: Api<Deployment> = Api::namespaced(client, &cli.namespace);
|
|
|
|
if cli.delete {
|
|
match api.delete(&cli.name, &DeleteParams::default()).await {
|
|
Ok(_) => println!("deleted deployment '{}/{}'", cli.namespace, cli.name),
|
|
Err(kube::Error::Api(ae)) if ae.code == 404 => {
|
|
println!(
|
|
"deployment '{}/{}' not found (already gone)",
|
|
cli.namespace, cli.name
|
|
)
|
|
}
|
|
Err(e) => anyhow::bail!("delete failed: {e}"),
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Server-side apply so repeated invocations (upgrades) patch
|
|
// the existing CR instead of erroring with "already exists."
|
|
let params = PatchParams::apply("harmony-apply-deployment").force();
|
|
let applied = api
|
|
.patch(&cli.name, ¶ms, &Patch::Apply(&cr))
|
|
.await
|
|
.context("applying Deployment CR")?;
|
|
let meta = applied.metadata;
|
|
println!(
|
|
"applied deployment '{}/{}' (resourceVersion={}, image={})",
|
|
cli.namespace,
|
|
meta.name.as_deref().unwrap_or("?"),
|
|
meta.resource_version.as_deref().unwrap_or("?"),
|
|
cli.image,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Mirrors `harmony::topology::RestartPolicy` so we can keep the CLI
|
|
/// schema stable even if the underlying enum gains variants.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
|
enum CliRestart {
|
|
No,
|
|
UnlessStopped,
|
|
OnFailure,
|
|
Always,
|
|
}
|
|
|
|
impl From<CliRestart> for RestartPolicy {
|
|
fn from(c: CliRestart) -> Self {
|
|
match c {
|
|
CliRestart::No => RestartPolicy::No,
|
|
CliRestart::UnlessStopped => RestartPolicy::UnlessStopped,
|
|
CliRestart::OnFailure => RestartPolicy::OnFailure,
|
|
CliRestart::Always => RestartPolicy::Always,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_env(s: &str) -> Result<(String, String)> {
|
|
let (k, v) = s
|
|
.split_once('=')
|
|
.ok_or_else(|| anyhow::anyhow!("--env expects KEY=VALUE, got {s:?}"))?;
|
|
Ok((k.to_string(), v.to_string()))
|
|
}
|
|
|
|
fn parse_volume(s: &str) -> Result<VolumeMount> {
|
|
let parts: Vec<&str> = s.split(':').collect();
|
|
let (host, cont, ro) = match parts.as_slice() {
|
|
[host, cont] => (host, cont, false),
|
|
[host, cont, mode] if *mode == "ro" => (host, cont, true),
|
|
[host, cont, mode] if *mode == "rw" => (host, cont, false),
|
|
_ => anyhow::bail!("--volume expects HOST:CONTAINER[:ro|rw], got {s:?}"),
|
|
};
|
|
Ok(VolumeMount {
|
|
host_path: host.to_string(),
|
|
container_path: cont.to_string(),
|
|
read_only: ro,
|
|
})
|
|
}
|
|
|
|
fn build_cr(cli: &Cli) -> Deployment {
|
|
let env: Vec<EnvVar> = cli
|
|
.envs
|
|
.iter()
|
|
.map(|s| EnvVar::from(parse_env(s).expect("--env validated")))
|
|
.collect();
|
|
let volumes: Vec<VolumeMount> = cli
|
|
.volumes
|
|
.iter()
|
|
.map(|s| parse_volume(s).expect("--volume validated"))
|
|
.collect();
|
|
|
|
let score = PodmanV0Score {
|
|
services: vec![PodmanService {
|
|
name: cli.name.clone(),
|
|
image: cli.image.clone(),
|
|
ports: vec![cli.port.clone()],
|
|
env,
|
|
volumes,
|
|
restart_policy: cli.restart.into(),
|
|
}],
|
|
};
|
|
|
|
let payload = ReconcileScore::PodmanV0(score);
|
|
|
|
let mut match_labels = BTreeMap::new();
|
|
if cli.selectors.is_empty() {
|
|
match_labels.insert("device-id".to_string(), cli.target_device.clone());
|
|
} else {
|
|
for kv in &cli.selectors {
|
|
let (k, v) = kv
|
|
.split_once('=')
|
|
.unwrap_or_else(|| panic!("--selector expects KEY=VALUE, got '{kv}'"));
|
|
match_labels.insert(k.to_string(), v.to_string());
|
|
}
|
|
}
|
|
|
|
Deployment::new(
|
|
&cli.name,
|
|
DeploymentSpec {
|
|
target_selector: LabelSelector {
|
|
match_labels: Some(match_labels),
|
|
match_expressions: None,
|
|
},
|
|
score: payload,
|
|
rollout: Rollout {
|
|
strategy: RolloutStrategy::Immediate,
|
|
},
|
|
},
|
|
)
|
|
}
|