diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index 00e88792..651ff733 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::{debug, 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,20 +103,37 @@ impl AppContext { ) })?; + info!( + "Resolving deploy context '{name}' (profile {:?})", + def.profile + ); + debug!("Context '{name}' definition: {def:?}"); let guard = match (def.autoprovision, &def.k3d, &def.openbao_namespace) { - (true, None, None) => None, - (false, Some(cluster), None) => Some(k3d_kubeconfig(cluster)?), + (true, None, None) => { + info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')"); + None + } + (false, Some(cluster), None) => { + info!("Cluster access: existing local k3d cluster '{cluster}'"); + Some(k3d_kubeconfig(cluster)?) + } (false, None, Some(ns)) => { - 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?)" - ) - })?; + 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())?) } _ => bail!( @@ -211,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 f610767e..ae2a4cd9 100644 --- a/harmony_config/src/lib.rs +++ b/harmony_config/src/lib.rs @@ -196,31 +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; }; - - 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)) => { @@ -243,7 +255,7 @@ async fn openbao_from_env(namespace: &str) -> Option> { token: env("OPENBAO_TOKEN"), username: env("OPENBAO_USERNAME"), password: env("OPENBAO_PASSWORD"), - zitadel_sso_url: env("HARMONY_SSO_URL"), + zitadel_sso_url: sso_url, zitadel_client_id: env("HARMONY_SSO_CLIENT_ID"), jwt_role: env("OPENBAO_JWT_ROLE"), jwt_auth_mount: env("OPENBAO_JWT_AUTH_MOUNT"),