feat/context-openbao-url #339

Merged
johnride merged 3 commits from feat/context-openbao-url into master 2026-06-23 13:25:52 +00:00
2 changed files with 76 additions and 26 deletions

View File

@@ -15,6 +15,7 @@ use anyhow::{Context, Result, bail};
use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology}; use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology};
use harmony_config::{Config, ConfigClient}; use harmony_config::{Config, ConfigClient};
use harmony_k8s::K8sClient; use harmony_k8s::K8sClient;
use log::{debug, info};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
@@ -39,6 +40,8 @@ struct ContextDef {
autoprovision: bool, autoprovision: bool,
k3d: Option<String>, k3d: Option<String>,
openbao_namespace: Option<String>, openbao_namespace: Option<String>,
openbao_url: Option<String>,
zitadel_url: Option<String>,
} }
/// The cluster K8sAnywhereTopology autoprovisions (its K3DInstallationScore /// The cluster K8sAnywhereTopology autoprovisions (its K3DInstallationScore
@@ -100,18 +103,35 @@ 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) { let guard = match (def.autoprovision, &def.k3d, &def.openbao_namespace) {
(true, None, None) => None, (true, None, None) => {
(false, Some(cluster), None) => Some(k3d_kubeconfig(cluster)?), 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)) => { (false, None, Some(ns)) => {
let access: ClusterAccess = ConfigClient::for_namespace(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 .await
.with_context(|| format!("reaching OpenBao for context '{name}'"))?
.get() .get()
.await .await
.with_context(|| { .with_context(|| {
format!( format!(
"loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \ "loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \
— are the Zitadel key + OPENBAO_* env vars set?)" — are the Zitadel key + OpenBao URL/role set?"
) )
})?; })?;
Some(write_kubeconfig(access.kubeconfig.as_bytes())?) Some(write_kubeconfig(access.kubeconfig.as_bytes())?)
@@ -211,6 +231,24 @@ fn find_contexts_file() -> Result<PathBuf> {
} }
} }
/// 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<String>,
zitadel_url: Option<String>,
) -> Result<ConfigClient> {
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<NamedTempFile> { fn write_kubeconfig(contents: &[u8]) -> Result<NamedTempFile> {
let mut file = let mut file =
NamedTempFile::with_prefix("harmony-ctx-").context("create kubeconfig tempfile")?; NamedTempFile::with_prefix("harmony-ctx-").context("create kubeconfig tempfile")?;

View File

@@ -196,31 +196,43 @@ impl ConfigClient {
} }
} }
/// Build an OpenBao-backed `StoreSource` from env vars, or `None` (with a /// Build an OpenBao-backed `StoreSource` purely from env — the default chain.
/// `warn!`) when `OPENBAO_URL`/`VAULT_ADDR` is unset or the connection
/// fails — so a missing OpenBao degrades the chain instead of breaking
/// startup.
async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> { async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
let Some(url) = std::env::var("OPENBAO_URL") openbao_source(namespace, None, None).await
.or_else(|_| std::env::var("VAULT_ADDR")) }
.ok()
/// 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<String>,
zitadel_sso_url: Option<String>,
) -> Option<Arc<dyn ConfigSource>> {
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 { else {
warn!("OpenBao URL not set; OpenBao source omitted from chain"); warn!("OpenBao URL not set; OpenBao source omitted from chain");
return None; return None;
}; };
// The Zitadel base for both the JWT-bearer issuer and the OIDC rung.
let env = |k: &str| std::env::var(k).ok(); let sso_url = zitadel_sso_url.or_else(|| env("HARMONY_SSO_URL"));
// Headless Zitadel-machine → OpenBao (JWT-bearer) rung: needs a machine // Headless Zitadel-machine → OpenBao (JWT-bearer) rung: needs a machine
// keyfile (path or inline JSON) plus the project-ID audience. The issuer // keyfile (path or inline JSON) plus the project-ID audience. Absent any
// is the same Zitadel base the OIDC rung uses (`HARMONY_SSO_URL`). Absent // piece, the rung stays off and the ladder falls through.
// any piece, the rung stays off and the ladder falls through.
let zitadel_jwt_bearer = { let zitadel_jwt_bearer = {
let key_path = env("HARMONY_ZITADEL_KEY_PATH").map(PathBuf::from); let key_path = env("HARMONY_ZITADEL_KEY_PATH").map(PathBuf::from);
let key_json = env("HARMONY_ZITADEL_KEY_JSON"); let key_json = env("HARMONY_ZITADEL_KEY_JSON");
match ( match (
key_path.is_some() || key_json.is_some(), key_path.is_some() || key_json.is_some(),
env("HARMONY_SSO_URL"), sso_url.clone(),
env("HARMONY_ZITADEL_AUDIENCE"), env("HARMONY_ZITADEL_AUDIENCE"),
) { ) {
(true, Some(oidc_issuer_url), Some(audience)) => { (true, Some(oidc_issuer_url), Some(audience)) => {
@@ -243,7 +255,7 @@ async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
token: env("OPENBAO_TOKEN"), token: env("OPENBAO_TOKEN"),
username: env("OPENBAO_USERNAME"), username: env("OPENBAO_USERNAME"),
password: env("OPENBAO_PASSWORD"), password: env("OPENBAO_PASSWORD"),
zitadel_sso_url: env("HARMONY_SSO_URL"), zitadel_sso_url: sso_url,
zitadel_client_id: env("HARMONY_SSO_CLIENT_ID"), zitadel_client_id: env("HARMONY_SSO_CLIENT_ID"),
jwt_role: env("OPENBAO_JWT_ROLE"), jwt_role: env("OPENBAO_JWT_ROLE"),
jwt_auth_mount: env("OPENBAO_JWT_AUTH_MOUNT"), jwt_auth_mount: env("OPENBAO_JWT_AUTH_MOUNT"),