From 245d7dd9a5a7b3b9cb2e586a10bf40b7d37098c0 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 29 May 2026 12:38:59 -0400 Subject: [PATCH 1/7] feat(fleet): clickable CD deploy of the operator to staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the deploy half of fleet-operator CD: a manual workflow_dispatch that installs a published operator chart version via harmony apply, run from an in-cluster, permissionless runner. - FleetDeploySecrets (#[config(secret)]) loaded via ConfigClient: the published-chart path is Zitadel-SSO-only by construction — operator credentials + deployer kubeconfig come from OpenBao, no user/pass field reachable. user/pass stays a dev-only rendered-chart flag. - FleetOperatorScore::credentials() so the published-chart path applies the operator Secret (was a no-op). - Deprecate SecretManager in favour of harmony_config::ConfigClient. --- .../harmony-fleet-operator-deploy.yaml | 52 +++++++ Cargo.lock | 2 + fleet/deployment-process.md | 77 +++++++---- fleet/harmony-fleet-deploy/Cargo.toml | 2 + fleet/harmony-fleet-deploy/src/lib.rs | 2 + fleet/harmony-fleet-deploy/src/main.rs | 128 +++++++++++++----- .../src/operator/score.rs | 21 +++ fleet/harmony-fleet-deploy/src/secrets.rs | 52 +++++++ harmony_secret/src/lib.rs | 3 + 9 files changed, 282 insertions(+), 57 deletions(-) create mode 100644 .gitea/workflows/harmony-fleet-operator-deploy.yaml create mode 100644 fleet/harmony-fleet-deploy/src/secrets.rs diff --git a/.gitea/workflows/harmony-fleet-operator-deploy.yaml b/.gitea/workflows/harmony-fleet-operator-deploy.yaml new file mode 100644 index 00000000..f76b22ad --- /dev/null +++ b/.gitea/workflows/harmony-fleet-operator-deploy.yaml @@ -0,0 +1,52 @@ +name: harmony-fleet-operator — deploy (staging) +# Clickable CD: deploy a *published* operator chart version to staging. +# The release workflow (harmony-fleet-operator.yaml) publishes the image +# + chart on tag; THIS is the separate manual "deploy" click. +# +# Runs on the in-cluster staging runner (`runs-on: fleet-staging`). That +# pod carries no Kubernetes permissions — the job pulls its kube + +# operator credentials from OpenBao through harmony_config +# (FleetDeploySecrets). SSO-only: the published-chart path has no +# user/pass. See fleet/deployment-process.md for the one-time runner + +# OpenBao bootstrap. +on: + workflow_dispatch: + inputs: + version: + description: 'Published operator chart version, e.g. 0.0.2' + required: true + type: string + +jobs: + deploy-staging: + container: + image: hub.nationtech.io/harmony/harmony_composer:latest + runs-on: fleet-staging + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # TODO: bake helm into harmony_composer so this step disappears. + - name: Install helm + run: curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + + - name: Log in to hub.nationtech.io (helm OCI) + run: | + echo "${{ secrets.HUB_BOT_PASSWORD }}" \ + | helm registry login hub.nationtech.io \ + --username "${{ secrets.HUB_BOT_USER }}" --password-stdin + + - name: Deploy published operator chart + env: + # OpenBao reachable from the locked-down tenant (LB:443). The + # Zitadel machine-identity auth to OpenBao is supplied by the + # runner env; FleetDeploySecrets (kubeconfig + operator + # credentials) is then resolved from OpenBao. + OPENBAO_URL: ${{ secrets.OPENBAO_URL }} + HARMONY_SECRET_NAMESPACE: harmony + run: | + cargo run --release -p harmony-fleet-deploy --bin harmony-fleet-deploy -- \ + --filter FleetOperatorScore \ + --operator-chart-version "${{ inputs.version }}" \ + --namespace fleet-system \ + --yes diff --git a/Cargo.lock b/Cargo.lock index 2fe6774f..5ab989e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4093,12 +4093,14 @@ dependencies = [ "harmony-fleet-auth", "harmony-reconciler-contracts", "harmony_cli", + "harmony_config", "harmony_macros", "harmony_types", "k8s-openapi", "kube", "log", "non-blank-string-rs", + "schemars 0.8.22", "serde", "serde_json", "serde_yaml", diff --git a/fleet/deployment-process.md b/fleet/deployment-process.md index 8b649972..2dfb7ed9 100644 --- a/fleet/deployment-process.md +++ b/fleet/deployment-process.md @@ -28,32 +28,41 @@ cargo run -p harmony-fleet-deploy --bin harmony-fleet-release -- \ --from-tag harmony-fleet-operator-v0.0.2 --no-push ``` -## 2. Deploy a published version (`harmony apply`) +## 2. Deploy a published version to staging (a click) -`--operator-chart-version` switches the operator to install the -published `oci://hub.nationtech.io/harmony/harmony-fleet-operator:0.0.2` -chart instead of rendering one from local source. Same command -bootstraps and upgrades; re-running with the same version is a no-op. +The deploy is a manual `workflow_dispatch` — +`.gitea/workflows/harmony-fleet-operator-deploy.yaml`. Enter the +published chart version and run it. It executes on the **in-cluster +staging runner** and runs `harmony-fleet-deploy --filter +FleetOperatorScore --operator-chart-version `, which installs +`oci://hub.nationtech.io/harmony/harmony-fleet-operator:` +instead of rendering from source. Same command bootstraps and upgrades; +re-running the same version is a no-op. + +**Auth is Zitadel-SSO-only on this path.** The job carries no plaintext +secrets and the runner pod carries no Kubernetes permissions. It pulls a +`FleetDeploySecrets` config from OpenBao via `harmony_config`: + +- `operator_credentials_toml` — the operator's zitadel-jwt NATS + credentials (applied as the operator Secret before `helm install`), +- `kubeconfig` — the scoped `fleet-deployer` ServiceAccount, so the + permissionless runner authenticates to the API only for this job. + +There is no user/pass on the published-chart path — it's structurally +unreachable. (`--nats-*` flags exist only for the dev/e2e rendered-chart +path in step 1's `--no-push` smoke-test.) + +Laptop fallback (override the config via env instead of OpenBao): ```sh -export HARMONY_FLEET_NATS_ADMIN_USER=… HARMONY_FLEET_NATS_ADMIN_PASS=… -export HARMONY_FLEET_NATS_DEVICE_USER=… HARMONY_FLEET_NATS_DEVICE_PASS=… - -harmony-fleet-deploy \ - --filter FleetOperatorScore \ - --operator-chart-version 0.0.2 \ - --namespace fleet-system \ - --yes +export HARMONY_CONFIG_FleetDeploySecrets='{"operator_credentials_toml":"…","kubeconfig":"…"}' +harmony-fleet-deploy --filter FleetOperatorScore \ + --operator-chart-version 0.0.2 --namespace fleet-system --yes ``` ## 3. Roll forward -Same command, a newer (or previous-good) version: - -```sh -harmony-fleet-deploy --filter FleetOperatorScore --operator-chart-version 0.0.3 --namespace fleet-system --yes -``` - +Re-run the deploy workflow with a newer (or previous-good) version. `helm upgrade --install` applies it and fails loudly if convergence fails — no automatic rollback. Fix the spec, bump, re-run. @@ -61,10 +70,28 @@ fails — no automatic rollback. Fix the spec, bump, re-run. | Step | Where | |---|---| -| Build + push image + chart on tag | CI (`release` job) | -| Deploy a published version + roll forward | Manual `harmony apply` (above) | +| Build + push image + chart on tag | CI (`release` job, on tag) | +| Deploy a published version + roll forward | CI (`deploy-staging`, manual `workflow_dispatch` click) | -A staging-auto / production-gated CD job is a follow-up — it needs the -cluster `KUBECONFIG` + NATS secrets provisioned, which is out of scope -for the initial CD branch (ADR-012-2). The release job is fully -functional today. +## One-time bootstrap (in-cluster staging runner) + +`deploy-staging` runs `runs-on: fleet-staging` — a Gitea `act_runner` +provisioned once inside an isolated tenant namespace in the cluster. +This is what makes the API reachable without exposing it publicly or +over the VPN: the runner is already inside. Provision (reproducibly, via +Harmony Scores — not handrolled manifests): + +- **Runner pod** with `automountServiceAccountToken: false` — zero + standing Kubernetes permissions. +- **Egress NetworkPolicy** allowing only the LB IP on :443 (OpenBao / + Zitadel / hub via the DNS alias) plus the API endpoint; deny the rest. +- **`fleet-deployer` ServiceAccount + RBAC** scoped to exactly what the + operator chart installs (its CRDs, ClusterRole, ClusterRoleBinding + + the `fleet-system` namespaced resources). +- **Store the deployer kubeconfig + operator credentials in OpenBao** + under `FleetDeploySecrets` so the job resolves them at runtime. + +Until the Zitadel→OpenBao machine-identity auth lands, the runner's +OpenBao auth env is supplied directly; afterward the runner authenticates +as a Zitadel machine identity. Production-gated promotion is a follow-up +(ADR-012-2). diff --git a/fleet/harmony-fleet-deploy/Cargo.toml b/fleet/harmony-fleet-deploy/Cargo.toml index 0cabf7f0..beeffc1a 100644 --- a/fleet/harmony-fleet-deploy/Cargo.toml +++ b/fleet/harmony-fleet-deploy/Cargo.toml @@ -25,6 +25,7 @@ path = "src/bin/harmony-fleet-release.rs" [dependencies] harmony = { path = "../../harmony", features = ["podman"] } harmony_cli = { path = "../../harmony_cli" } +harmony_config = { path = "../../harmony_config" } harmony_types = { path = "../../harmony_types" } harmony_macros = { path = "../../harmony_macros" } harmony-fleet-auth = { path = "../harmony-fleet-auth" } @@ -38,6 +39,7 @@ kube = { workspace = true, features = ["runtime", "derive"] } log = { workspace = true } env_logger = { workspace = true } non-blank-string-rs = "1" +schemars = "0.8" serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } diff --git a/fleet/harmony-fleet-deploy/src/lib.rs b/fleet/harmony-fleet-deploy/src/lib.rs index 943cdd5e..0cef22c3 100644 --- a/fleet/harmony-fleet-deploy/src/lib.rs +++ b/fleet/harmony-fleet-deploy/src/lib.rs @@ -32,6 +32,7 @@ pub mod companion; pub mod nats; pub mod operator; pub mod release; +pub mod secrets; pub mod server; pub use agent::{FleetAgentScore, PodTarget}; @@ -39,4 +40,5 @@ pub use companion::AgentObservation; pub use nats::{FleetNatsScore, UserPassCredentials}; pub use operator::{FleetOperatorScore, OperatorCredentials, PublishedChart}; pub use release::{release_operator, version_from_tag}; +pub use secrets::FleetDeploySecrets; pub use server::FleetServerScore; diff --git a/fleet/harmony-fleet-deploy/src/main.rs b/fleet/harmony-fleet-deploy/src/main.rs index 494548b5..4f6fcedd 100644 --- a/fleet/harmony-fleet-deploy/src/main.rs +++ b/fleet/harmony-fleet-deploy/src/main.rs @@ -17,14 +17,19 @@ //! any imperative bring-up loops, any auth secret rendering — that //! all sits inside the `*Score` impls in [`harmony_fleet_deploy`]. +use std::io::Write; + use anyhow::{Context, Result}; use clap::Parser; use harmony::inventory::Inventory; use harmony::score::Score; use harmony::topology::K8sAnywhereTopology; use harmony_cli::Args as HarmonyCliArgs; +use harmony_config::ConfigClient; use harmony_fleet_deploy::nats::UserPassCredentials; -use harmony_fleet_deploy::{FleetAgentScore, FleetNatsScore, FleetOperatorScore, agent::PodTarget}; +use harmony_fleet_deploy::{ + FleetAgentScore, FleetDeploySecrets, FleetNatsScore, FleetOperatorScore, agent::PodTarget, +}; #[derive(Parser, Debug)] #[command( @@ -74,19 +79,22 @@ struct CliConfig { #[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. + // NATS user/pass — dev / rendered-chart path ONLY. The published + // chart (CD) path is Zitadel-SSO-only and never reads these, so + // user/pass can't leak into a prod deploy. + /// NATS admin user (full pub/sub on the `APP` account). Dev only. #[arg(long, env = "HARMONY_FLEET_NATS_ADMIN_USER")] nats_admin_user: Option, - /// NATS admin password. Required. + /// NATS admin password. Dev only. #[arg(long, env = "HARMONY_FLEET_NATS_ADMIN_PASS")] nats_admin_pass: Option, - /// NATS device user (limited pub/sub). Required. + /// NATS device user (limited pub/sub). Dev only. #[arg(long, env = "HARMONY_FLEET_NATS_DEVICE_USER")] nats_device_user: Option, - /// NATS device password. Required. + /// NATS device password. Dev only. #[arg(long, env = "HARMONY_FLEET_NATS_DEVICE_PASS")] nats_device_pass: Option, @@ -111,6 +119,11 @@ struct CliConfig { )] operator_chart_project: String, + /// Config/secret namespace the published-chart (CD) path resolves + /// `FleetDeploySecrets` under (`EnvSource` → OpenBao). + #[arg(long, env = "HARMONY_SECRET_NAMESPACE", default_value = "harmony")] + config_namespace: String, + // Flattened so CI can pass `--yes` / `--filter` etc. #[command(flatten)] harmony_cli: HarmonyCliArgs, @@ -146,34 +159,20 @@ impl CliConfig { 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 mut operator = FleetOperatorScore::new() - .namespace(cli.namespace.clone()) - .image(cli.operator_image.clone()) - .nats_url(nats.in_cluster_url()); - if let Some(version) = cli.operator_chart_version.clone() { - operator = operator.published_chart( - cli.operator_chart_registry.clone(), - cli.operator_chart_project.clone(), - version, - ); - } - 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, - ), - ); - + // The published-chart version flag is the dev/prod discriminator: + // present → CD path (published OCI chart, Zitadel SSO only); absent + // → dev/e2e path (rendered chart, user/pass). Keeping the kubeconfig + // tempfile alive across the run; it's dropped (and deleted) on exit. + let _kubeconfig_guard: Option; let scores: Vec>> = - vec![Box::new(nats), Box::new(operator), Box::new(agent)]; + if let Some(version) = cli.operator_chart_version.clone() { + let (operator, guard) = published_operator(&cli, version).await?; + _kubeconfig_guard = guard; + vec![Box::new(operator)] + } else { + _kubeconfig_guard = None; + dev_stack(&cli)? + }; harmony_cli::run( Inventory::autoload(), @@ -184,3 +183,68 @@ async fn main() -> Result<()> { .await .map_err(|e| anyhow::anyhow!("{e}")) } + +/// CD path: the operator from its published OCI chart, authenticated to +/// NATS via Zitadel SSO. Secrets come from `ConfigClient` (EnvSource → +/// OpenBao), never from CLI flags — user/pass is unreachable here. +/// Returns the kubeconfig tempfile guard so `main` keeps it alive past +/// `KUBECONFIG` resolution. +async fn published_operator( + cli: &CliConfig, + version: String, +) -> Result<(FleetOperatorScore, Option)> { + let client = ConfigClient::for_namespace(&cli.config_namespace).await; + let secrets: FleetDeploySecrets = client + .get() + .await + .context("loading FleetDeploySecrets (set HARMONY_CONFIG_FleetDeploySecrets or OpenBao)")?; + + // Point the topology at the scoped fleet-deployer kubeconfig before + // K8sAnywhereTopology::from_env reads KUBECONFIG, so the runner pod + // itself stays permissionless. + let guard = match secrets.kubeconfig { + Some(kubeconfig) => { + let mut f = tempfile::NamedTempFile::new().context("kubeconfig tempfile")?; + f.write_all(kubeconfig.as_bytes()) + .context("writing kubeconfig")?; + // SAFETY: single-threaded startup, before any topology reads it. + unsafe { std::env::set_var("KUBECONFIG", f.path()) }; + Some(f) + } + None => None, + }; + + let operator = FleetOperatorScore::new() + .namespace(cli.namespace.clone()) + .credentials(secrets.operator_credentials_toml) + .published_chart( + cli.operator_chart_registry.clone(), + cli.operator_chart_project.clone(), + version, + ); + Ok((operator, guard)) +} + +/// Dev/e2e path: the full stack (NATS + operator + agent) from rendered +/// charts with simple user/pass auth supplied via `--nats-*` flags. +fn dev_stack(cli: &CliConfig) -> Result>>> { + 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 operator = FleetOperatorScore::new() + .namespace(cli.namespace.clone()) + .image(cli.operator_image.clone()) + .nats_url(nats.in_cluster_url()); + 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, + ), + ); + Ok(vec![Box::new(nats), Box::new(operator), Box::new(agent)]) +} diff --git a/fleet/harmony-fleet-deploy/src/operator/score.rs b/fleet/harmony-fleet-deploy/src/operator/score.rs index 00daddab..cc60dd61 100644 --- a/fleet/harmony-fleet-deploy/src/operator/score.rs +++ b/fleet/harmony-fleet-deploy/src/operator/score.rs @@ -133,6 +133,16 @@ impl FleetOperatorScore { self } + /// Set the operator's NATS auth-callout credentials (zitadel-jwt + /// `[credentials]` TOML). Applied as the operator Secret before the + /// helm install — including on the published-chart (CD) path. + pub fn credentials(mut self, credentials_toml: impl Into) -> Self { + self.credentials = Some(OperatorCredentials { + credentials_toml: credentials_toml.into(), + }); + self + } + pub fn log_level(mut self, level: impl Into) -> Self { self.log_level = level.into(); self @@ -306,4 +316,15 @@ mod tests { assert_eq!(s.nats_url, "nats://nats:4222"); assert_eq!(s.log_level, "debug"); } + + #[test] + fn credentials_set_on_published_chart_path() { + let s = FleetOperatorScore::new() + .credentials("type = \"zitadel-jwt\"") + .published_chart("hub.example", "proj", "1.2.3"); + let creds = s.credentials.expect("credentials wired"); + assert_eq!(creds.credentials_toml, "type = \"zitadel-jwt\""); + // The published-chart interpret applies this Secret before install. + assert!(s.published_chart.is_some()); + } } diff --git a/fleet/harmony-fleet-deploy/src/secrets.rs b/fleet/harmony-fleet-deploy/src/secrets.rs new file mode 100644 index 00000000..023bd21f --- /dev/null +++ b/fleet/harmony-fleet-deploy/src/secrets.rs @@ -0,0 +1,52 @@ +//! Secrets the **prod / CD** operator deploy needs, loaded through +//! [`harmony_config::ConfigClient`]. This path is Zitadel-SSO-only by +//! construction — there is no user/pass field here, so dev-only +//! user/pass auth cannot leak into a published-chart deploy. The +//! rendered-chart (dev/e2e) path keeps its own `--nats-*` flags. +//! +//! `ConfigClient::for_namespace` resolves these from `EnvSource` then +//! OpenBao (when its env is set) — so the in-cluster CD runner pulls +//! them from OpenBao with no plaintext on the runner, while a developer +//! can override locally with `HARMONY_CONFIG_FleetDeploySecrets='{…}'`. + +use harmony_config::Config; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// `#[config(secret)]`: every field is secret — masked in logs and kept +/// out of the cleartext SQLite source. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Config)] +#[config(secret)] +pub struct FleetDeploySecrets { + /// Operator's `[credentials]` TOML (zitadel-jwt) — prod NATS auth + /// via machine identity. Applied as the operator Secret before the + /// helm install. Required: the prod path has no user/pass fallback. + pub operator_credentials_toml: String, + + /// kubeconfig the deploy authenticates with — the scoped + /// fleet-deployer ServiceAccount credential. Kept in config (not on + /// the runner pod) so the in-cluster runner stays permissionless: + /// the job fetches it at runtime. `None` falls back to the ambient + /// `KUBECONFIG` / in-cluster config. + #[serde(default)] + pub kubeconfig: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use harmony_config::ConfigClass; + + #[test] + fn kubeconfig_is_optional() { + let json = r#"{ "operator_credentials_toml": "type = \"zitadel-jwt\"" }"#; + let s: FleetDeploySecrets = serde_json::from_str(json).unwrap(); + assert_eq!(s.operator_credentials_toml, "type = \"zitadel-jwt\""); + assert!(s.kubeconfig.is_none()); + } + + #[test] + fn is_secret_class() { + assert_eq!(FleetDeploySecrets::CLASS, ConfigClass::Secret); + } +} diff --git a/harmony_secret/src/lib.rs b/harmony_secret/src/lib.rs index f21e7b52..cda2786f 100644 --- a/harmony_secret/src/lib.rs +++ b/harmony_secret/src/lib.rs @@ -124,6 +124,9 @@ async fn init_secret_manager() -> SecretManager { } /// Manages the lifecycle of secrets, providing a simple static API. +#[deprecated( + note = "Use harmony_config::ConfigClient instead; it unifies config + secrets over the same stores." +)] #[derive(Debug)] pub struct SecretManager { namespace: String, -- 2.39.5 From 96d78f867179a9ee8114762b543019ebb2ebbb77 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 29 May 2026 12:40:45 -0400 Subject: [PATCH 2/7] =?UTF-8?q?refactor(fleet):=20densify=20CD=20deploy=20?= =?UTF-8?q?comments=20=E2=80=94=20WHY-only,=20no=20narration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../harmony-fleet-operator-deploy.yaml | 21 +++------ fleet/harmony-fleet-deploy/src/main.rs | 20 ++++----- fleet/harmony-fleet-deploy/src/secrets.rs | 44 +++++++------------ 3 files changed, 31 insertions(+), 54 deletions(-) diff --git a/.gitea/workflows/harmony-fleet-operator-deploy.yaml b/.gitea/workflows/harmony-fleet-operator-deploy.yaml index f76b22ad..2a9098b4 100644 --- a/.gitea/workflows/harmony-fleet-operator-deploy.yaml +++ b/.gitea/workflows/harmony-fleet-operator-deploy.yaml @@ -1,14 +1,9 @@ name: harmony-fleet-operator — deploy (staging) -# Clickable CD: deploy a *published* operator chart version to staging. -# The release workflow (harmony-fleet-operator.yaml) publishes the image -# + chart on tag; THIS is the separate manual "deploy" click. -# -# Runs on the in-cluster staging runner (`runs-on: fleet-staging`). That -# pod carries no Kubernetes permissions — the job pulls its kube + -# operator credentials from OpenBao through harmony_config -# (FleetDeploySecrets). SSO-only: the published-chart path has no -# user/pass. See fleet/deployment-process.md for the one-time runner + -# OpenBao bootstrap. +# Manual "deploy" click for a published chart version (the release +# workflow publishes on tag). Runs on the in-cluster, permissionless +# staging runner, which pulls its kube + operator credentials from +# OpenBao via harmony_config. Runner/OpenBao bootstrap: +# fleet/deployment-process.md. on: workflow_dispatch: inputs: @@ -38,10 +33,8 @@ jobs: - name: Deploy published operator chart env: - # OpenBao reachable from the locked-down tenant (LB:443). The - # Zitadel machine-identity auth to OpenBao is supplied by the - # runner env; FleetDeploySecrets (kubeconfig + operator - # credentials) is then resolved from OpenBao. + # OpenBao auth comes from the runner env (machine-identity PR); + # FleetDeploySecrets is then resolved from OpenBao. OPENBAO_URL: ${{ secrets.OPENBAO_URL }} HARMONY_SECRET_NAMESPACE: harmony run: | diff --git a/fleet/harmony-fleet-deploy/src/main.rs b/fleet/harmony-fleet-deploy/src/main.rs index 4f6fcedd..5a7cc541 100644 --- a/fleet/harmony-fleet-deploy/src/main.rs +++ b/fleet/harmony-fleet-deploy/src/main.rs @@ -159,10 +159,9 @@ impl CliConfig { async fn main() -> Result<()> { let cli = CliConfig::parse(); - // The published-chart version flag is the dev/prod discriminator: - // present → CD path (published OCI chart, Zitadel SSO only); absent - // → dev/e2e path (rendered chart, user/pass). Keeping the kubeconfig - // tempfile alive across the run; it's dropped (and deleted) on exit. + // `--operator-chart-version` is the dev/prod discriminator: set → CD + // (published chart, SSO-only); unset → dev/e2e (rendered, user/pass). + // The guard keeps the kubeconfig tempfile alive for the whole run. let _kubeconfig_guard: Option; let scores: Vec>> = if let Some(version) = cli.operator_chart_version.clone() { @@ -184,11 +183,9 @@ async fn main() -> Result<()> { .map_err(|e| anyhow::anyhow!("{e}")) } -/// CD path: the operator from its published OCI chart, authenticated to -/// NATS via Zitadel SSO. Secrets come from `ConfigClient` (EnvSource → -/// OpenBao), never from CLI flags — user/pass is unreachable here. -/// Returns the kubeconfig tempfile guard so `main` keeps it alive past -/// `KUBECONFIG` resolution. +/// CD path: operator from its published OCI chart, NATS auth via Zitadel +/// SSO. Secrets come from `ConfigClient`, never CLI flags. Returns the +/// kubeconfig tempfile guard so `main` outlives `KUBECONFIG` resolution. async fn published_operator( cli: &CliConfig, version: String, @@ -199,9 +196,8 @@ async fn published_operator( .await .context("loading FleetDeploySecrets (set HARMONY_CONFIG_FleetDeploySecrets or OpenBao)")?; - // Point the topology at the scoped fleet-deployer kubeconfig before - // K8sAnywhereTopology::from_env reads KUBECONFIG, so the runner pod - // itself stays permissionless. + // Set KUBECONFIG before K8sAnywhereTopology::from_env reads it, so the + // runner pod itself needs no standing permissions. let guard = match secrets.kubeconfig { Some(kubeconfig) => { let mut f = tempfile::NamedTempFile::new().context("kubeconfig tempfile")?; diff --git a/fleet/harmony-fleet-deploy/src/secrets.rs b/fleet/harmony-fleet-deploy/src/secrets.rs index 023bd21f..54126774 100644 --- a/fleet/harmony-fleet-deploy/src/secrets.rs +++ b/fleet/harmony-fleet-deploy/src/secrets.rs @@ -1,33 +1,25 @@ -//! Secrets the **prod / CD** operator deploy needs, loaded through -//! [`harmony_config::ConfigClient`]. This path is Zitadel-SSO-only by -//! construction — there is no user/pass field here, so dev-only -//! user/pass auth cannot leak into a published-chart deploy. The -//! rendered-chart (dev/e2e) path keeps its own `--nats-*` flags. -//! -//! `ConfigClient::for_namespace` resolves these from `EnvSource` then -//! OpenBao (when its env is set) — so the in-cluster CD runner pulls -//! them from OpenBao with no plaintext on the runner, while a developer -//! can override locally with `HARMONY_CONFIG_FleetDeploySecrets='{…}'`. +//! Secrets for the published-chart (CD) operator deploy, via +//! [`harmony_config::ConfigClient`]. SSO-only by construction: no +//! user/pass field exists, so dev-only user/pass auth can't reach a prod +//! deploy. Resolved EnvSource → OpenBao, so the in-cluster runner pulls +//! them at job time with no plaintext on the pod. use harmony_config::Config; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// `#[config(secret)]`: every field is secret — masked in logs and kept -/// out of the cleartext SQLite source. +/// `#[config(secret)]` keeps every field out of cleartext SQLite and +/// masks it in logs. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Config)] #[config(secret)] pub struct FleetDeploySecrets { - /// Operator's `[credentials]` TOML (zitadel-jwt) — prod NATS auth - /// via machine identity. Applied as the operator Secret before the - /// helm install. Required: the prod path has no user/pass fallback. + /// Operator's zitadel-jwt `[credentials]` TOML — its NATS auth. The + /// CD path's only credential; there is no user/pass fallback. pub operator_credentials_toml: String, - /// kubeconfig the deploy authenticates with — the scoped - /// fleet-deployer ServiceAccount credential. Kept in config (not on - /// the runner pod) so the in-cluster runner stays permissionless: - /// the job fetches it at runtime. `None` falls back to the ambient - /// `KUBECONFIG` / in-cluster config. + /// Scoped fleet-deployer credential, fetched at job time so the + /// runner pod holds no standing permissions. `None` uses the ambient + /// `KUBECONFIG`. #[serde(default)] pub kubeconfig: Option, } @@ -38,15 +30,11 @@ mod tests { use harmony_config::ConfigClass; #[test] - fn kubeconfig_is_optional() { - let json = r#"{ "operator_credentials_toml": "type = \"zitadel-jwt\"" }"#; - let s: FleetDeploySecrets = serde_json::from_str(json).unwrap(); - assert_eq!(s.operator_credentials_toml, "type = \"zitadel-jwt\""); + fn secret_class_and_optional_kubeconfig() { + let s: FleetDeploySecrets = + serde_json::from_str(r#"{ "operator_credentials_toml": "x" }"#).unwrap(); assert!(s.kubeconfig.is_none()); - } - - #[test] - fn is_secret_class() { + // Secrets must never land in cleartext SQLite. assert_eq!(FleetDeploySecrets::CLASS, ConfigClass::Secret); } } -- 2.39.5 From 8736efc29ec2ae97b3c469ea04d8d323d71ccf20 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 29 May 2026 14:53:50 -0400 Subject: [PATCH 3/7] feat(fleet): deploy by release tag, not a typed version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy workflow took a hand-typed chart version (a second source of truth, typo-prone). Take a --from-tag instead and parse the version in Rust via the existing version_from_tag — the tag is the single source of truth and YAML never parses it. --operator-chart-version stays for the laptop path. --- .../harmony-fleet-operator-deploy.yaml | 6 +++--- fleet/deployment-process.md | 15 ++++++++------ fleet/harmony-fleet-deploy/src/main.rs | 20 ++++++++++++++++++- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/harmony-fleet-operator-deploy.yaml b/.gitea/workflows/harmony-fleet-operator-deploy.yaml index 2a9098b4..fe132247 100644 --- a/.gitea/workflows/harmony-fleet-operator-deploy.yaml +++ b/.gitea/workflows/harmony-fleet-operator-deploy.yaml @@ -7,8 +7,8 @@ name: harmony-fleet-operator — deploy (staging) on: workflow_dispatch: inputs: - version: - description: 'Published operator chart version, e.g. 0.0.2' + tag: + description: 'Release tag to deploy, e.g. harmony-fleet-operator-v0.0.2' required: true type: string @@ -40,6 +40,6 @@ jobs: run: | cargo run --release -p harmony-fleet-deploy --bin harmony-fleet-deploy -- \ --filter FleetOperatorScore \ - --operator-chart-version "${{ inputs.version }}" \ + --from-tag "${{ inputs.tag }}" \ --namespace fleet-system \ --yes diff --git a/fleet/deployment-process.md b/fleet/deployment-process.md index 2dfb7ed9..79dfd7c4 100644 --- a/fleet/deployment-process.md +++ b/fleet/deployment-process.md @@ -31,13 +31,16 @@ cargo run -p harmony-fleet-deploy --bin harmony-fleet-release -- \ ## 2. Deploy a published version to staging (a click) The deploy is a manual `workflow_dispatch` — -`.gitea/workflows/harmony-fleet-operator-deploy.yaml`. Enter the -published chart version and run it. It executes on the **in-cluster -staging runner** and runs `harmony-fleet-deploy --filter -FleetOperatorScore --operator-chart-version `, which installs -`oci://hub.nationtech.io/harmony/harmony-fleet-operator:` +`.gitea/workflows/harmony-fleet-operator-deploy.yaml` — run after the +release workflow finishes. Enter the **release tag** (e.g. +`harmony-fleet-operator-v0.0.2`); the version is parsed from it in Rust, +so the tag is the single source of truth and YAML never parses it. It +executes on the **in-cluster staging runner** and runs +`harmony-fleet-deploy --filter FleetOperatorScore --from-tag `, +which installs the published +`oci://hub.nationtech.io/harmony/harmony-fleet-operator:` chart instead of rendering from source. Same command bootstraps and upgrades; -re-running the same version is a no-op. +re-running the same tag is a no-op. **Auth is Zitadel-SSO-only on this path.** The job carries no plaintext secrets and the runner pod carries no Kubernetes permissions. It pulls a diff --git a/fleet/harmony-fleet-deploy/src/main.rs b/fleet/harmony-fleet-deploy/src/main.rs index 5a7cc541..24d6142b 100644 --- a/fleet/harmony-fleet-deploy/src/main.rs +++ b/fleet/harmony-fleet-deploy/src/main.rs @@ -29,6 +29,7 @@ use harmony_config::ConfigClient; use harmony_fleet_deploy::nats::UserPassCredentials; use harmony_fleet_deploy::{ FleetAgentScore, FleetDeploySecrets, FleetNatsScore, FleetOperatorScore, agent::PodTarget, + version_from_tag, }; #[derive(Parser, Debug)] @@ -105,6 +106,13 @@ struct CliConfig { #[arg(long, env = "HARMONY_FLEET_OPERATOR_CHART_VERSION")] operator_chart_version: Option, + /// Release tag to deploy (e.g. `harmony-fleet-operator-v0.0.2`); the + /// version is parsed from it in Rust, so the CD workflow passes a tag + /// and never parses one in YAML. Takes precedence over + /// `--operator-chart-version`. + #[arg(long, env = "HARMONY_FLEET_OPERATOR_TAG")] + from_tag: Option, + #[arg( long, env = "HARMONY_FLEET_OPERATOR_CHART_REGISTRY", @@ -130,6 +138,16 @@ struct CliConfig { } impl CliConfig { + /// The published chart version to deploy, or `None` for the dev + /// rendered-chart path. `--from-tag` wins and is parsed in Rust; + /// otherwise `--operator-chart-version` is taken verbatim. + fn chart_version(&self) -> Result> { + match &self.from_tag { + Some(tag) => Ok(Some(version_from_tag(tag)?)), + None => Ok(self.operator_chart_version.clone()), + } + } + /// 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. @@ -164,7 +182,7 @@ async fn main() -> Result<()> { // The guard keeps the kubeconfig tempfile alive for the whole run. let _kubeconfig_guard: Option; let scores: Vec>> = - if let Some(version) = cli.operator_chart_version.clone() { + if let Some(version) = cli.chart_version()? { let (operator, guard) = published_operator(&cli, version).await?; _kubeconfig_guard = guard; vec![Box::new(operator)] -- 2.39.5 From 7f76c9e81fc93d4d21a3653eccb433c8b91cca51 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 29 May 2026 15:03:38 -0400 Subject: [PATCH 4/7] =?UTF-8?q?refactor(fleet):=20deploy=20binary=20is=20o?= =?UTF-8?q?perator-only=20=E2=80=94=20load=20config,=20run=20one=20Score?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binary carried a dev nats+operator+agent stack with user/pass flags that no automated caller used (the e2e harness composes those Scores from the lib directly). Cut it: main.rs is now load FleetDeploySecrets → run one FleetOperatorScore. 265→118 lines. Matches ADR-023 (one Score per component, compose upward). --- fleet/README.md | 18 +- fleet/harmony-fleet-deploy/Cargo.toml | 5 +- fleet/harmony-fleet-deploy/src/main.rs | 226 +++++-------------------- 3 files changed, 48 insertions(+), 201 deletions(-) diff --git a/fleet/README.md b/fleet/README.md index aeefd3a6..38c3436d 100644 --- a/fleet/README.md +++ b/fleet/README.md @@ -102,23 +102,17 @@ k3d cluster delete fleet-e2e ## Production deploys -`harmony-fleet-deploy` is the binary that puts the fleet stack on a real cluster (OKD, vanilla k8s, anywhere `K8sAnywhereTopology` can reach). It composes `FleetNatsScore` + `FleetOperatorScore` + `FleetAgentScore` against the topology you point it at. +`harmony-fleet-deploy` puts the published operator chart on a real cluster (OKD, vanilla k8s, anywhere `K8sAnywhereTopology` can reach) — the `harmony apply` / CD path. It loads `FleetDeploySecrets` from config (Env → OpenBao) and runs one `FleetOperatorScore`; auth is Zitadel-SSO-only. The full bring-up stack (`FleetNatsScore` + `FleetAgentScore` + …) is composed by the e2e harness directly over the same lib Scores, not by this binary. ```bash -# Default: K8sAnywhereTopology against whatever KUBECONFIG points at +# Deploy a released tag (version parsed from it in Rust): cargo run -p harmony-fleet-deploy -- \ - --namespace fleet-system \ - --operator-image hub.nationtech.io/harmony/harmony-fleet-operator:dev \ - --agent-image hub.nationtech.io/harmony/harmony-fleet-agent:dev \ - --agent-device-id fleet-agent-01 - -# Pick a single component with the harmony_cli filter -cargo run -p harmony-fleet-deploy -- \ - --namespace fleet-system \ - -- --filter FleetOperatorScore --all + --filter FleetOperatorScore \ + --from-tag harmony-fleet-operator-v0.0.2 \ + --namespace fleet-system --yes ``` -`harmony-fleet-deploy` reads its full config from CLI flags + env vars (`FLEET_NAMESPACE`, `FLEET_OPERATOR_IMAGE`, …). The minimal-CLI surface is deliberate — per ADR-023 the long-term answer is a plugin-discovery layer over `harmony-*` binaries; until that lands, deploy crates stay small and use the existing `harmony_cli`. +See [`deployment-process.md`](deployment-process.md) for the clickable CD workflow and the in-cluster runner bootstrap. ### Connecting to the operator diff --git a/fleet/harmony-fleet-deploy/Cargo.toml b/fleet/harmony-fleet-deploy/Cargo.toml index beeffc1a..fb855a03 100644 --- a/fleet/harmony-fleet-deploy/Cargo.toml +++ b/fleet/harmony-fleet-deploy/Cargo.toml @@ -9,9 +9,8 @@ description = "Deploy-side Scores for the fleet stack: operator, agent, NATS, ca [lib] path = "src/lib.rs" -# CLI entry point. `harmony-fleet-deploy ` picks a subcommand -# per fleet component plus an `all` composite. Built on harmony_cli the -# way the rest of the workspace's *-deploy crates are. +# CLI entry point: deploy the published operator chart (harmony apply). +# Built on harmony_cli like the rest of the workspace's *-deploy crates. [[bin]] name = "harmony-fleet-deploy" path = "src/main.rs" diff --git a/fleet/harmony-fleet-deploy/src/main.rs b/fleet/harmony-fleet-deploy/src/main.rs index 24d6142b..8d449dd0 100644 --- a/fleet/harmony-fleet-deploy/src/main.rs +++ b/fleet/harmony-fleet-deploy/src/main.rs @@ -1,45 +1,28 @@ -//! `harmony-fleet-deploy` — deploy the fleet stack to a cluster. +//! `harmony-fleet-deploy` — deploy the published fleet operator chart +//! (`harmony apply`). Loads its secrets from config and runs one +//! [`FleetOperatorScore`] against [`K8sAnywhereTopology`]. //! -//! Built on `harmony_cli::run` like the rest of the workspace's -//! deploy binaries (`harmony_agent_deploy`, …). The CLI offers a -//! minimal env-driven config and hands off to `harmony_cli`, which -//! provides the standard `--filter` / `--all` / `--list` selection -//! surface; pick a single component by filter or run them all. -//! -//! Topology default is [`K8sAnywhereTopology::from_env`] — local k3d -//! when `HARMONY_USE_LOCAL_K3D` flips, otherwise whatever cluster -//! `KUBECONFIG` points at. Per ADR-023 the binary's full set of -//! topologies is compile-time; for the moment only `K8sAnywhere` is -//! wired in, with `K8sBareTopology` planned for the next iteration. -//! -//! What the binary owns: assembling the Scores from environment -//! input. What it does **not** own: any handrolled k8s manifests, -//! any imperative bring-up loops, any auth secret rendering — that -//! all sits inside the `*Score` impls in [`harmony_fleet_deploy`]. +//! The full dev/e2e stack (NATS + agent, user/pass) is composed by the +//! e2e harness directly over the `harmony_fleet_deploy` Scores — not +//! here. This binary is the prod/CD path: Zitadel-SSO-only, no +//! handrolled manifests. use std::io::Write; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use clap::Parser; use harmony::inventory::Inventory; -use harmony::score::Score; use harmony::topology::K8sAnywhereTopology; use harmony_cli::Args as HarmonyCliArgs; use harmony_config::ConfigClient; -use harmony_fleet_deploy::nats::UserPassCredentials; -use harmony_fleet_deploy::{ - FleetAgentScore, FleetDeploySecrets, FleetNatsScore, FleetOperatorScore, agent::PodTarget, - version_from_tag, -}; +use harmony_fleet_deploy::{FleetDeploySecrets, FleetOperatorScore, version_from_tag}; #[derive(Parser, Debug)] #[command( name = "harmony-fleet-deploy", - about = "Deploy the harmony fleet stack to a Kubernetes cluster" + about = "Deploy the published harmony fleet operator chart" )] struct CliConfig { - /// Namespace every component lands in. Override with - /// `HARMONY_FLEET_NAMESPACE`. #[arg( long, env = "HARMONY_FLEET_NAMESPACE", @@ -47,72 +30,16 @@ struct CliConfig { )] 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 user/pass — dev / rendered-chart path ONLY. The published - // chart (CD) path is Zitadel-SSO-only and never reads these, so - // user/pass can't leak into a prod deploy. - /// NATS admin user (full pub/sub on the `APP` account). Dev only. - #[arg(long, env = "HARMONY_FLEET_NATS_ADMIN_USER")] - nats_admin_user: Option, - - /// NATS admin password. Dev only. - #[arg(long, env = "HARMONY_FLEET_NATS_ADMIN_PASS")] - nats_admin_pass: Option, - - /// NATS device user (limited pub/sub). Dev only. - #[arg(long, env = "HARMONY_FLEET_NATS_DEVICE_USER")] - nats_device_user: Option, - - /// NATS device password. Dev only. - #[arg(long, env = "HARMONY_FLEET_NATS_DEVICE_PASS")] - nats_device_pass: Option, - - /// Deploy the published operator chart at this version - /// (`oci:////harmony-fleet-operator:`) - /// instead of rendering one from source — the CD `harmony apply` - /// path. Re-run with a newer version to roll forward. - #[arg(long, env = "HARMONY_FLEET_OPERATOR_CHART_VERSION")] - operator_chart_version: Option, - /// Release tag to deploy (e.g. `harmony-fleet-operator-v0.0.2`); the - /// version is parsed from it in Rust, so the CD workflow passes a tag - /// and never parses one in YAML. Takes precedence over - /// `--operator-chart-version`. + /// version is parsed from it in Rust so the workflow passes a tag and + /// never parses one in YAML. Wins over `--operator-chart-version`. #[arg(long, env = "HARMONY_FLEET_OPERATOR_TAG")] from_tag: Option, + /// Bare chart version, for the laptop path. + #[arg(long, env = "HARMONY_FLEET_OPERATOR_CHART_VERSION")] + operator_chart_version: Option, + #[arg( long, env = "HARMONY_FLEET_OPERATOR_CHART_REGISTRY", @@ -127,96 +54,39 @@ struct CliConfig { )] operator_chart_project: String, - /// Config/secret namespace the published-chart (CD) path resolves - /// `FleetDeploySecrets` under (`EnvSource` → OpenBao). + /// Config namespace `FleetDeploySecrets` resolves under (Env → OpenBao). #[arg(long, env = "HARMONY_SECRET_NAMESPACE", default_value = "harmony")] config_namespace: String, - // Flattened so CI can pass `--yes` / `--filter` etc. #[command(flatten)] harmony_cli: HarmonyCliArgs, } impl CliConfig { - /// The published chart version to deploy, or `None` for the dev - /// rendered-chart path. `--from-tag` wins and is parsed in Rust; - /// otherwise `--operator-chart-version` is taken verbatim. - fn chart_version(&self) -> Result> { - match &self.from_tag { - Some(tag) => Ok(Some(version_from_tag(tag)?)), - None => Ok(self.operator_chart_version.clone()), + fn chart_version(&self) -> Result { + match (&self.from_tag, &self.operator_chart_version) { + (Some(tag), _) => version_from_tag(tag), + (None, Some(v)) => Ok(v.clone()), + (None, None) => bail!("set --from-tag or --operator-chart-version"), } } - - /// 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 { - 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 version = cli.chart_version()?; - // `--operator-chart-version` is the dev/prod discriminator: set → CD - // (published chart, SSO-only); unset → dev/e2e (rendered, user/pass). - // The guard keeps the kubeconfig tempfile alive for the whole run. - let _kubeconfig_guard: Option; - let scores: Vec>> = - if let Some(version) = cli.chart_version()? { - let (operator, guard) = published_operator(&cli, version).await?; - _kubeconfig_guard = guard; - vec![Box::new(operator)] - } else { - _kubeconfig_guard = None; - dev_stack(&cli)? - }; - - harmony_cli::run( - Inventory::autoload(), - K8sAnywhereTopology::from_env(), - scores, - Some(cli.harmony_cli), - ) - .await - .map_err(|e| anyhow::anyhow!("{e}")) -} - -/// CD path: operator from its published OCI chart, NATS auth via Zitadel -/// SSO. Secrets come from `ConfigClient`, never CLI flags. Returns the -/// kubeconfig tempfile guard so `main` outlives `KUBECONFIG` resolution. -async fn published_operator( - cli: &CliConfig, - version: String, -) -> Result<(FleetOperatorScore, Option)> { - let client = ConfigClient::for_namespace(&cli.config_namespace).await; - let secrets: FleetDeploySecrets = client + let secrets: FleetDeploySecrets = ConfigClient::for_namespace(&cli.config_namespace) + .await .get() .await .context("loading FleetDeploySecrets (set HARMONY_CONFIG_FleetDeploySecrets or OpenBao)")?; - // Set KUBECONFIG before K8sAnywhereTopology::from_env reads it, so the - // runner pod itself needs no standing permissions. - let guard = match secrets.kubeconfig { + // Point KUBECONFIG at the scoped deployer credential before the + // topology reads it, so the runner pod needs no standing permissions. + // Held to end of scope so the tempfile outlives the deploy. + let _kubeconfig = match &secrets.kubeconfig { Some(kubeconfig) => { let mut f = tempfile::NamedTempFile::new().context("kubeconfig tempfile")?; f.write_all(kubeconfig.as_bytes()) @@ -229,36 +99,20 @@ async fn published_operator( }; let operator = FleetOperatorScore::new() - .namespace(cli.namespace.clone()) + .namespace(cli.namespace) .credentials(secrets.operator_credentials_toml) .published_chart( - cli.operator_chart_registry.clone(), - cli.operator_chart_project.clone(), + cli.operator_chart_registry, + cli.operator_chart_project, version, ); - Ok((operator, guard)) -} -/// Dev/e2e path: the full stack (NATS + operator + agent) from rendered -/// charts with simple user/pass auth supplied via `--nats-*` flags. -fn dev_stack(cli: &CliConfig) -> Result>>> { - 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 operator = FleetOperatorScore::new() - .namespace(cli.namespace.clone()) - .image(cli.operator_image.clone()) - .nats_url(nats.in_cluster_url()); - 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, - ), - ); - Ok(vec![Box::new(nats), Box::new(operator), Box::new(agent)]) + harmony_cli::run( + Inventory::autoload(), + K8sAnywhereTopology::from_env(), + vec![Box::new(operator)], + Some(cli.harmony_cli), + ) + .await + .map_err(|e| anyhow::anyhow!("{e}")) } -- 2.39.5 From d687b29f3558517c7ee30fab2d3920db9d4fab1b Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 29 May 2026 16:07:01 -0400 Subject: [PATCH 5/7] docs(fleet): manual staging deploy is interim; drop premature CD workflow Full in-cluster CD is blocked on headless OpenBao auth (Zitadel machine identity), so the clickable deploy-staging workflow + its runner would be dead config. Drop it; document the manual operator deploy (same secure OpenBao-config path) until the auth flow lands. --- .../harmony-fleet-operator-deploy.yaml | 45 ----------- fleet/deployment-process.md | 81 +++++++------------ 2 files changed, 27 insertions(+), 99 deletions(-) delete mode 100644 .gitea/workflows/harmony-fleet-operator-deploy.yaml diff --git a/.gitea/workflows/harmony-fleet-operator-deploy.yaml b/.gitea/workflows/harmony-fleet-operator-deploy.yaml deleted file mode 100644 index fe132247..00000000 --- a/.gitea/workflows/harmony-fleet-operator-deploy.yaml +++ /dev/null @@ -1,45 +0,0 @@ -name: harmony-fleet-operator — deploy (staging) -# Manual "deploy" click for a published chart version (the release -# workflow publishes on tag). Runs on the in-cluster, permissionless -# staging runner, which pulls its kube + operator credentials from -# OpenBao via harmony_config. Runner/OpenBao bootstrap: -# fleet/deployment-process.md. -on: - workflow_dispatch: - inputs: - tag: - description: 'Release tag to deploy, e.g. harmony-fleet-operator-v0.0.2' - required: true - type: string - -jobs: - deploy-staging: - container: - image: hub.nationtech.io/harmony/harmony_composer:latest - runs-on: fleet-staging - steps: - - name: Checkout code - uses: actions/checkout@v4 - - # TODO: bake helm into harmony_composer so this step disappears. - - name: Install helm - run: curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - - - name: Log in to hub.nationtech.io (helm OCI) - run: | - echo "${{ secrets.HUB_BOT_PASSWORD }}" \ - | helm registry login hub.nationtech.io \ - --username "${{ secrets.HUB_BOT_USER }}" --password-stdin - - - name: Deploy published operator chart - env: - # OpenBao auth comes from the runner env (machine-identity PR); - # FleetDeploySecrets is then resolved from OpenBao. - OPENBAO_URL: ${{ secrets.OPENBAO_URL }} - HARMONY_SECRET_NAMESPACE: harmony - run: | - cargo run --release -p harmony-fleet-deploy --bin harmony-fleet-deploy -- \ - --filter FleetOperatorScore \ - --from-tag "${{ inputs.tag }}" \ - --namespace fleet-system \ - --yes diff --git a/fleet/deployment-process.md b/fleet/deployment-process.md index 79dfd7c4..70e6e115 100644 --- a/fleet/deployment-process.md +++ b/fleet/deployment-process.md @@ -28,73 +28,46 @@ cargo run -p harmony-fleet-deploy --bin harmony-fleet-release -- \ --from-tag harmony-fleet-operator-v0.0.2 --no-push ``` -## 2. Deploy a published version to staging (a click) +## 2. Deploy a published version to staging (manual, for now) -The deploy is a manual `workflow_dispatch` — -`.gitea/workflows/harmony-fleet-operator-deploy.yaml` — run after the -release workflow finishes. Enter the **release tag** (e.g. -`harmony-fleet-operator-v0.0.2`); the version is parsed from it in Rust, -so the tag is the single source of truth and YAML never parses it. It -executes on the **in-cluster staging runner** and runs -`harmony-fleet-deploy --filter FleetOperatorScore --from-tag `, -which installs the published -`oci://hub.nationtech.io/harmony/harmony-fleet-operator:` chart -instead of rendering from source. Same command bootstraps and upgrades; -re-running the same tag is a no-op. - -**Auth is Zitadel-SSO-only on this path.** The job carries no plaintext -secrets and the runner pod carries no Kubernetes permissions. It pulls a -`FleetDeploySecrets` config from OpenBao via `harmony_config`: - -- `operator_credentials_toml` — the operator's zitadel-jwt NATS - credentials (applied as the operator Secret before `helm install`), -- `kubeconfig` — the scoped `fleet-deployer` ServiceAccount, so the - permissionless runner authenticates to the API only for this job. - -There is no user/pass on the published-chart path — it's structurally -unreachable. (`--nats-*` flags exist only for the dev/e2e rendered-chart -path in step 1's `--no-push` smoke-test.) - -Laptop fallback (override the config via env instead of OpenBao): +Push to staging is manual until headless OpenBao auth (Zitadel machine +identity) lands; secrets still come from shared OpenBao config. Point at +your staging kube context and OpenBao, then run the operator deploy: ```sh -export HARMONY_CONFIG_FleetDeploySecrets='{"operator_credentials_toml":"…","kubeconfig":"…"}' +export OPENBAO_URL= +export OPENBAO_TOKEN=/*> harmony-fleet-deploy --filter FleetOperatorScore \ - --operator-chart-version 0.0.2 --namespace fleet-system --yes + --from-tag --namespace fleet-staging --yes ``` +It installs the published +`oci://hub.nationtech.io/harmony/harmony-fleet-operator:` chart; +the version is parsed from the tag in Rust (the tag is the only source +of truth). Same command bootstraps and upgrades; re-running the same tag +is a no-op. Auth is Zitadel-SSO-only: the operator gets its zitadel-jwt +`operator_credentials_toml` from `FleetDeploySecrets` in OpenBao (no +user/pass on the published-chart path). For manual deploy, store that +config **without** a `kubeconfig` field so your own kube context is used. + ## 3. Roll forward -Re-run the deploy workflow with a newer (or previous-good) version. -`helm upgrade --install` applies it and fails loudly if convergence -fails — no automatic rollback. Fix the spec, bump, re-run. +Re-run with a newer (or previous-good) tag. `helm upgrade --install` +applies it and fails loudly if convergence fails — no automatic +rollback. Fix the spec, bump, re-run. ## Automated vs. manual | Step | Where | |---|---| | Build + push image + chart on tag | CI (`release` job, on tag) | -| Deploy a published version + roll forward | CI (`deploy-staging`, manual `workflow_dispatch` click) | +| Push to staging + roll forward | Manual (operator runs the deploy) | -## One-time bootstrap (in-cluster staging runner) +## Future: in-cluster CD (blocked on headless OpenBao auth) -`deploy-staging` runs `runs-on: fleet-staging` — a Gitea `act_runner` -provisioned once inside an isolated tenant namespace in the cluster. -This is what makes the API reachable without exposing it publicly or -over the VPN: the runner is already inside. Provision (reproducibly, via -Harmony Scores — not handrolled manifests): - -- **Runner pod** with `automountServiceAccountToken: false` — zero - standing Kubernetes permissions. -- **Egress NetworkPolicy** allowing only the LB IP on :443 (OpenBao / - Zitadel / hub via the DNS alias) plus the API endpoint; deny the rest. -- **`fleet-deployer` ServiceAccount + RBAC** scoped to exactly what the - operator chart installs (its CRDs, ClusterRole, ClusterRoleBinding + - the `fleet-system` namespaced resources). -- **Store the deployer kubeconfig + operator credentials in OpenBao** - under `FleetDeploySecrets` so the job resolves them at runtime. - -Until the Zitadel→OpenBao machine-identity auth lands, the runner's -OpenBao auth env is supplied directly; afterward the runner authenticates -as a Zitadel machine identity. Production-gated promotion is a follow-up -(ADR-012-2). +Once `harmony_config` can authenticate to OpenBao headlessly (Zitadel +machine identity), these exports become a `deploy-staging` workflow on an +in-cluster, permissionless Gitea runner that pulls a `fleet-deployer` +`kubeconfig` + operator credentials from OpenBao at job time (provisioned +via a `TenantScore` with one extra egress CIDR to the OpenBao/Zitadel +ingress). Production-gated promotion is a later step (ADR-012-2). -- 2.39.5 From 1f525cd5d1ac5263217ad46c32286ef6e0f440f2 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 29 May 2026 17:02:23 -0400 Subject: [PATCH 6/7] feat(openbao): optional cert-manager ingress TLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenbaoScore.tls_issuer: Some(issuer) adds the cert-manager cluster-issuer annotation + tls block (edge TLS, listener stays plain); None keeps plain HTTP. Option not bool — cert-manager needs the issuer name. Rendering extracted to values() and covered by tests. --- examples/harmony_sso/src/main.rs | 1 + examples/openbao/src/main.rs | 1 + harmony/src/modules/openbao/mod.rs | 77 +++++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/examples/harmony_sso/src/main.rs b/examples/harmony_sso/src/main.rs index 1fe895b3..4c5c2944 100644 --- a/examples/harmony_sso/src/main.rs +++ b/examples/harmony_sso/src/main.rs @@ -247,6 +247,7 @@ async fn main() -> anyhow::Result<()> { OpenbaoScore { host: OPENBAO_HOST.to_string(), openshift: false, + tls_issuer: None, } .interpret(&Inventory::autoload(), &topology) .await diff --git a/examples/openbao/src/main.rs b/examples/openbao/src/main.rs index adcb5f45..1329c87f 100644 --- a/examples/openbao/src/main.rs +++ b/examples/openbao/src/main.rs @@ -7,6 +7,7 @@ async fn main() { let openbao = OpenbaoScore { host: "openbao.sebastien.sto1.nationtech.io".to_string(), openshift: false, + tls_issuer: None, }; harmony_cli::run( diff --git a/harmony/src/modules/openbao/mod.rs b/harmony/src/modules/openbao/mod.rs index e7e11bc5..80d8eabb 100644 --- a/harmony/src/modules/openbao/mod.rs +++ b/harmony/src/modules/openbao/mod.rs @@ -22,19 +22,31 @@ pub struct OpenbaoScore { /// Set to true when deploying to OpenShift. Defaults to false for k3d/Kubernetes. #[serde(default)] pub openshift: bool, + /// cert-manager `ClusterIssuer` for ingress TLS. `None` serves plain + /// HTTP (TLS terminated elsewhere); `Some` adds the annotation + a + /// `tls` block so cert-manager issues and renews the cert. Carries the + /// issuer name because a bare bool can't address an issuer. + #[serde(default)] + pub tls_issuer: Option, } -impl Score for OpenbaoScore { - fn name(&self) -> String { - "OpenbaoScore".to_string() - } +impl OpenbaoScore { + fn values(&self) -> String { + let Self { + host, + openshift, + tls_issuer, + } = self; + // Edge TLS: the listener stays plain HTTP behind the ingress, which + // terminates with the cert-manager-issued cert. + let ingress_tls = match tls_issuer { + Some(issuer) => format!( + "\n annotations:\n cert-manager.io/cluster-issuer: {issuer}\n tls:\n - hosts: [{host}]\n secretName: openbao-tls" + ), + None => String::new(), + }; - #[doc(hidden)] - fn create_interpret(&self) -> Box> { - let host = &self.host; - let openshift = self.openshift; - - let values_yaml = Some(format!( + format!( r#"global: openshift: {openshift} server: @@ -65,7 +77,7 @@ server: ingress: enabled: true hosts: - - host: {host} + - host: {host}{ingress_tls} dataStorage: enabled: true size: 10Gi @@ -79,15 +91,24 @@ server: accessMode: ReadWriteOnce ui: enabled: true"# - )); + ) + } +} +impl Score for OpenbaoScore { + fn name(&self) -> String { + "OpenbaoScore".to_string() + } + + #[doc(hidden)] + fn create_interpret(&self) -> Box> { HelmChartScore { namespace: Some(NonBlankString::from_str("openbao").unwrap()), release_name: NonBlankString::from_str("openbao").unwrap(), chart_name: NonBlankString::from_str("openbao/openbao").unwrap(), chart_version: None, values_overrides: None, - values_yaml, + values_yaml: Some(self.values()), create_namespace: true, install_only: false, repository: Some(HelmRepository::new( @@ -99,3 +120,33 @@ ui: .create_interpret() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_issuer_renders_plain_ingress() { + let v = OpenbaoScore { + host: "bao.example".into(), + openshift: false, + tls_issuer: None, + } + .values(); + assert!(!v.contains("cert-manager.io/cluster-issuer")); + assert!(!v.contains("secretName")); + } + + #[test] + fn issuer_renders_certmanager_tls_for_host() { + let v = OpenbaoScore { + host: "bao.example".into(), + openshift: false, + tls_issuer: Some("letsencrypt".into()), + } + .values(); + assert!(v.contains("cert-manager.io/cluster-issuer: letsencrypt")); + assert!(v.contains("- hosts: [bao.example]")); + assert!(v.contains("secretName: openbao-tls")); + } +} -- 2.39.5 From 7638611b9f2eca4bf2824b321d4d9f4b2ca94536 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 29 May 2026 17:32:26 -0400 Subject: [PATCH 7/7] refactor(openbao): share OpenbaoInstance across deploy + setup scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit namespace/release/pod were duplicated as independent literals across OpenbaoScore (hardcoded) and OpenbaoSetupScore (defaults) — pod was a derived fact (`{release}-0`) stored as a literal that rots if release changes, and namespace agreement was by coincidence. Introduce OpenbaoInstance { namespace, release } with a derived pod(); both scores take it. Only the shared identity moves; per-score knobs (host, tls, kv_mount, policies, …) stay on their owner. --- examples/harmony_sso/src/main.rs | 1 + examples/openbao/src/main.rs | 1 + harmony/src/modules/openbao/mod.rs | 40 ++++++++++++++++++- harmony/src/modules/openbao/setup.rs | 59 +++++++++++++--------------- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/examples/harmony_sso/src/main.rs b/examples/harmony_sso/src/main.rs index 4c5c2944..96d70766 100644 --- a/examples/harmony_sso/src/main.rs +++ b/examples/harmony_sso/src/main.rs @@ -245,6 +245,7 @@ async fn main() -> anyhow::Result<()> { // Deploy + configure OpenBao (no JWT auth yet -- Zitadel isn't up) cleanup_openbao_webhook(&k8s).await?; OpenbaoScore { + instance: Default::default(), host: OPENBAO_HOST.to_string(), openshift: false, tls_issuer: None, diff --git a/examples/openbao/src/main.rs b/examples/openbao/src/main.rs index 1329c87f..eee92944 100644 --- a/examples/openbao/src/main.rs +++ b/examples/openbao/src/main.rs @@ -5,6 +5,7 @@ use harmony::{ #[tokio::main] async fn main() { let openbao = OpenbaoScore { + instance: Default::default(), host: "openbao.sebastien.sto1.nationtech.io".to_string(), openshift: false, tls_issuer: None, diff --git a/harmony/src/modules/openbao/mod.rs b/harmony/src/modules/openbao/mod.rs index 80d8eabb..6ab454b4 100644 --- a/harmony/src/modules/openbao/mod.rs +++ b/harmony/src/modules/openbao/mod.rs @@ -15,8 +15,41 @@ use crate::{ pub use setup::{OpenbaoJwtAuth, OpenbaoPolicy, OpenbaoSetupScore, OpenbaoUser}; +const DEFAULT_NAMESPACE: &str = "openbao"; +const DEFAULT_RELEASE: &str = "openbao"; + +/// Where one OpenBao instance lives — the single authority both the +/// deploy ([`OpenbaoScore`]) and the setup ([`OpenbaoSetupScore`]) take, +/// so namespace and release can't drift apart. +#[derive(Debug, Clone, Serialize)] +pub struct OpenbaoInstance { + pub namespace: String, + /// Helm release name; the chart names its StatefulSet after it. + pub release: String, +} + +impl OpenbaoInstance { + /// `{release}-0` — the chart deploys a StatefulSet, so a stored pod + /// literal would rot the moment `release` changes. + pub fn pod(&self) -> String { + format!("{}-0", self.release) + } +} + +impl Default for OpenbaoInstance { + fn default() -> Self { + Self { + namespace: DEFAULT_NAMESPACE.to_string(), + release: DEFAULT_RELEASE.to_string(), + } + } +} + #[derive(Debug, Serialize, Clone)] pub struct OpenbaoScore { + /// Where this OpenBao is deployed (namespace + helm release). + #[serde(default)] + pub instance: OpenbaoInstance, /// Host used for external access (ingress) pub host: String, /// Set to true when deploying to OpenShift. Defaults to false for k3d/Kubernetes. @@ -36,6 +69,7 @@ impl OpenbaoScore { host, openshift, tls_issuer, + instance: _, } = self; // Edge TLS: the listener stays plain HTTP behind the ingress, which // terminates with the cert-manager-issued cert. @@ -103,8 +137,8 @@ impl Score for OpenbaoScore { #[doc(hidden)] fn create_interpret(&self) -> Box> { HelmChartScore { - namespace: Some(NonBlankString::from_str("openbao").unwrap()), - release_name: NonBlankString::from_str("openbao").unwrap(), + namespace: Some(NonBlankString::from_str(&self.instance.namespace).unwrap()), + release_name: NonBlankString::from_str(&self.instance.release).unwrap(), chart_name: NonBlankString::from_str("openbao/openbao").unwrap(), chart_version: None, values_overrides: None, @@ -128,6 +162,7 @@ mod tests { #[test] fn no_issuer_renders_plain_ingress() { let v = OpenbaoScore { + instance: Default::default(), host: "bao.example".into(), openshift: false, tls_issuer: None, @@ -140,6 +175,7 @@ mod tests { #[test] fn issuer_renders_certmanager_tls_for_host() { let v = OpenbaoScore { + instance: Default::default(), host: "bao.example".into(), openshift: false, tls_issuer: Some("letsencrypt".into()), diff --git a/harmony/src/modules/openbao/setup.rs b/harmony/src/modules/openbao/setup.rs index 1971ed4b..52f355b3 100644 --- a/harmony/src/modules/openbao/setup.rs +++ b/harmony/src/modules/openbao/setup.rs @@ -13,8 +13,8 @@ use crate::{ }; use harmony_types::id::Id; -const DEFAULT_NAMESPACE: &str = "openbao"; -const DEFAULT_POD: &str = "openbao-0"; +use super::OpenbaoInstance; + const DEFAULT_KV_MOUNT: &str = "secret"; /// A policy to create in OpenBao. @@ -72,13 +72,9 @@ pub struct OpenbaoJwtAuth { /// deployments should use auto-unseal (Transit, cloud KMS, etc.). #[derive(Debug, Clone, Serialize)] pub struct OpenbaoSetupScore { - /// Kubernetes namespace where OpenBao is deployed. - #[serde(default = "default_namespace")] - pub namespace: String, - - /// StatefulSet pod name to exec into. - #[serde(default = "default_pod")] - pub pod: String, + /// Where the target OpenBao is deployed (namespace + release). + #[serde(default)] + pub instance: OpenbaoInstance, /// KV v2 mount path to enable. #[serde(default = "default_kv_mount")] @@ -97,12 +93,6 @@ pub struct OpenbaoSetupScore { pub jwt_auth: Option, } -fn default_namespace() -> String { - DEFAULT_NAMESPACE.to_string() -} -fn default_pod() -> String { - DEFAULT_POD.to_string() -} fn default_kv_mount() -> String { DEFAULT_KV_MOUNT.to_string() } @@ -110,8 +100,7 @@ fn default_kv_mount() -> String { impl Default for OpenbaoSetupScore { fn default() -> Self { Self { - namespace: default_namespace(), - pod: default_pod(), + instance: OpenbaoInstance::default(), kv_mount: default_kv_mount(), policies: Vec::new(), users: Vec::new(), @@ -164,8 +153,12 @@ impl OpenbaoSetupInterpret { k8s: &harmony_k8s::K8sClient, command: Vec<&str>, ) -> Result { - k8s.exec_pod_capture_output(&self.score.pod, Some(&self.score.namespace), command) - .await + k8s.exec_pod_capture_output( + &self.score.instance.pod(), + Some(&self.score.instance.namespace), + command, + ) + .await } async fn bao_command( @@ -279,8 +272,8 @@ impl OpenbaoSetupInterpret { // status and parse the `sealed` field authoritatively. let sealed = match k8s .exec_pod_capture( - &self.score.pod, - Some(&self.score.namespace), + &self.score.instance.pod(), + Some(&self.score.instance.namespace), vec!["bao", "status", "-format=json"], ) .await @@ -514,14 +507,18 @@ impl Interpret for OpenbaoSetupInterpret { .map_err(|e| InterpretError::new(format!("Failed to get K8s client: {e}")))?; // Wait for the pod to be running before attempting any operations. - k8s.wait_for_pod_ready(&self.score.pod, Some(&self.score.namespace)) - .await - .map_err(|e| { - InterpretError::new(format!( - "Pod {}/{} not ready: {e}", - self.score.namespace, self.score.pod - )) - })?; + k8s.wait_for_pod_ready( + &self.score.instance.pod(), + Some(&self.score.instance.namespace), + ) + .await + .map_err(|e| { + InterpretError::new(format!( + "Pod {}/{} not ready: {e}", + self.score.instance.namespace, + self.score.instance.pod() + )) + })?; let root_token = self.init(&k8s).await?; self.unseal(&k8s).await?; @@ -574,8 +571,8 @@ mod tests { #[test] fn default_score_carries_expected_mounts() { let s = OpenbaoSetupScore::default(); - assert_eq!(s.namespace, "openbao"); - assert_eq!(s.pod, "openbao-0"); + assert_eq!(s.instance.namespace, "openbao"); + assert_eq!(s.instance.pod(), "openbao-0"); assert_eq!(s.kv_mount, "secret"); } } -- 2.39.5