Files
harmony/fleet/harmony-fleet-deploy/src/main.rs
Jean-Gabriel Gill-Couture 0992183438 feat(fleet): Argo-based CD path for the fleet operator
Adds an `operator_application()` helper that builds an
`ArgoApplication` targeting
`oci://<registry>/<project>/harmony-fleet-operator:<chart_version>`.
main.rs composes it into `ArgoHelmScore { argo_apps: vec![...] }` —
no new score types, no wrapper module.

CLI gains `--use-argo` to swap the operator path from direct-helm
to Argo, plus `--operator-chart-version` as the CD knob CI re-runs
to deploy / upgrade / roll back.

Splits `ArgoApplication.destination_namespace` from
`metadata.namespace` so the Application CR can live in `argocd`
while syncing chart resources into `fleet-system` — `to_yaml`
previously collapsed them, breaking any cross-namespace deploy.

NATS + agent stay on the direct path in v1; flattens
`harmony_cli::Args` into the binary's CliConfig so `--yes` makes it
through one argv parse.

Tested on a fresh k3d cluster against the published
`hub.nationtech.io/harmony/harmony-fleet-operator:0.0.1`.
2026-05-27 21:22:30 -04:00

192 lines
6.1 KiB
Rust

//! `harmony-fleet-deploy` — deploy the fleet stack to a cluster.
use anyhow::{Context, Result};
use clap::Parser;
use harmony::inventory::Inventory;
use harmony::modules::application::features::ArgoHelmScore;
use harmony::score::Score;
use harmony::topology::K8sAnywhereTopology;
use harmony_cli::Args as HarmonyCliArgs;
use harmony_fleet_deploy::nats::UserPassCredentials;
use harmony_fleet_deploy::{
FleetAgentScore, FleetNatsScore, FleetOperatorScore, agent::PodTarget, argo,
};
#[derive(Parser, Debug)]
#[command(
name = "harmony-fleet-deploy",
about = "Deploy the harmony fleet stack to a Kubernetes cluster"
)]
struct CliConfig {
/// Namespace every component lands in. Override with
/// `HARMONY_FLEET_NAMESPACE`.
#[arg(
long,
env = "HARMONY_FLEET_NAMESPACE",
default_value = "harmony-fleet-system"
)]
namespace: String,
/// Operator image to pull. Defaults to the dev tag the
/// `harmony-fleet-operator/Dockerfile` produces.
#[arg(
long,
env = "HARMONY_FLEET_OPERATOR_IMAGE",
default_value = "localhost/harmony-fleet-operator:dev"
)]
operator_image: String,
/// Agent image to pull. The e2e harness sideloads
/// `localhost/harmony-fleet-agent:e2e`; production override via
/// the env var.
#[arg(
long,
env = "HARMONY_FLEET_AGENT_IMAGE",
default_value = "localhost/harmony-fleet-agent:e2e"
)]
agent_image: String,
/// Device id for the agent Pod this deploy provisions. Production
/// will likely deploy multiple agents; for the minimal CLI v1 the
/// caller runs the binary once per device.
#[arg(
long,
env = "HARMONY_FLEET_AGENT_DEVICE_ID",
default_value = "fleet-agent-00"
)]
agent_device_id: String,
/// Host-side NodePort the NATS Service exposes.
#[arg(long, env = "HARMONY_FLEET_NATS_NODE_PORT", default_value_t = 30423)]
nats_node_port: u16,
/// NATS admin user (full pub/sub on the `APP` account). Required.
#[arg(long, env = "HARMONY_FLEET_NATS_ADMIN_USER")]
nats_admin_user: Option<String>,
/// NATS admin password. Required.
#[arg(long, env = "HARMONY_FLEET_NATS_ADMIN_PASS")]
nats_admin_pass: Option<String>,
/// NATS device user (limited pub/sub). Required.
#[arg(long, env = "HARMONY_FLEET_NATS_DEVICE_USER")]
nats_device_user: Option<String>,
/// NATS device password. Required.
#[arg(long, env = "HARMONY_FLEET_NATS_DEVICE_PASS")]
nats_device_pass: Option<String>,
/// Deploy the operator via Argo CD instead of harmony-direct
/// helm. Re-run with a different `--operator-chart-version` to
/// upgrade or roll back.
#[arg(long, env = "HARMONY_FLEET_USE_ARGO")]
use_argo: bool,
#[arg(
long,
env = "HARMONY_FLEET_OPERATOR_CHART_REGISTRY",
default_value = "hub.nationtech.io"
)]
operator_chart_registry: String,
#[arg(
long,
env = "HARMONY_FLEET_OPERATOR_CHART_PROJECT",
default_value = "harmony"
)]
operator_chart_project: String,
#[arg(
long,
env = "HARMONY_FLEET_OPERATOR_CHART_VERSION",
default_value = "0.0.1"
)]
operator_chart_version: String,
#[arg(long, env = "HARMONY_FLEET_ARGO_NAMESPACE", default_value = "argocd")]
argo_namespace: String,
// Flattened so a single argv parse covers both this CLI and
// harmony_cli's `--yes` / `--filter` / `--all` / etc.
#[command(flatten)]
harmony_cli: HarmonyCliArgs,
}
impl CliConfig {
/// Build the NATS credentials struct or fail with an actionable
/// error message naming the missing env var. No dev defaults —
/// the deploy binary refuses to ship known-weak passwords.
fn nats_creds(&self) -> Result<UserPassCredentials> {
Ok(UserPassCredentials {
admin_user: self
.nats_admin_user
.clone()
.context("HARMONY_FLEET_NATS_ADMIN_USER must be set")?,
admin_pass: self
.nats_admin_pass
.clone()
.context("HARMONY_FLEET_NATS_ADMIN_PASS must be set")?,
device_user: self
.nats_device_user
.clone()
.context("HARMONY_FLEET_NATS_DEVICE_USER must be set")?,
device_pass: self
.nats_device_pass
.clone()
.context("HARMONY_FLEET_NATS_DEVICE_PASS must be set")?,
})
}
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = CliConfig::parse();
let creds = cli.nats_creds()?;
let device_user = creds.device_user.clone();
let device_pass = creds.device_pass.clone();
let nats = FleetNatsScore::user_pass(cli.namespace.clone(), cli.nats_node_port, creds);
let agent = FleetAgentScore::pod(
cli.namespace.clone(),
PodTarget::user_pass(
cli.agent_device_id.clone(),
cli.agent_image.clone(),
nats.in_cluster_url(),
device_user,
device_pass,
),
);
// `--use-argo` swaps the operator path only. NATS + agent stay
// direct in v1.
let scores: Vec<Box<dyn Score<K8sAnywhereTopology>>> = if cli.use_argo {
let argo = ArgoHelmScore {
namespace: cli.argo_namespace.clone(),
openshift: false,
ingress_class_name: None,
argo_apps: vec![argo::operator_application(
&cli.namespace,
&cli.operator_chart_registry,
&cli.operator_chart_project,
&cli.operator_chart_version,
)],
};
vec![Box::new(nats), Box::new(argo), Box::new(agent)]
} else {
let operator = FleetOperatorScore::new()
.namespace(cli.namespace.clone())
.image(cli.operator_image.clone())
.nats_url(nats.in_cluster_url());
vec![Box::new(nats), Box::new(operator), Box::new(agent)]
};
harmony_cli::run(
Inventory::autoload(),
K8sAnywhereTopology::from_env(),
scores,
Some(cli.harmony_cli),
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
}