From e6eadd321c6af94ffe0e07ca3dc6c7d5af4fcc35 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 23:23:45 -0400 Subject: [PATCH 1/3] feat(context): load OpenBao URL from the deploy context AppContext parses openbao_url/zitadel_url and builds the OpenBao config source from the context URL via harmony_config::openbao_from_env_with_url, instead of requiring OPENBAO_URL in the ambient env. A prod context can point at its own OpenBao directly. --- harmony_app/src/context.rs | 38 ++++++++++++++++++++++++++++++++++---- harmony_config/src/lib.rs | 4 ++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index 00e88792..e2c986e5 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -15,6 +15,7 @@ use anyhow::{Context, Result, bail}; use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology}; use harmony_config::{Config, ConfigClient}; use harmony_k8s::K8sClient; +use log::info; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tempfile::NamedTempFile; @@ -39,6 +40,8 @@ struct ContextDef { autoprovision: bool, k3d: Option, openbao_namespace: Option, + openbao_url: Option, + zitadel_url: Option, } /// The cluster K8sAnywhereTopology autoprovisions (its K3DInstallationScore @@ -100,10 +103,16 @@ impl AppContext { ) })?; - let guard = match (def.autoprovision, &def.k3d, &def.openbao_namespace) { - (true, None, None) => None, - (false, Some(cluster), None) => Some(k3d_kubeconfig(cluster)?), - (false, None, Some(ns)) => { + info!("Context : {def:?}"); + let guard = match ( + def.autoprovision, + &def.k3d, + &def.openbao_namespace, + &def.openbao_url, + ) { + (true, None, None, None) => None, + (false, Some(cluster), None, None) => Some(k3d_kubeconfig(cluster)?), + (false, None, Some(ns), None) => { let access: ClusterAccess = ConfigClient::for_namespace(ns) .await .get() @@ -116,6 +125,27 @@ impl AppContext { })?; Some(write_kubeconfig(access.kubeconfig.as_bytes())?) } + (false, None, Some(ns), Some(openbao_url)) => { + if let Some(openbao) = + harmony_config::openbao_from_env_with_url(ns, openbao_url.clone()).await + { + let access: ClusterAccess = ConfigClient::new(vec![openbao]) + .get() + .await + .with_context(|| { + format!( + "loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) with url {openbao_url}\ + — are the Zitadel key + OPENBAO_* env vars set?)" + ) + })?; + Some(write_kubeconfig(access.kubeconfig.as_bytes())?) + } else { + bail!( + "loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) with url {openbao_url}\ + — are the Zitadel key + OPENBAO_* env vars set?)" + ) + } + } _ => bail!( "context '{name}' must set exactly one of `autoprovision = true`, \ `k3d`, or `openbao_namespace`" diff --git a/harmony_config/src/lib.rs b/harmony_config/src/lib.rs index f610767e..92230401 100644 --- a/harmony_config/src/lib.rs +++ b/harmony_config/src/lib.rs @@ -208,6 +208,10 @@ async fn openbao_from_env(namespace: &str) -> Option> { warn!("OpenBao URL not set; OpenBao source omitted from chain"); return None; }; + openbao_from_env_with_url(namespace, url).await +} + +pub async fn openbao_from_env_with_url(namespace: &str, url: String) -> Option> { let env = |k: &str| std::env::var(k).ok(); -- 2.39.5 From ad12ba3fd44d172789f6aaef431c4743f00bcddc Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 23 Jun 2026 08:51:55 -0400 Subject: [PATCH 2/3] feat(context): clearer deploy logs + honor zitadel_url INFO now states the resolved context, profile, and which cluster source is used (autoprovision k3d / existing k3d / OpenBao); DEBUG dumps the context definition. Wire the context's zitadel_url into HARMONY_SSO_URL so the OpenBao rung uses it instead of silently falling back to the ambient env. --- harmony_app/src/context.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index e2c986e5..4b3d22ac 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -15,7 +15,7 @@ use anyhow::{Context, Result, bail}; use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology}; use harmony_config::{Config, ConfigClient}; use harmony_k8s::K8sClient; -use log::info; +use log::{debug, info}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tempfile::NamedTempFile; @@ -103,16 +103,33 @@ impl AppContext { ) })?; - info!("Context : {def:?}"); + info!( + "Resolving deploy context '{name}' (profile {:?})", + def.profile + ); + debug!("Context '{name}' definition: {def:?}"); + // The OpenBao rung reads the Zitadel issuer from HARMONY_SSO_URL; let the + // context supply it (else it falls back to the ambient env). resolve() + // runs before any concurrent secret access, so set_var is safe here. + if let Some(url) = &def.zitadel_url { + unsafe { std::env::set_var("HARMONY_SSO_URL", url) }; + } let guard = match ( def.autoprovision, &def.k3d, &def.openbao_namespace, &def.openbao_url, ) { - (true, None, None, None) => None, - (false, Some(cluster), None, None) => Some(k3d_kubeconfig(cluster)?), + (true, None, None, None) => { + info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')"); + None + } + (false, Some(cluster), None, None) => { + info!("Cluster access: existing local k3d cluster '{cluster}'"); + Some(k3d_kubeconfig(cluster)?) + } (false, None, Some(ns), None) => { + info!("Cluster access: kubeconfig from OpenBao (namespace '{ns}', URL from env)"); let access: ClusterAccess = ConfigClient::for_namespace(ns) .await .get() @@ -126,6 +143,9 @@ impl AppContext { Some(write_kubeconfig(access.kubeconfig.as_bytes())?) } (false, None, Some(ns), Some(openbao_url)) => { + info!( + "Cluster access: kubeconfig from OpenBao (namespace '{ns}', URL '{openbao_url}')" + ); if let Some(openbao) = harmony_config::openbao_from_env_with_url(ns, openbao_url.clone()).await { -- 2.39.5 From fc80df580c0e4038659a65127088804725020181 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 23 Jun 2026 09:11:50 -0400 Subject: [PATCH 3/3] refactor(config): thread OpenBao + Zitadel URLs via openbao_source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace openbao_from_env_with_url with openbao_source(namespace, openbao_url, zitadel_sso_url): each URL overrides its env var (OPENBAO_URL/VAULT_ADDR, HARMONY_SSO_URL) when Some, else falls through to env. The deploy context passes its openbao_url + zitadel_url straight into the source instead of mutating process env — drops the unsafe set_var. ClusterAccess is read from an OpenBao-only client and the resolve match collapses back to one OpenBao arm. --- harmony_app/src/context.rs | 88 ++++++++++++++++---------------------- harmony_config/src/lib.rs | 44 +++++++++++-------- 2 files changed, 64 insertions(+), 68 deletions(-) diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index 4b3d22ac..651ff733 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -108,64 +108,34 @@ impl AppContext { def.profile ); debug!("Context '{name}' definition: {def:?}"); - // The OpenBao rung reads the Zitadel issuer from HARMONY_SSO_URL; let the - // context supply it (else it falls back to the ambient env). resolve() - // runs before any concurrent secret access, so set_var is safe here. - if let Some(url) = &def.zitadel_url { - unsafe { std::env::set_var("HARMONY_SSO_URL", url) }; - } - let guard = match ( - def.autoprovision, - &def.k3d, - &def.openbao_namespace, - &def.openbao_url, - ) { - (true, None, None, None) => { + let guard = match (def.autoprovision, &def.k3d, &def.openbao_namespace) { + (true, None, None) => { info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')"); None } - (false, Some(cluster), None, None) => { + (false, Some(cluster), None) => { info!("Cluster access: existing local k3d cluster '{cluster}'"); Some(k3d_kubeconfig(cluster)?) } - (false, None, Some(ns), None) => { - info!("Cluster access: kubeconfig from OpenBao (namespace '{ns}', URL from env)"); - let access: ClusterAccess = ConfigClient::for_namespace(ns) - .await - .get() - .await - .with_context(|| { - format!( - "loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \ - — are the Zitadel key + OPENBAO_* env vars set?)" - ) - })?; + (false, None, Some(ns)) => { + info!("Cluster access: kubeconfig from OpenBao (namespace '{ns}')"); + // The kubeconfig is an OpenBao secret, so read it from an + // OpenBao-only client built with the context's URLs (each + // falling through to env when unset). + let access: ClusterAccess = + openbao_client(ns, def.openbao_url.clone(), def.zitadel_url.clone()) + .await + .with_context(|| format!("reaching OpenBao for context '{name}'"))? + .get() + .await + .with_context(|| { + format!( + "loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \ + — are the Zitadel key + OpenBao URL/role set?" + ) + })?; Some(write_kubeconfig(access.kubeconfig.as_bytes())?) } - (false, None, Some(ns), Some(openbao_url)) => { - info!( - "Cluster access: kubeconfig from OpenBao (namespace '{ns}', URL '{openbao_url}')" - ); - if let Some(openbao) = - harmony_config::openbao_from_env_with_url(ns, openbao_url.clone()).await - { - let access: ClusterAccess = ConfigClient::new(vec![openbao]) - .get() - .await - .with_context(|| { - format!( - "loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) with url {openbao_url}\ - — are the Zitadel key + OPENBAO_* env vars set?)" - ) - })?; - Some(write_kubeconfig(access.kubeconfig.as_bytes())?) - } else { - bail!( - "loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) with url {openbao_url}\ - — are the Zitadel key + OPENBAO_* env vars set?)" - ) - } - } _ => bail!( "context '{name}' must set exactly one of `autoprovision = true`, \ `k3d`, or `openbao_namespace`" @@ -261,6 +231,24 @@ fn find_contexts_file() -> Result { } } +/// An OpenBao-only `ConfigClient` for `namespace`, using the given URLs (each +/// falling through to env). Errors when no OpenBao endpoint is resolvable. +async fn openbao_client( + namespace: &str, + openbao_url: Option, + zitadel_url: Option, +) -> Result { + let source = harmony_config::openbao_source(namespace, openbao_url, zitadel_url) + .await + .with_context(|| { + format!( + "no OpenBao source for '{namespace}' — set `openbao_url`/OPENBAO_URL \ + and the Zitadel auth env (HARMONY_ZITADEL_KEY_*, AUDIENCE, OPENBAO_JWT_ROLE)" + ) + })?; + Ok(ConfigClient::new(vec![source])) +} + fn write_kubeconfig(contents: &[u8]) -> Result { let mut file = NamedTempFile::with_prefix("harmony-ctx-").context("create kubeconfig tempfile")?; diff --git a/harmony_config/src/lib.rs b/harmony_config/src/lib.rs index 92230401..ae2a4cd9 100644 --- a/harmony_config/src/lib.rs +++ b/harmony_config/src/lib.rs @@ -196,35 +196,43 @@ impl ConfigClient { } } -/// Build an OpenBao-backed `StoreSource` from env vars, or `None` (with a -/// `warn!`) when `OPENBAO_URL`/`VAULT_ADDR` is unset or the connection -/// fails — so a missing OpenBao degrades the chain instead of breaking -/// startup. +/// Build an OpenBao-backed `StoreSource` purely from env — the default chain. async fn openbao_from_env(namespace: &str) -> Option> { - let Some(url) = std::env::var("OPENBAO_URL") - .or_else(|_| std::env::var("VAULT_ADDR")) - .ok() + openbao_source(namespace, None, None).await +} + +/// Build an OpenBao-backed `StoreSource`. `openbao_url` and `zitadel_sso_url` +/// override their env vars (`OPENBAO_URL`/`VAULT_ADDR`, `HARMONY_SSO_URL`) when +/// `Some`, else fall through to the env — so a caller that knows the endpoints +/// (e.g. a deploy context) supplies them without mutating process env. Returns +/// `None` (with a `warn!`) when no OpenBao URL is resolvable or the connection +/// fails, so a missing OpenBao degrades the chain instead of breaking startup. +pub async fn openbao_source( + namespace: &str, + openbao_url: Option, + zitadel_sso_url: Option, +) -> Option> { + let env = |k: &str| std::env::var(k).ok(); + + let Some(url) = openbao_url + .or_else(|| env("OPENBAO_URL")) + .or_else(|| env("VAULT_ADDR")) else { warn!("OpenBao URL not set; OpenBao source omitted from chain"); return None; }; - openbao_from_env_with_url(namespace, url).await -} - -pub async fn openbao_from_env_with_url(namespace: &str, url: String) -> Option> { - - let env = |k: &str| std::env::var(k).ok(); + // The Zitadel base for both the JWT-bearer issuer and the OIDC rung. + let sso_url = zitadel_sso_url.or_else(|| env("HARMONY_SSO_URL")); // Headless Zitadel-machine → OpenBao (JWT-bearer) rung: needs a machine - // keyfile (path or inline JSON) plus the project-ID audience. The issuer - // is the same Zitadel base the OIDC rung uses (`HARMONY_SSO_URL`). Absent - // any piece, the rung stays off and the ladder falls through. + // keyfile (path or inline JSON) plus the project-ID audience. Absent any + // piece, the rung stays off and the ladder falls through. let zitadel_jwt_bearer = { let key_path = env("HARMONY_ZITADEL_KEY_PATH").map(PathBuf::from); let key_json = env("HARMONY_ZITADEL_KEY_JSON"); match ( key_path.is_some() || key_json.is_some(), - env("HARMONY_SSO_URL"), + sso_url.clone(), env("HARMONY_ZITADEL_AUDIENCE"), ) { (true, Some(oidc_issuer_url), Some(audience)) => { @@ -247,7 +255,7 @@ pub async fn openbao_from_env_with_url(namespace: &str, url: String) -> Option