feat/compose-build-args-zitadel-readiness-ci-auth #341

Merged
johnride merged 15 commits from feat/compose-build-args-zitadel-readiness-ci-auth into master 2026-07-05 12:01:23 +00:00
41 changed files with 1106 additions and 256 deletions

1
Cargo.lock generated
View File

@@ -4011,6 +4011,7 @@ dependencies = [
"futures-util", "futures-util",
"harmony-k8s", "harmony-k8s",
"harmony-reconciler-contracts", "harmony-reconciler-contracts",
"harmony_config",
"harmony_execution", "harmony_execution",
"harmony_inventory_agent", "harmony_inventory_agent",
"harmony_macros", "harmony_macros",

View File

@@ -469,6 +469,7 @@ natsBox:
values_yaml, values_yaml,
create_namespace: true, create_namespace: true,
install_only: false, install_only: false,
force_conflicts: false,
repository: Some(HelmRepository::new( repository: Some(HelmRepository::new(
"nats".to_string(), "nats".to_string(),
hurl!("https://nats-io.github.io/k8s/helm/charts/"), hurl!("https://nats-io.github.io/k8s/helm/charts/"),

View File

@@ -103,6 +103,7 @@ natsBox:
values_yaml, values_yaml,
create_namespace: true, create_namespace: true,
install_only: false, install_only: false,
force_conflicts: false,
repository: Some(HelmRepository::new( repository: Some(HelmRepository::new(
"nats".to_string(), "nats".to_string(),
hurl!("https://nats-io.github.io/k8s/helm/charts/"), hurl!("https://nats-io.github.io/k8s/helm/charts/"),

View File

@@ -273,6 +273,7 @@ impl<T: Topology + HelmCommand + K8sclient> Interpret<T> for FleetOperatorInterp
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: true,
install_only: false, install_only: false,
force_conflicts: false,
repository: None, repository: None,
} }
.interpret(inventory, topology) .interpret(inventory, topology)
@@ -310,6 +311,7 @@ impl<T: Topology + HelmCommand + K8sclient> Interpret<T> for FleetOperatorInterp
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: true,
install_only: false, install_only: false,
force_conflicts: false,
repository: None, repository: None,
} }
.interpret(inventory, topology) .interpret(inventory, topology)

View File

@@ -13,7 +13,7 @@ use kube::{
runtime::conditions, runtime::conditions,
runtime::wait::await_condition, runtime::wait::await_condition,
}; };
use log::debug; use log::{debug, info};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::Value; use serde_json::Value;
use std::time::Duration; use std::time::Duration;
@@ -373,6 +373,27 @@ impl K8sClient {
.await .await
} }
/// Ensure the namespace exists, **without ever patching it**. A GET (which a
/// namespace-scoped deployer is allowed to do) decides: if the namespace is
/// already present — e.g. provisioned by platform onboarding — this is a
/// no-op; only a genuinely missing namespace is created. This keeps tenant
/// deployers from needing cluster-scoped permission to patch the
/// (cluster-scoped) Namespace object, which a namespaced RBAC role can't do.
///
/// Use this instead of applying a `Namespace` resource or passing
/// `helm --create-namespace` when the target namespace may be pre-created
/// and the deployer is namespace-scoped.
pub async fn ensure_namespace(&self, name: &str) -> Result<(), Error> {
if self.namespace_exists(name).await? {
info!("Namespace '{name}' already exists");
return Ok(());
}
info!("Creating namespace '{name}'");
self.create_namespace(name).await?;
self.wait_for_namespace(name, Some(Duration::from_secs(30)))
.await
}
pub async fn wait_for_namespace( pub async fn wait_for_namespace(
&self, &self,
name: &str, name: &str,

View File

@@ -43,6 +43,7 @@ opnsense-api = { path = "../opnsense-api" }
opnsense-config = { path = "../opnsense-config" } opnsense-config = { path = "../opnsense-config" }
opnsense-config-xml = { path = "../opnsense-config-xml" } opnsense-config-xml = { path = "../opnsense-config-xml" }
harmony_macros = { path = "../harmony_macros" } harmony_macros = { path = "../harmony_macros" }
harmony_config = { path = "../harmony_config" }
harmony_types = { path = "../harmony_types" } harmony_types = { path = "../harmony_types" }
harmony_execution = { path = "../harmony_execution" } harmony_execution = { path = "../harmony_execution" }
harmony-k8s = { path = "../harmony-k8s" } harmony-k8s = { path = "../harmony-k8s" }

View File

@@ -390,6 +390,14 @@ impl Serialize for K8sAnywhereTopology {
} }
} }
/// True when a Kubernetes API error is an RBAC denial (403 Forbidden). Used to
/// skip cluster-scope checks a namespace-scoped deployer isn't allowed to run,
/// rather than failing the whole deploy.
fn is_forbidden<E: std::fmt::Display>(e: &E) -> bool {
let msg = e.to_string().to_lowercase();
msg.contains("forbidden") || msg.contains("403")
}
#[async_trait] #[async_trait]
impl CertificateManagement for K8sAnywhereTopology { impl CertificateManagement for K8sAnywhereTopology {
async fn install(&self) -> Result<Outcome, ExecutorError> { async fn install(&self) -> Result<Outcome, ExecutorError> {
@@ -408,13 +416,29 @@ impl CertificateManagement for K8sAnywhereTopology {
async fn ensure_certificate_management_ready(&self) -> Result<Outcome, ExecutorError> { async fn ensure_certificate_management_ready(&self) -> Result<Outcome, ExecutorError> {
let k8s_client = self.k8s_client().await.unwrap(); let k8s_client = self.k8s_client().await.unwrap();
let pods = k8s_client let pods = match k8s_client
.list_all_resources_with_labels::<Pod>( .list_all_resources_with_labels::<Pod>(
"app.kubernetes.io/component=controller,\ "app.kubernetes.io/component=controller,\
app.kubernetes.io/name=cert-manager", app.kubernetes.io/name=cert-manager",
) )
.await .await
.map_err(|e| ExecutorError::UnexpectedError(format!("{}", e)))?; {
Ok(pods) => pods,
// A namespace-scoped deployer (e.g. a tenant CI service account)
// cannot list pods cluster-wide. cert-manager is a cluster-wide
// platform component managed out-of-band, so don't fail the deploy —
// skip the readiness check and assume it is present.
Err(e) if is_forbidden(&e) => {
info!(
"Skipped cert-manager setup due to restricted (namespace-scoped) \
access — assuming cert-manager is managed cluster-wide by the platform"
);
return Ok(Outcome::noop(
"cert-manager readiness skipped (restricted access)".to_string(),
));
}
Err(e) => return Err(ExecutorError::UnexpectedError(format!("{}", e))),
};
if pods.is_empty() { if pods.is_empty() {
info!("cert-manager not installed (no controller pods found)"); info!("cert-manager not installed (no controller pods found)");

View File

@@ -1087,6 +1087,7 @@ commitServer:
values_yaml: Some(values.to_string()), values_yaml: Some(values.to_string()),
create_namespace: true, create_namespace: true,
install_only: false, install_only: false,
force_conflicts: false,
repository: Some(HelmRepository::new( repository: Some(HelmRepository::new(
"argo".to_string(), "argo".to_string(),
hurl!("https://argoproj.github.io/argo-helm"), hurl!("https://argoproj.github.io/argo-helm"),

View File

@@ -31,6 +31,7 @@ impl<T: Topology + HelmCommand> Score<T> for CertManagerHelmScore {
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: true,
install_only: true, install_only: true,
force_conflicts: false,
repository: Some(HelmRepository::new( repository: Some(HelmRepository::new(
"jetstack".to_string(), "jetstack".to_string(),
hurl!("https://charts.jetstack.io"), hurl!("https://charts.jetstack.io"),

View File

@@ -53,6 +53,7 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Score<T> for CertManagerOp
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: true,
install_only: true, install_only: true,
force_conflicts: false,
repository: None, repository: None,
}; };

View File

@@ -0,0 +1,219 @@
//! Install GitHub self-hosted runners on Kubernetes via **Actions Runner
//! Controller (ARC)**, built on [`HelmChartScore`].
//!
//! ARC is two Helm releases:
//! 1. the **controller** (`gha-runner-scale-set-controller`, cluster-scoped) —
//! install once per cluster;
//! 2. one or more **runner scale sets** (`gha-runner-scale-set`) — each
//! registers a set of ephemeral runners under a name that becomes the
//! workflow `runs-on:` label.
//!
//! [`GithubRunnerScore`] bundles the config and yields both as [`HelmChartScore`]s
//! (controller first). The GitHub credential is referenced by the name of a
//! pre-created Kubernetes Secret (holding `github_token`) — it never appears in
//! code or values, so no secret is baked into the deploy.
//!
//! ```ignore
//! let runner = GithubRunnerScore {
//! github_config_url: "https://github.com/Devsights".into(), // an ORG → all its repos
//! github_config_secret: "devsights-github".into(), // pre-created Secret
//! runner_scale_set_name: "okd-cb1".into(), // = runs-on label
//! ..GithubRunnerScore::defaults()
//! };
//! // add both to your scores(), controller first:
//! vec![Box::new(runner.controller()), Box::new(runner.runner_set())]
//! ```
//!
//! GitHub-App auth (one org-scoped App, installed once per client org) is a
//! platform/operations concern documented separately, not in this repo.
//!
//! On OpenShift/OKD, `containerMode: dind` needs a privileged container — grant
//! the runner ServiceAccount the `privileged` SCC. The runner needs **no**
//! cluster RBAC: jobs reach the cluster through their own brokered kubeconfig.
//!
//! TODO(okd): encode the OKD-safe runner pod template instead of patching ARC
//! post-install. The working deployment needed the runner container to execute
//! `/home/runner/run.sh` as UID 1001/GID 123, the dind sidecar privileged as
//! root, and `dockerd --storage-driver=vfs`; nested overlay fails on OKD/CRI-O.
use std::str::FromStr;
use non_blank_string_rs::NonBlankString;
use crate::modules::helm::chart::HelmChartScore;
const CONTROLLER_CHART: &str =
"oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller";
const RUNNER_SET_CHART: &str =
"oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set";
/// Pin a known-good ARC chart version (controller + scale set share the line).
const DEFAULT_CHART_VERSION: &str = "0.9.3";
const DEFAULT_RUNNER_IMAGE: &str = "ghcr.io/actions/actions-runner:latest";
/// One GitHub self-hosted runner deployment (ARC controller + a runner set).
pub struct GithubRunnerScore {
/// URL the runners register against. Prefer an **org** URL
/// (`https://github.com/<org>`) so one scale set serves all of that org's
/// repos; a single repo URL also works for a repo-scoped runner.
pub github_config_url: String,
/// Name of a **pre-created** Kubernetes Secret in `runners_namespace` holding
/// the `github_token` key (a fine-grained PAT or GitHub App credentials).
/// Kept out of code/values — create it separately (e.g. from OpenBao).
pub github_config_secret: String,
/// Scale-set name — this becomes the workflow `runs-on:` label (e.g. `okd-cb1`).
pub runner_scale_set_name: String,
/// Namespace for the runner pods (default `arc-runners`).
pub runners_namespace: String,
/// Namespace for the controller (default `arc-systems`).
pub controller_namespace: String,
pub min_runners: u32,
pub max_runners: u32,
/// CPU/memory limits applied to BOTH the runner and the dind container
/// (the build runs in dind, so it needs the headroom too).
pub cpu_limit: String,
pub memory_limit: String,
/// Runner container image.
pub runner_image: String,
/// ARC chart version (controller + scale set).
pub chart_version: String,
}
impl GithubRunnerScore {
/// Sensible defaults; override the four required fields (`github_config_url`,
/// `github_config_secret`, `runner_scale_set_name`, and resources as needed).
pub fn defaults() -> Self {
Self {
github_config_url: String::new(),
github_config_secret: String::new(),
runner_scale_set_name: String::new(),
runners_namespace: "arc-runners".to_string(),
controller_namespace: "arc-systems".to_string(),
min_runners: 1,
max_runners: 2,
cpu_limit: "8".to_string(),
memory_limit: "16Gi".to_string(),
runner_image: DEFAULT_RUNNER_IMAGE.to_string(),
chart_version: DEFAULT_CHART_VERSION.to_string(),
}
}
/// The cluster-scoped ARC controller. Install once per cluster (idempotent).
pub fn controller(&self) -> HelmChartScore {
HelmChartScore {
namespace: Some(nbs(&self.controller_namespace)),
release_name: nbs("github-runner-arc"),
chart_name: nbs(CONTROLLER_CHART),
chart_version: Some(nbs(&self.chart_version)),
values_overrides: None,
values_yaml: None,
create_namespace: true,
install_only: false,
force_conflicts: false,
repository: None, // OCI chart — referenced directly, no `helm repo add`
}
}
/// The runner scale set — registers ephemeral runners labelled
/// `runner_scale_set_name`, with dind (so `docker build` works) and the
/// configured resource limits.
pub fn runner_set(&self) -> HelmChartScore {
HelmChartScore {
namespace: Some(nbs(&self.runners_namespace)),
release_name: nbs(format!("github-runner-{}", &self.runner_scale_set_name).as_str()),
chart_name: nbs(RUNNER_SET_CHART),
chart_version: Some(nbs(&self.chart_version)),
values_overrides: None,
values_yaml: Some(self.runner_set_values()),
create_namespace: true,
install_only: false,
force_conflicts: false,
repository: None,
}
}
fn runner_set_values(&self) -> String {
let Self {
github_config_url,
github_config_secret,
runner_scale_set_name,
min_runners,
max_runners,
cpu_limit,
memory_limit,
runner_image,
..
} = self;
// dind is where `docker build` actually runs, so it gets the same limits.
format!(
"githubConfigUrl: {github_config_url}\n\
githubConfigSecret: {github_config_secret}\n\
runnerScaleSetName: {runner_scale_set_name}\n\
minRunners: {min_runners}\n\
maxRunners: {max_runners}\n\
containerMode:\n\
\x20 type: dind\n\
template:\n\
\x20 spec:\n\
\x20 containers:\n\
\x20 - name: runner\n\
\x20 image: {runner_image}\n\
\x20 resources:\n\
\x20 limits:\n\
\x20 cpu: \"{cpu_limit}\"\n\
\x20 memory: \"{memory_limit}\"\n\
\x20 - name: dind\n\
\x20 resources:\n\
\x20 limits:\n\
\x20 cpu: \"{cpu_limit}\"\n\
\x20 memory: \"{memory_limit}\"\n"
)
}
}
/// `NonBlankString` from a known-non-blank `&str` (panics on blank, like the
/// rest of the helm modules — these are constants or required config).
fn nbs(s: &str) -> NonBlankString {
NonBlankString::from_str(s).expect("non-blank string")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> GithubRunnerScore {
GithubRunnerScore {
github_config_url: "https://github.com/Devsights/folk-timesheets".to_string(),
github_config_secret: "okd-cb1-github".to_string(),
runner_scale_set_name: "okd-cb1".to_string(),
cpu_limit: "46".to_string(),
memory_limit: "32Gi".to_string(),
..GithubRunnerScore::defaults()
}
}
#[test]
fn controller_is_an_oci_chart_with_no_repo() {
let c = sample().controller();
assert_eq!(c.chart_name.to_string(), CONTROLLER_CHART);
assert!(c.repository.is_none());
assert_eq!(c.namespace.unwrap().to_string(), "arc-systems");
}
#[test]
fn runner_set_values_carry_name_dind_and_limits() {
let v = sample().runner_set_values();
assert!(v.contains("runnerScaleSetName: okd-cb1"));
assert!(v.contains("type: dind"));
assert!(v.contains("cpu: \"46\""));
assert!(v.contains("memory: \"32Gi\""));
assert!(v.contains("githubConfigSecret: okd-cb1-github"));
}
#[test]
fn runner_set_values_are_valid_yaml() {
let v = sample().runner_set_values();
let parsed: serde_yaml::Value = serde_yaml::from_str(&v).expect("valid yaml");
assert_eq!(parsed["runnerScaleSetName"], "okd-cb1");
assert_eq!(parsed["containerMode"]["type"], "dind");
}
}

View File

@@ -44,6 +44,7 @@ pub struct HelmChartScore {
/// itself diffs the rendered chart against the live release and is a /// itself diffs the rendered chart against the live release and is a
/// no-op when nothing changed. /// no-op when nothing changed.
pub install_only: bool, pub install_only: bool,
pub force_conflicts: bool,
pub repository: Option<HelmRepository>, pub repository: Option<HelmRepository>,
} }
@@ -248,7 +249,11 @@ impl<T: Topology + HelmCommand> Interpret<T> for HelmChartInterpret {
let mut args = if self.score.install_only { let mut args = if self.score.install_only {
vec!["install"] vec!["install"]
} else { } else {
vec!["upgrade", "--install"] let mut v = vec!["upgrade", "--install"];
if self.score.force_conflicts {
v.push("--force-conflicts");
}
v
}; };
args.extend(vec![ args.extend(vec![

View File

@@ -209,6 +209,7 @@ impl LAMPInterpret {
values_overrides: Some(values_overrides), values_overrides: Some(values_overrides),
create_namespace: true, create_namespace: true,
install_only: false, install_only: false,
force_conflicts: false,
values_yaml: None, values_yaml: None,
repository: None, repository: None,
}; };

View File

@@ -6,6 +6,7 @@ pub mod dhcp;
pub mod dns; pub mod dns;
pub mod dummy; pub mod dummy;
pub mod fleet; pub mod fleet;
pub mod github_runner;
pub mod helm; pub mod helm;
pub mod http; pub mod http;
pub mod inventory; pub mod inventory;
@@ -28,6 +29,7 @@ pub mod opnsense;
pub mod podman; pub mod podman;
pub mod postgresql; pub mod postgresql;
pub mod prometheus; pub mod prometheus;
pub mod registry_pull_secret;
pub mod storage; pub mod storage;
pub mod tenant; pub mod tenant;
pub mod tftp; pub mod tftp;

View File

@@ -38,6 +38,7 @@ pub fn grafana_helm_chart_score(
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: true,
install_only: true, install_only: true,
force_conflicts: false,
repository: Some(HelmRepository::new( repository: Some(HelmRepository::new(
"grafana".to_string(), "grafana".to_string(),
hurl!("https://grafana.github.io/helm-charts"), hurl!("https://grafana.github.io/helm-charts"),

View File

@@ -15,6 +15,7 @@ pub fn grafana_operator_helm_chart_score(ns: String) -> HelmChartScore {
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: true,
install_only: true, install_only: true,
force_conflicts: false,
repository: None, repository: None,
} }
} }

View File

@@ -17,6 +17,7 @@ pub fn prometheus_operator_helm_chart_score(ns: String) -> HelmChartScore {
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: true,
install_only: true, install_only: true,
force_conflicts: false,
repository: None, repository: None,
} }
} }

View File

@@ -17,6 +17,7 @@ pub fn rhob_cluster_observability_operator() -> HelmChartScore {
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: true,
install_only: true, install_only: true,
force_conflicts: false,
repository: None, repository: None,
} }
} }

View File

@@ -418,6 +418,7 @@ prometheusOperator:
values_yaml: Some(values.to_string()), values_yaml: Some(values.to_string()),
create_namespace: true, create_namespace: true,
install_only: true, install_only: true,
force_conflicts: false,
repository: None, repository: None,
} }
} }

View File

@@ -89,6 +89,7 @@ persistence:
values_yaml: Some(values.to_string()), values_yaml: Some(values.to_string()),
create_namespace: true, create_namespace: true,
install_only: false, install_only: false,
force_conflicts: false,
repository: None, repository: None,
} }
} }

View File

@@ -42,6 +42,7 @@ fullnameOverride: prometheus-{ns}
values_yaml: Some(values.to_string()), values_yaml: Some(values.to_string()),
create_namespace: true, create_namespace: true,
install_only: true, install_only: true,
force_conflicts: false,
repository: None, repository: None,
} }
} }

View File

@@ -96,6 +96,7 @@ impl NatsHelmChartScore {
values_yaml: Some(self.values_yaml), values_yaml: Some(self.values_yaml),
create_namespace: self.create_namespace, create_namespace: self.create_namespace,
install_only: self.install_only, install_only: self.install_only,
force_conflicts: false,
repository: Some(HelmRepository::new( repository: Some(HelmRepository::new(
REPO_NAME.to_string(), REPO_NAME.to_string(),
hurl!("https://nats-io.github.io/k8s/helm/charts/"), hurl!("https://nats-io.github.io/k8s/helm/charts/"),

View File

@@ -156,6 +156,7 @@ impl<T: Topology + K8sclient + HelmCommand> Score<T> for OpenbaoScore {
values_yaml: Some(self.values()), values_yaml: Some(self.values()),
create_namespace: true, create_namespace: true,
install_only: false, install_only: false,
force_conflicts: false,
repository: Some(HelmRepository::new( repository: Some(HelmRepository::new(
"openbao".to_string(), "openbao".to_string(),
hurl!("https://openbao.github.io/openbao-helm"), hurl!("https://openbao.github.io/openbao-helm"),

View File

@@ -136,6 +136,7 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Score<T> for CloudNativePg
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: true,
install_only: true, install_only: true,
force_conflicts: false,
repository: None, repository: None,
}; };

View File

@@ -22,6 +22,13 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use log::{debug, info, warn}; use log::{debug, info, warn};
use serde::Serialize; use serde::Serialize;
/// True when a Kubernetes API error is an RBAC denial (403 Forbidden) — lets a
/// namespace-scoped deployer skip cluster-scope checks it isn't allowed to run.
fn is_forbidden<E: std::fmt::Display>(e: &E) -> bool {
let msg = e.to_string().to_lowercase();
msg.contains("forbidden") || msg.contains("403")
}
/// Deploys an opinionated, highly available PostgreSQL cluster managed by CNPG. /// Deploys an opinionated, highly available PostgreSQL cluster managed by CNPG.
/// ///
/// This score automatically ensures the CloudNativePG (CNPG) operator is installed /// This score automatically ensures the CloudNativePG (CNPG) operator is installed
@@ -82,42 +89,15 @@ impl K8sPostgreSQLInterpret {
.await .await
.map_err(|e| InterpretError::new(format!("Failed to get k8s client: {}", e)))?; .map_err(|e| InterpretError::new(format!("Failed to get k8s client: {}", e)))?;
let namespace_name = &self.config.namespace; k8s_client
.ensure_namespace(&self.config.namespace)
if k8s_client
.namespace_exists(namespace_name)
.await .await
.map_err(|e| { .map_err(|e| {
InterpretError::new(format!( InterpretError::new(format!(
"Failed to check namespace '{}': {}", "Failed to ensure namespace '{}': {}",
namespace_name, e self.config.namespace, e
)) ))
})? })
{
info!("Namespace '{}' already exists", namespace_name);
return Ok(());
}
info!("Creating namespace '{}'", namespace_name);
k8s_client
.create_namespace(namespace_name)
.await
.map_err(|e| {
InterpretError::new(format!(
"Failed to create namespace '{}': {}",
namespace_name, e
))
})?;
k8s_client
.wait_for_namespace(namespace_name, Some(std::time::Duration::from_secs(30)))
.await
.map_err(|e| {
InterpretError::new(format!("Namespace '{}' not ready: {}", namespace_name, e))
})?;
info!("Namespace '{}' is ready", namespace_name);
Ok(())
} }
async fn ensure_cnpg_operator<T: Topology + K8sclient + HelmCommand + 'static>( async fn ensure_cnpg_operator<T: Topology + K8sclient + HelmCommand + 'static>(
@@ -129,12 +109,29 @@ impl K8sPostgreSQLInterpret {
.await .await
.map_err(|e| InterpretError::new(format!("Failed to get k8s client: {}", e)))?; .map_err(|e| InterpretError::new(format!("Failed to get k8s client: {}", e)))?;
let pods = k8s_client let pods = match k8s_client
.list_all_resources_with_labels::<Pod>("app.kubernetes.io/name=cloudnative-pg") .list_all_resources_with_labels::<Pod>("app.kubernetes.io/name=cloudnative-pg")
.await .await
.map_err(|e| { {
InterpretError::new(format!("Failed to list CNPG operator pods: {}", e)) Ok(pods) => pods,
})?; // A namespace-scoped deployer (e.g. a tenant CI service account)
// cannot list pods cluster-wide. CNPG is a cluster-wide platform
// operator managed out-of-band, so skip the install/readiness check
// rather than fail the deploy — assume it is present.
Err(e) if is_forbidden(&e) => {
info!(
"Skipped CNPG operator setup due to restricted (namespace-scoped) \
access — assuming the CNPG operator is managed cluster-wide by the platform"
);
return Ok(());
}
Err(e) => {
return Err(InterpretError::new(format!(
"Failed to list CNPG operator pods: {}",
e
)));
}
};
if !pods.is_empty() { if !pods.is_empty() {
info!("CNPG operator is already installed"); info!("CNPG operator is already installed");

View File

@@ -0,0 +1,125 @@
//! A reusable Score that materializes a `kubernetes.io/dockerconfigjson` Secret
//! so the kubelet can pull an app's images from a private registry.
//!
//! The Secret is referenced by name from each pod's `imagePullSecrets` (see
//! `harmony_app`'s `DeployConfig::image_pull_secrets`). Keep the credentials
//! **pull-only** and load them from a vault (e.g. OpenBao `DeploySecrets`) — they
//! never belong in code or chart values. The Secret is namespaced, so a
//! namespace-scoped deployer can apply it without any cluster RBAC.
//!
//! ```ignore
//! let pull = RegistryPullSecretScore {
//! namespace: "devsights-folk".into(),
//! name: "registry-pull".into(),
//! registry: "hub.nationtech.io".into(),
//! username: pull_user,
//! token: pull_token,
//! };
//! // add `pull` to scores(), and set deploy.image_pull_secrets = vec!["registry-pull"]
//! ```
use std::collections::BTreeMap;
use base64::Engine;
use k8s_openapi::ByteString;
use k8s_openapi::api::core::v1::Secret as K8sSecret;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use serde::Serialize;
use crate::interpret::Interpret;
use crate::modules::k8s::resource::K8sResourceScore;
use crate::score::Score;
use crate::topology::{K8sclient, Topology};
/// Creates a `kubernetes.io/dockerconfigjson` Secret in `namespace` from
/// pull-only registry credentials. Idempotent — a namespaced Secret apply.
#[derive(Debug, Clone, Serialize)]
pub struct RegistryPullSecretScore {
pub namespace: String,
/// Secret name — the same string goes in each pod's `imagePullSecrets`.
pub name: String,
/// Registry host the creds authenticate to, e.g. `hub.nationtech.io`.
pub registry: String,
pub username: String,
/// Pull-only robot token. Serialized into the Secret, never logged.
pub token: String,
}
impl RegistryPullSecretScore {
/// Build the `.dockerconfigjson` Secret. The inner `auth` field is
/// `base64(user:token)` per Docker's config format; the kubelet reads it to
/// authenticate image pulls.
fn secret(&self) -> K8sSecret {
let auth = base64::engine::general_purpose::STANDARD
.encode(format!("{}:{}", self.username, self.token));
let dockerconfig = serde_json::json!({
"auths": {
&self.registry: {
"username": self.username,
"password": self.token,
"auth": auth,
}
}
});
let mut data = BTreeMap::new();
data.insert(
".dockerconfigjson".to_string(),
ByteString(dockerconfig.to_string().into_bytes()),
);
K8sSecret {
metadata: ObjectMeta {
name: Some(self.name.clone()),
namespace: Some(self.namespace.clone()),
..Default::default()
},
type_: Some("kubernetes.io/dockerconfigjson".to_string()),
data: Some(data),
..Default::default()
}
}
}
impl<T: Topology + K8sclient + 'static> Score<T> for RegistryPullSecretScore {
fn name(&self) -> String {
format!("RegistryPullSecretScore({}/{})", self.namespace, self.name)
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
K8sResourceScore::single(self.secret(), Some(self.namespace.clone())).create_interpret()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> RegistryPullSecretScore {
RegistryPullSecretScore {
namespace: "devsights-folk".into(),
name: "registry-pull".into(),
registry: "hub.nationtech.io".into(),
username: "robot$pull".into(),
token: "s3cr3t".into(),
}
}
#[test]
fn builds_a_typed_dockerconfigjson_secret() {
let s = sample().secret();
assert_eq!(s.type_.as_deref(), Some("kubernetes.io/dockerconfigjson"));
assert_eq!(s.metadata.name.as_deref(), Some("registry-pull"));
assert_eq!(s.metadata.namespace.as_deref(), Some("devsights-folk"));
}
#[test]
fn dockerconfig_carries_creds_and_base64_auth() {
let s = sample().secret();
let blob = s.data.unwrap().remove(".dockerconfigjson").unwrap();
let json: serde_json::Value = serde_json::from_slice(&blob.0).unwrap();
let entry = &json["auths"]["hub.nationtech.io"];
assert_eq!(entry["username"], "robot$pull");
assert_eq!(entry["password"], "s3cr3t");
let expected_auth = base64::engine::general_purpose::STANDARD.encode("robot$pull:s3cr3t");
assert_eq!(entry["auth"], expected_auth);
}
}

View File

@@ -22,8 +22,8 @@ use std::collections::BTreeMap;
use std::str::FromStr; use std::str::FromStr;
use async_trait::async_trait; use async_trait::async_trait;
use harmony_config::Config;
use harmony_macros::hurl; use harmony_macros::hurl;
use harmony_secret::{Secret, SecretManager};
use harmony_types::id::Id; use harmony_types::id::Id;
use harmony_types::storage::StorageSize; use harmony_types::storage::StorageSize;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
@@ -36,7 +36,6 @@ use crate::{
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
inventory::Inventory, inventory::Inventory,
modules::helm::chart::{HelmChartScore, HelmRepository}, modules::helm::chart::{HelmChartScore, HelmRepository},
modules::k8s::resource::K8sResourceScore,
modules::postgresql::capability::{PostgreSQL, PostgreSQLClusterRole, PostgreSQLConfig}, modules::postgresql::capability::{PostgreSQL, PostgreSQLClusterRole, PostgreSQLConfig},
score::Score, score::Score,
topology::{HelmCommand, K8sclient, Topology}, topology::{HelmCommand, K8sclient, Topology},
@@ -230,25 +229,26 @@ impl<T: Topology + K8sclient + HelmCommand + PostgreSQL> Interpret<T> for Zitade
self.host self.host
); );
info!("Creating namespace {ns} if it does not exist"); // Ensure the tenant namespace exists without patching it (it is
K8sResourceScore::single( // provisioned by platform onboarding; a namespace-scoped deployer can't
Namespace { // patch the cluster-scoped Namespace object).
metadata: ObjectMeta { let k8s_client = topology
name: Some(self.namespace.to_string()), .k8s_client()
..Default::default() .await
}, .map_err(|e| InterpretError::new(format!("Failed to get k8s client: {e}")))?;
..Default::default() k8s_client
}, .ensure_namespace(ns)
None, .await
) .map_err(|e| InterpretError::new(format!("Failed to ensure namespace '{ns}': {e}")))?;
.interpret(inventory, topology)
.await?;
// --- Step 1: PostgreSQL ------------------------------------------- // --- Step 1: PostgreSQL -------------------------------------------
let pg_config = PostgreSQLConfig { let pg_config = PostgreSQLConfig {
cluster_name: PG_CLUSTER_NAME.to_string(), cluster_name: PG_CLUSTER_NAME.to_string(),
instances: 2, // Single instance by default: storage durability comes from the
// backing layer (Ceph), so CNPG-level replication adds cost/ops
// without buying availability here.
instances: 1,
storage_size: StorageSize::gi(10), storage_size: StorageSize::gi(10),
role: PostgreSQLClusterRole::Primary, role: PostgreSQLClusterRole::Primary,
namespace: self.namespace.to_string(), namespace: self.namespace.to_string(),
@@ -356,10 +356,10 @@ impl<T: Topology + K8sclient + HelmCommand + PostgreSQL> Interpret<T> for Zitade
// password every time (Zitadel's chart only honors FirstInstance.* // password every time (Zitadel's chart only honors FirstInstance.*
// on the very first install; on re-runs the live password is // on the very first install; on re-runs the live password is
// whatever's already in the DB, but our printed banner used to // whatever's already in the DB, but our printed banner used to
// emit a fresh random — misleading the operator). harmony_secret // emit a fresh random — misleading the operator). harmony_config
// namespaces by install context (config-resolved), so two // namespaces by install context (config-resolved), so two
// installs in the same context share credentials. // installs in the same context share credentials.
let admin = match SecretManager::get::<ZitadelAdmin>().await { let admin = match harmony_config::get::<ZitadelAdmin>().await {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
debug!("[Zitadel] No persisted admin credentials yet ({e}); generating"); debug!("[Zitadel] No persisted admin credentials yet ({e}); generating");
@@ -367,7 +367,7 @@ impl<T: Topology + K8sclient + HelmCommand + PostgreSQL> Interpret<T> for Zitade
username: "admin".to_string(), username: "admin".to_string(),
password: generate_secure_password(16), password: generate_secure_password(16),
}; };
SecretManager::set(&a).await.map_err(|err| { harmony_config::set(&a).await.map_err(|err| {
InterpretError::new(format!("Failed to persist Zitadel admin password: {err}")) InterpretError::new(format!("Failed to persist Zitadel admin password: {err}"))
})?; })?;
a a
@@ -402,12 +402,47 @@ impl<T: Topology + K8sclient + HelmCommand + PostgreSQL> Interpret<T> for Zitade
MASTERKEY_SECRET_NAME, self.namespace MASTERKEY_SECRET_NAME, self.namespace
); );
// Masterkey for symmetric encryption — must be exactly 32 ASCII bytes (alphanumeric only). // Masterkey for symmetric encryption — exactly 32 ASCII alphanumeric
let masterkey = rng() // bytes, and it MUST be stable: regenerating it makes the existing
// Zitadel DB undecryptable. Resolve in priority order:
// 1. the in-cluster Secret, if present (authoritative — it's what the
// running instance encrypted its data with),
// 2. the value persisted in harmony_config (survives a namespace reset),
// 3. a freshly generated one.
// Then mirror the resolved value into harmony_config so a deleted/
// recreated namespace reuses it rather than minting a new (broken) key.
let existing_masterkey = k8s_client
.get_resource::<K8sSecret>(MASTERKEY_SECRET_NAME, Some(&self.namespace))
.await
.ok()
.flatten()
.and_then(|s| s.data?.get("masterkey").cloned())
.and_then(|bs| String::from_utf8(bs.0).ok());
let masterkey = match existing_masterkey {
Some(k) => k,
None => match harmony_config::get::<ZitadelMasterkey>().await {
Ok(m) => m.masterkey,
Err(e) => {
debug!("[Zitadel] No persisted masterkey yet ({e}); generating");
rng()
.sample_iter(&rand::distr::Alphanumeric) .sample_iter(&rand::distr::Alphanumeric)
.take(32) .take(32)
.map(char::from) .map(char::from)
.collect::<String>(); .collect::<String>()
}
},
};
if harmony_config::get::<ZitadelMasterkey>().await.is_err() {
harmony_config::set(&ZitadelMasterkey {
masterkey: masterkey.clone(),
})
.await
.map_err(|e| {
InterpretError::new(format!("Failed to persist Zitadel masterkey: {e}"))
})?;
}
debug!( debug!(
"[Zitadel] Created masterkey secret '{}' in namespace '{}'", "[Zitadel] Created masterkey secret '{}' in namespace '{}'",
@@ -875,8 +910,18 @@ login:
chart_version: Some(NonBlankString::from_str("9.27.1").unwrap()), chart_version: Some(NonBlankString::from_str("9.27.1").unwrap()),
values_overrides: None, values_overrides: None,
values_yaml: Some(values_yaml), values_yaml: Some(values_yaml),
create_namespace: true, // The namespace is ensured up front via `ensure_namespace`; don't let
install_only: false, // helm `--create-namespace` patch the (cluster-scoped) Namespace
// object, which a namespace-scoped deployer isn't allowed to do.
create_namespace: false,
// Install-once: Zitadel is stateful (DB + masterkey persist), and its
// chart's `zitadel-setup` init Job is a pre-upgrade hook that isn't
// safely re-runnable. Once the release exists we skip the helm op
// entirely — re-deploys converge via ZitadelSetupScore, not by
// re-running the chart. (Deliberate version upgrades are a separate,
// explicit action.)
install_only: true,
force_conflicts: false,
repository: Some(HelmRepository::new( repository: Some(HelmRepository::new(
"zitadel".to_string(), "zitadel".to_string(),
hurl!("https://charts.zitadel.com"), hurl!("https://charts.zitadel.com"),
@@ -934,9 +979,18 @@ login:
/// Persisted admin credentials for the Zitadel instance — written on /// Persisted admin credentials for the Zitadel instance — written on
/// first install, reused on subsequent runs so the printed banner /// first install, reused on subsequent runs so the printed banner
/// matches what's actually live in the DB. Stored under whichever /// matches what's actually live in the DB. Stored under whichever
/// `harmony_secret` backend is configured (LocalFile by default). /// `harmony_config` backend is configured (LocalFile by default).
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug, PartialEq, Clone)] #[derive(Config, Serialize, Deserialize, JsonSchema, Debug, PartialEq, Clone)]
struct ZitadelAdmin { struct ZitadelAdmin {
username: String, username: String,
#[config(secret)]
password: String, password: String,
} }
/// Persisted Zitadel masterkey (32 ASCII alphanumeric bytes). Stored so it
/// survives a namespace/Secret reset — a new key would orphan the existing DB.
#[derive(Config, Serialize, Deserialize, JsonSchema, Debug, PartialEq, Clone)]
struct ZitadelMasterkey {
#[config(secret)]
masterkey: String,
}

View File

@@ -2254,9 +2254,20 @@ impl<T: Topology + K8sclient> Interpret<T> for ZitadelSetupInterpret {
// the management API through 127.0.0.1. `_pf` must outlive every call // the management API through 127.0.0.1. `_pf` must outlive every call
// below. `me` is `self` with the resolved endpoint patched in. // below. `me` is `self` with the resolved endpoint patched in.
let _pf; let _pf;
let me: std::borrow::Cow<'_, ZitadelSetupInterpret> = let me: std::borrow::Cow<'_, ZitadelSetupInterpret> = if let Some(svc) =
if let Some(svc) = &self.score.port_forward_service { &self.score.port_forward_service
{
let label = format!("app.kubernetes.io/name={svc}"); let label = format!("app.kubernetes.io/name={svc}");
// On a cold cluster the Zitadel Deployment pod is not Running yet
// (image pull + DB migration take a minute or more), so poll for
// it instead of failing on the first miss — otherwise a one-shot
// `ship` aborts here and only a re-run (pod now up) succeeds.
let pod = {
use std::time::{Duration, Instant};
let timeout = Duration::from_secs(300);
let interval = Duration::from_secs(3);
let deadline = Instant::now() + timeout;
loop {
let pods = k8s let pods = k8s
.list_resources::<k8s_openapi::api::core::v1::Pod>( .list_resources::<k8s_openapi::api::core::v1::Pod>(
Some(&self.score.namespace), Some(&self.score.namespace),
@@ -2264,19 +2275,29 @@ impl<T: Topology + K8sclient> Interpret<T> for ZitadelSetupInterpret {
) )
.await .await
.map_err(|e| InterpretError::new(format!("listing '{svc}' pods: {e}")))?; .map_err(|e| InterpretError::new(format!("listing '{svc}' pods: {e}")))?;
// The chart's init/setup Job pods share this label; only the // The chart's init/setup Job pods share this label; only
// serving Deployment pod is Running, so filter on phase. // the serving Deployment pod is Running, so filter on phase.
let pod = pods let running = pods
.items .items
.into_iter() .into_iter()
.find(|p| p.status.as_ref().and_then(|s| s.phase.as_deref()) == Some("Running")) .find(|p| {
.and_then(|p| p.metadata.name) p.status.as_ref().and_then(|s| s.phase.as_deref()) == Some("Running")
.ok_or_else(|| { })
InterpretError::new(format!( .and_then(|p| p.metadata.name);
"no Running pod for service '{svc}' in namespace '{}'", if let Some(name) = running {
self.score.namespace break name;
)) }
})?; if Instant::now() >= deadline {
return Err(InterpretError::new(format!(
"no Running pod for service '{svc}' in namespace '{}' within {}s",
self.score.namespace,
timeout.as_secs(),
)));
}
debug!("[ZitadelSetup] waiting for a Running '{svc}' pod...");
tokio::time::sleep(interval).await;
}
};
// Zitadel serves http on 8080 in-cluster (chart default). // Zitadel serves http on 8080 in-cluster (chart default).
let handle = k8s let handle = k8s
.port_forward(&pod, &self.score.namespace, 0, 8080) .port_forward(&pod, &self.score.namespace, 0, 8080)

View File

@@ -34,6 +34,14 @@ pub trait HarmonyApp<T: Topology>: Send + Sync {
/// branching on `ctx.profile()`. Called by `deploy`/`ship`. /// branching on `ctx.profile()`. Called by `deploy`/`ship`.
async fn scores(&self, ctx: &AppContext) -> Result<Vec<Box<dyn Score<T>>>>; async fn scores(&self, ctx: &AppContext) -> Result<Vec<Box<dyn Score<T>>>>;
async fn scores_with_options(
&self,
ctx: &AppContext,
_options: DeployOptions,
) -> Result<Vec<Box<dyn Score<T>>>> {
self.scores(ctx).await
}
/// Build + distribute images for `ctx` (push to a registry, or import to /// Build + distribute images for `ctx` (push to a registry, or import to
/// k3d). Default: nothing to build. Called by `ship`. /// k3d). Default: nothing to build. Called by `ship`.
async fn publish(&self, _ctx: &AppContext) -> Result<()> { async fn publish(&self, _ctx: &AppContext) -> Result<()> {
@@ -54,6 +62,11 @@ pub struct DeployReport {
pub steps: Vec<StepOutcome>, pub steps: Vec<StepOutcome>,
} }
#[derive(Debug, Clone, Copy, Default)]
pub struct DeployOptions {
pub force_conflicts: bool,
}
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
pub struct WorkloadStatus { pub struct WorkloadStatus {
pub name: String, pub name: String,
@@ -81,7 +94,16 @@ pub async fn deploy<T: Topology + Send + Sync + 'static>(
topology: T, topology: T,
ctx: &AppContext, ctx: &AppContext,
) -> Result<DeployReport> { ) -> Result<DeployReport> {
let scores = app.scores(ctx).await?; deploy_with_options(app, topology, ctx, DeployOptions::default()).await
}
pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
app: &dyn HarmonyApp<T>,
topology: T,
ctx: &AppContext,
options: DeployOptions,
) -> Result<DeployReport> {
let scores = app.scores_with_options(ctx, options).await?;
let to_run: Vec<Box<dyn Score<T>>> = scores.iter().map(|s| s.clone_box()).collect(); let to_run: Vec<Box<dyn Score<T>>> = scores.iter().map(|s| s.clone_box()).collect();
let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology); let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology);
@@ -111,9 +133,18 @@ pub async fn ship<T: Topology + Send + Sync + 'static>(
app: &dyn HarmonyApp<T>, app: &dyn HarmonyApp<T>,
topology: T, topology: T,
ctx: &AppContext, ctx: &AppContext,
) -> Result<DeployReport> {
ship_with_options(app, topology, ctx, DeployOptions::default()).await
}
pub async fn ship_with_options<T: Topology + Send + Sync + 'static>(
app: &dyn HarmonyApp<T>,
topology: T,
ctx: &AppContext,
options: DeployOptions,
) -> Result<DeployReport> { ) -> Result<DeployReport> {
app.publish(ctx).await?; app.publish(ctx).await?;
deploy(app, topology, ctx).await deploy_with_options(app, topology, ctx, options).await
} }
/// Workload readiness in the app's namespace (operational, read-only). /// Workload readiness in the app's namespace (operational, read-only).

View File

@@ -15,10 +15,10 @@ use k8s_openapi::api::apps::v1::{
Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment, Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment,
}; };
use k8s_openapi::api::core::v1::{ use k8s_openapi::api::core::v1::{
Container, ContainerPort, EnvFromSource, EnvVar, KeyToPath, PersistentVolumeClaim, Container, ContainerPort, EnvFromSource, EnvVar, KeyToPath, LocalObjectReference,
PersistentVolumeClaimSpec, PersistentVolumeClaimVolumeSource, PodSpec, PodTemplateSpec, PersistentVolumeClaim, PersistentVolumeClaimSpec, PersistentVolumeClaimVolumeSource, PodSpec,
SecretEnvSource, SecretVolumeSource, Service, ServicePort, ServiceSpec, Volume, VolumeMount, PodTemplateSpec, SecretEnvSource, SecretVolumeSource, Service, ServicePort, ServiceSpec,
VolumeResourceRequirements, Volume, VolumeMount, VolumeResourceRequirements,
}; };
use k8s_openapi::apimachinery::pkg::api::resource::Quantity; use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
@@ -45,7 +45,7 @@ pub struct DeployConfig {
pub storage_class: Option<String>, pub storage_class: Option<String>,
pub volume_size: String, pub volume_size: String,
pub replicas: i32, pub replicas: i32,
/// `true` → RollingUpdate (needs RWX so old+new pods share volumes); /// `true` → RollingUpdate (when persistence enabled needs RWX so old+new pods share volumes);
/// `false` → Recreate (safe for single-writer stores like sqlite). /// `false` → Recreate (safe for single-writer stores like sqlite).
pub rolling: bool, pub rolling: bool,
/// Extra env injected into every service container by capabilities — /// Extra env injected into every service container by capabilities —
@@ -56,6 +56,10 @@ pub struct DeployConfig {
/// account key whose consumer wants a path, not an env var). By reference, /// account key whose consumer wants a path, not an env var). By reference,
/// like `extra_env`, so the chart carries no secret material. /// like `extra_env`, so the chart carries no secret material.
pub secret_file_mounts: Vec<SecretFileMount>, pub secret_file_mounts: Vec<SecretFileMount>,
/// Names of `kubernetes.io/dockerconfigjson` Secrets the kubelet uses to
/// pull this app's images from a private registry, set on every pod's
/// `imagePullSecrets`. By reference — the Secret is applied separately.
pub image_pull_secrets: Vec<String>,
} }
/// Mount one key of a Secret as a file at `path` (its parent dir is the mount /// Mount one key of a Secret as a file at `path` (its parent dir is the mount
@@ -86,12 +90,13 @@ impl DeployConfig {
registry: registry.into(), registry: registry.into(),
project: project.into(), project: project.into(),
version, version,
storage_class: Some(if prod { "cephfs" } else { "local-path" }.to_string()), storage_class: None,
volume_size: "1Gi".to_string(), volume_size: "1Gi".to_string(),
replicas: if prod { 2 } else { 1 }, replicas: 1,
rolling: prod, rolling: prod,
extra_env: Vec::new(), extra_env: Vec::new(),
secret_file_mounts: Vec::new(), secret_file_mounts: Vec::new(),
image_pull_secrets: Vec::new(),
} }
} }
} }
@@ -305,6 +310,12 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment {
volume_mounts: (!volume_mounts.is_empty()).then_some(volume_mounts), volume_mounts: (!volume_mounts.is_empty()).then_some(volume_mounts),
..Default::default() ..Default::default()
}], }],
image_pull_secrets: (!cfg.image_pull_secrets.is_empty()).then(|| {
cfg.image_pull_secrets
.iter()
.map(|name| LocalObjectReference { name: name.clone() })
.collect()
}),
volumes: (!volumes.is_empty()).then_some(volumes), volumes: (!volumes.is_empty()).then_some(volumes),
..Default::default() ..Default::default()
}), }),
@@ -401,6 +412,7 @@ mod tests {
rolling: true, rolling: true,
extra_env: vec![], extra_env: vec![],
secret_file_mounts: vec![], secret_file_mounts: vec![],
image_pull_secrets: vec![],
}; };
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
build_chart(&app, &cfg, tmp.path()).unwrap(); build_chart(&app, &cfg, tmp.path()).unwrap();
@@ -445,6 +457,25 @@ mod tests {
); );
} }
#[test]
fn image_pull_secrets_render_on_pods_when_set() {
let (app, mut cfg, _t) = fixture();
cfg.image_pull_secrets = vec!["registry-pull".to_string()];
let tmp = tempfile::tempdir().unwrap();
build_chart(&app, &cfg, tmp.path()).unwrap();
let dep = read(&tmp, "deployment-backend.yaml");
assert!(dep.contains("imagePullSecrets"), "{dep}");
assert!(dep.contains("registry-pull"), "{dep}");
}
#[test]
fn no_image_pull_secrets_block_when_empty() {
// `fixture()` leaves image_pull_secrets empty → no block rendered.
let (_, _, tmp) = fixture();
let dep = read(&tmp, "deployment-backend.yaml");
assert!(!dep.contains("imagePullSecrets"), "{dep}");
}
#[test] #[test]
fn portless_service_emits_no_service_object() { fn portless_service_emits_no_service_object() {
let (_, _, tmp) = fixture(); let (_, _, tmp) = fixture();

View File

@@ -55,6 +55,9 @@ pub struct ComposeService {
/// set, `publish` builds + pushes the image under our registry. /// set, `publish` builds + pushes the image under our registry.
pub build_context: Option<PathBuf>, pub build_context: Option<PathBuf>,
pub dockerfile: Option<String>, pub dockerfile: Option<String>,
/// `build.args` — baked into the image at `docker build` time (forwarded as
/// `--build-arg`). Only the advanced build form carries them.
pub build_args: Vec<(String, String)>,
pub ports: Vec<PortMap>, pub ports: Vec<PortMap>,
pub env: Vec<(String, String)>, pub env: Vec<(String, String)>,
pub mounts: Vec<VolumeMount>, pub mounts: Vec<VolumeMount>,
@@ -108,6 +111,10 @@ impl ComposeApp {
Some(docker_compose_types::BuildStep::Advanced(a)) => a.dockerfile.clone(), Some(docker_compose_types::BuildStep::Advanced(a)) => a.dockerfile.clone(),
_ => None, _ => None,
}; };
let build_args = match &svc.build_ {
Some(docker_compose_types::BuildStep::Advanced(a)) => parse_build_args(&a.args),
_ => Vec::new(),
};
if svc.image.is_none() && build_context.is_none() { if svc.image.is_none() && build_context.is_none() {
return Err(ComposeError::NoImageOrBuild { service: name }); return Err(ComposeError::NoImageOrBuild { service: name });
} }
@@ -121,6 +128,7 @@ impl ComposeApp {
image: svc.image, image: svc.image,
build_context, build_context,
dockerfile, dockerfile,
build_args,
name, name,
}); });
} }
@@ -192,6 +200,24 @@ fn parse_short_port(service: &str, spec: &str) -> Result<PortMap, ComposeError>
}) })
} }
/// Flatten compose `build.args` into ordered key/value pairs, mirroring
/// `parse_env`. List items without `=` get an empty value (docker then resolves
/// them from the build environment).
fn parse_build_args(args: &Option<docker_compose_types::BuildArgs>) -> Vec<(String, String)> {
use docker_compose_types::BuildArgs;
match args {
None | Some(BuildArgs::Simple(_)) => Vec::new(),
Some(BuildArgs::List(items)) => items
.iter()
.map(|item| match item.split_once('=') {
Some((k, v)) => (k.to_string(), v.to_string()),
None => (item.to_string(), String::new()),
})
.collect(),
Some(BuildArgs::KvPair(map)) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
}
}
fn parse_env(env: &Environment) -> Vec<(String, String)> { fn parse_env(env: &Environment) -> Vec<(String, String)> {
match env { match env {
Environment::List(items) => items Environment::List(items) => items
@@ -334,6 +360,23 @@ volumes:
); );
} }
#[test]
fn build_args_parsed_from_advanced_build() {
let yaml = "services:\n api:\n build:\n context: .\n args:\n QUARKUS_BUILD_PROFILE: dev\n";
let app = ComposeApp::parse(yaml, Path::new(".")).unwrap();
assert_eq!(
app.service("api").unwrap().build_args,
vec![("QUARKUS_BUILD_PROFILE".to_string(), "dev".to_string())]
);
}
#[test]
fn build_args_empty_for_simple_build() {
let yaml = "services:\n api:\n build: .\n";
let app = ComposeApp::parse(yaml, Path::new(".")).unwrap();
assert!(app.service("api").unwrap().build_args.is_empty());
}
#[test] #[test]
fn named_volume_becomes_a_mount_and_only_mounted_volumes_need_pvcs() { fn named_volume_becomes_a_mount_and_only_mounted_volumes_need_pvcs() {
let app = app(); let app = app();

View File

@@ -10,10 +10,11 @@ use std::ffi::OsString;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::Arc;
use anyhow::{Context, Result, bail}; 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, ConfigSource, LocalFileSource, PromptSource};
use harmony_k8s::K8sClient; use harmony_k8s::K8sClient;
use log::{debug, info}; use log::{debug, info};
use schemars::JsonSchema; use schemars::JsonSchema;
@@ -42,6 +43,8 @@ struct ContextDef {
openbao_namespace: Option<String>, openbao_namespace: Option<String>,
openbao_url: Option<String>, openbao_url: Option<String>,
zitadel_url: Option<String>, zitadel_url: Option<String>,
openbao_role: Option<String>,
zitadel_audience: Option<String>,
} }
/// The cluster K8sAnywhereTopology autoprovisions (its K3DInstallationScore /// The cluster K8sAnywhereTopology autoprovisions (its K3DInstallationScore
@@ -72,6 +75,7 @@ pub struct AppContext {
kubeconfig: Option<PathBuf>, kubeconfig: Option<PathBuf>,
/// The kubeconfig file must outlive every client/topology built from it. /// The kubeconfig file must outlive every client/topology built from it.
_kubeconfig_guard: Option<NamedTempFile>, _kubeconfig_guard: Option<NamedTempFile>,
config_client: Arc<ConfigClient>,
} }
impl AppContext { impl AppContext {
@@ -108,6 +112,11 @@ impl AppContext {
def.profile def.profile
); );
debug!("Context '{name}' definition: {def:?}"); debug!("Context '{name}' definition: {def:?}");
let config_sources = build_config_sources(def, local_config_dir.clone())
.await
.with_context(|| format!("building config sources for context '{name}'"))?;
harmony_config::init(config_sources.clone()).await;
let config_client = Arc::new(ConfigClient::new(config_sources));
let guard = match (def.autoprovision, &def.k3d, &def.openbao_namespace) { let guard = match (def.autoprovision, &def.k3d, &def.openbao_namespace) {
(true, None, None) => { (true, None, None) => {
info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')"); info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')");
@@ -119,19 +128,10 @@ impl AppContext {
} }
(false, None, Some(ns)) => { (false, None, Some(ns)) => {
info!("Cluster access: kubeconfig from OpenBao (namespace '{ns}')"); info!("Cluster access: kubeconfig from OpenBao (namespace '{ns}')");
// The kubeconfig is an OpenBao secret, so read it from an let access: ClusterAccess = config_client.get().await.with_context(|| {
// 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!( format!(
"loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \ "loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \
are the Zitadel key + OpenBao URL/role set?" is HARMONY_ZITADEL_KEY_JSON set for this context?"
) )
})?; })?;
Some(write_kubeconfig(access.kubeconfig.as_bytes())?) Some(write_kubeconfig(access.kubeconfig.as_bytes())?)
@@ -157,6 +157,7 @@ impl AppContext {
autoprovision: def.autoprovision, autoprovision: def.autoprovision,
kubeconfig: guard.as_ref().map(|g| g.path().to_path_buf()), kubeconfig: guard.as_ref().map(|g| g.path().to_path_buf()),
_kubeconfig_guard: guard, _kubeconfig_guard: guard,
config_client,
}) })
} }
@@ -172,6 +173,9 @@ impl AppContext {
pub fn local_config_dir(&self) -> Option<&Path> { pub fn local_config_dir(&self) -> Option<&Path> {
self.local_config_dir.as_deref() self.local_config_dir.as_deref()
} }
pub fn config_client(&self) -> &ConfigClient {
&self.config_client
}
pub fn k3d_cluster(&self) -> Option<&str> { pub fn k3d_cluster(&self) -> Option<&str> {
self.k3d_cluster.as_deref() self.k3d_cluster.as_deref()
} }
@@ -231,22 +235,36 @@ fn find_contexts_file() -> Result<PathBuf> {
} }
} }
/// An OpenBao-only `ConfigClient` for `namespace`, using the given URLs (each async fn build_config_sources(
/// falling through to env). Errors when no OpenBao endpoint is resolvable. def: &ContextDef,
async fn openbao_client( local_config_dir: Option<PathBuf>,
namespace: &str, ) -> Result<Vec<Arc<dyn ConfigSource>>> {
openbao_url: Option<String>, let mut sources: Vec<Arc<dyn ConfigSource>> = Vec::new();
zitadel_url: Option<String>,
) -> Result<ConfigClient> { if let Some(namespace) = &def.openbao_namespace {
let source = harmony_config::openbao_source(namespace, openbao_url, zitadel_url) let source = harmony_config::openbao_source(
.await namespace,
.with_context(|| { def.openbao_url.clone(),
format!( def.zitadel_url.clone(),
"no OpenBao source for '{namespace}' — set `openbao_url`/OPENBAO_URL \ def.zitadel_audience.clone(),
and the Zitadel auth env (HARMONY_ZITADEL_KEY_*, AUDIENCE, OPENBAO_JWT_ROLE)" Some(
def.openbao_role
.clone()
.unwrap_or_else(|| format!("{namespace}-cd")),
),
) )
})?; .await
Ok(ConfigClient::new(vec![source])) .with_context(|| format!("reaching OpenBao for namespace '{namespace}'"))?;
sources.push(source);
} else {
let dir = local_config_dir
.or_else(LocalFileSource::default_path)
.context("local contexts need a config directory")?;
sources.push(Arc::new(LocalFileSource::new(dir)));
}
sources.push(Arc::new(PromptSource::new()));
Ok(sources)
} }
fn write_kubeconfig(contents: &[u8]) -> Result<NamedTempFile> { fn write_kubeconfig(contents: &[u8]) -> Result<NamedTempFile> {

View File

@@ -7,7 +7,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::Path; use std::path::Path;
use crate::{AppContext, AppIdentity, AppRef, Capability, HarmonyApp, Profile}; use crate::{AppContext, AppIdentity, AppRef, Capability, DeployOptions, HarmonyApp, Profile};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use async_trait::async_trait; use async_trait::async_trait;
use harmony::score::Score; use harmony::score::Score;
@@ -104,7 +104,12 @@ impl<T: Topology> ComposeDeploy<T> {
} }
/// Derive the deploy Score for a profile (pure — the testable core). /// Derive the deploy Score for a profile (pure — the testable core).
pub fn score(&self, profile: Profile, version: &str) -> Result<ComposeAppScore, String> { pub fn score(
&self,
profile: Profile,
version: &str,
force_conflicts: bool,
) -> Result<ComposeAppScore, String> {
let public_endpoint = match &self.expose { let public_endpoint = match &self.expose {
Some(e) => Some(PublicEndpoint::from_compose( Some(e) => Some(PublicEndpoint::from_compose(
&self.app, &self.app,
@@ -133,6 +138,7 @@ impl<T: Topology> ComposeDeploy<T> {
app: self.app.clone(), app: self.app.clone(),
deploy, deploy,
app_secrets: self.app_secrets.clone(), app_secrets: self.app_secrets.clone(),
force_conflicts,
}) })
} }
} }
@@ -147,8 +153,17 @@ impl<T: Topology + HelmCommand + K8sclient> HarmonyApp<T> for ComposeDeploy<T> {
} }
async fn scores(&self, ctx: &AppContext) -> Result<Vec<Box<dyn Score<T>>>> { async fn scores(&self, ctx: &AppContext) -> Result<Vec<Box<dyn Score<T>>>> {
self.scores_with_options(ctx, DeployOptions::default())
.await
}
async fn scores_with_options(
&self,
ctx: &AppContext,
options: DeployOptions,
) -> Result<Vec<Box<dyn Score<T>>>> {
let app_score = self let app_score = self
.score(ctx.profile(), ctx.version()) .score(ctx.profile(), ctx.version(), options.force_conflicts)
.map_err(|e| anyhow!(e))?; .map_err(|e| anyhow!(e))?;
let mut scores: Vec<Box<dyn Score<T>>> = vec![Box::new(app_score)]; let mut scores: Vec<Box<dyn Score<T>>> = vec![Box::new(app_score)];
let app_ref = self.app_ref(ctx.profile()); let app_ref = self.app_ref(ctx.profile());
@@ -201,7 +216,7 @@ mod tests {
#[test] #[test]
fn namespace_and_project_default_to_name() { fn namespace_and_project_default_to_name() {
let s = CD::from_compose("timesheet", fixture()) let s = CD::from_compose("timesheet", fixture())
.score(Profile::Local, "0.1.0") .score(Profile::Local, "0.1.0", false)
.unwrap(); .unwrap();
assert_eq!(s.namespace, "timesheet"); assert_eq!(s.namespace, "timesheet");
assert_eq!(s.release_name, "timesheet"); assert_eq!(s.release_name, "timesheet");
@@ -211,7 +226,7 @@ mod tests {
fn local_profile_renders_locally_rwo_http() { fn local_profile_renders_locally_rwo_http() {
let s = CD::from_compose("ts", fixture()) let s = CD::from_compose("ts", fixture())
.expose("frontend", "ts.local") .expose("frontend", "ts.local")
.score(Profile::Local, "1.0.0") .score(Profile::Local, "1.0.0", false)
.unwrap(); .unwrap();
assert!(s.published_chart.is_none(), "local renders from compose"); assert!(s.published_chart.is_none(), "local renders from compose");
assert_eq!(s.deploy.replicas, 1); assert_eq!(s.deploy.replicas, 1);
@@ -229,13 +244,13 @@ mod tests {
.registry("hub.x") .registry("hub.x")
.project("p") .project("p")
.expose("frontend", "ts.x") .expose("frontend", "ts.x")
.score(Profile::Prod, "2.0.0") .score(Profile::Prod, "2.0.0", false)
.unwrap(); .unwrap();
let pc = s.published_chart.expect("prod installs published chart"); let pc = s.published_chart.expect("prod installs published chart");
assert_eq!(pc.registry, "hub.x"); assert_eq!(pc.registry, "hub.x");
assert_eq!(pc.project, "p"); assert_eq!(pc.project, "p");
assert_eq!(pc.version, "2.0.0"); assert_eq!(pc.version, "2.0.0");
assert_eq!(s.deploy.replicas, 2); assert_eq!(s.deploy.replicas, 1);
assert!(s.deploy.rolling); assert!(s.deploy.rolling);
assert_eq!( assert_eq!(
s.public_endpoint.unwrap().cluster_issuer.as_deref(), s.public_endpoint.unwrap().cluster_issuer.as_deref(),
@@ -247,7 +262,7 @@ mod tests {
fn secrets_are_carried_to_the_score() { fn secrets_are_carried_to_the_score() {
let s = CD::from_compose("ts", fixture()) let s = CD::from_compose("ts", fixture())
.secret("DB_PASSWORD", "dev") .secret("DB_PASSWORD", "dev")
.score(Profile::Local, "0.1.0") .score(Profile::Local, "0.1.0", false)
.unwrap(); .unwrap();
assert_eq!(s.app_secrets.get("DB_PASSWORD").unwrap(), "dev"); assert_eq!(s.app_secrets.get("DB_PASSWORD").unwrap(), "dev");
} }
@@ -256,7 +271,7 @@ mod tests {
fn postgres_capability_wires_database_url_by_reference() { fn postgres_capability_wires_database_url_by_reference() {
let s = CD::from_compose("ts", fixture()) let s = CD::from_compose("ts", fixture())
.with(Postgres::managed()) .with(Postgres::managed())
.score(Profile::Local, "0.1.0") .score(Profile::Local, "0.1.0", false)
.unwrap(); .unwrap();
let db = s let db = s
.deploy .deploy

View File

@@ -25,8 +25,8 @@ pub mod publish;
pub mod score; pub mod score;
pub use app::{ pub use app::{
AppIdentity, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, WorkloadStatus, AppIdentity, DeployOptions, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome,
deploy, logs, ship, status, WorkloadStatus, deploy, deploy_with_options, logs, ship, ship_with_options, status,
}; };
pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth}; pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth};
pub use chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image}; pub use chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image};

View File

@@ -45,6 +45,9 @@ pub fn build_images(app: &ComposeApp, cfg: &DeployConfig) -> Result<()> {
if let Some(dockerfile) = &svc.dockerfile { if let Some(dockerfile) = &svc.dockerfile {
cmd.arg("-f").arg(context.join(dockerfile)); cmd.arg("-f").arg(context.join(dockerfile));
} }
for (key, value) in &svc.build_args {
cmd.arg("--build-arg").arg(format!("{key}={value}"));
}
cmd.arg(context); cmd.arg(context);
run(cmd)?; run(cmd)?;
} }

View File

@@ -92,6 +92,7 @@ pub struct ComposeAppScore {
/// values must never reach Score display/logs. /// values must never reach Score display/logs.
#[serde(skip)] #[serde(skip)]
pub app_secrets: BTreeMap<String, String>, pub app_secrets: BTreeMap<String, String>,
pub force_conflicts: bool,
} }
impl<T: Topology + HelmCommand + K8sclient> Score<T> for ComposeAppScore { impl<T: Topology + HelmCommand + K8sclient> Score<T> for ComposeAppScore {
@@ -229,6 +230,18 @@ impl ComposeAppInterpret {
version: &str, version: &str,
) -> Result<Outcome, InterpretError> { ) -> Result<Outcome, InterpretError> {
let s = &self.score; let s = &self.score;
// Ensure the namespace exists without patching it (a namespace-scoped
// deployer can't patch the cluster-scoped Namespace object), then install
// without helm `--create-namespace`.
topology
.k8s_client()
.await
.map_err(|e| InterpretError::new(format!("Failed to get k8s client: {e}")))?
.ensure_namespace(&s.namespace)
.await
.map_err(|e| {
InterpretError::new(format!("Failed to ensure namespace '{}': {e}", s.namespace))
})?;
HelmChartScore { HelmChartScore {
namespace: Some(non_blank(&s.namespace, "namespace")?), namespace: Some(non_blank(&s.namespace, "namespace")?),
release_name: non_blank(&s.release_name, "release_name")?, release_name: non_blank(&s.release_name, "release_name")?,
@@ -236,8 +249,9 @@ impl ComposeAppInterpret {
chart_version: Some(non_blank(version, "chart_version")?), chart_version: Some(non_blank(version, "chart_version")?),
values_overrides: None, values_overrides: None,
values_yaml: None, values_yaml: None,
create_namespace: true, create_namespace: false,
install_only: false, install_only: false,
force_conflicts: s.force_conflicts,
repository: None, repository: None,
} }
.interpret(inventory, topology) .interpret(inventory, topology)

View File

@@ -37,6 +37,7 @@ fn generated_chart_passes_helm_lint_and_template() {
rolling: true, rolling: true,
extra_env: vec![], extra_env: vec![],
secret_file_mounts: vec![], secret_file_mounts: vec![],
image_pull_secrets: vec![],
}; };
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();

View File

@@ -40,9 +40,17 @@ struct AppCli {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum Verb { enum Verb {
/// Build + publish + deploy. /// Build + publish + deploy.
Ship, Ship {
/// Pass `--force-conflicts` to the app chart's helm upgrade.
#[arg(long)]
force_conflicts: bool,
},
/// Converge the app's Scores (no build). /// Converge the app's Scores (no build).
Deploy, Deploy {
/// Pass `--force-conflicts` to the app chart's helm upgrade.
#[arg(long)]
force_conflicts: bool,
},
/// Workload readiness in the app's namespace. /// Workload readiness in the app's namespace.
Status, Status,
/// Recent logs from the app's pods. /// Recent logs from the app's pods.
@@ -66,8 +74,24 @@ pub async fn app_main<A: HarmonyApp<K8sAnywhereTopology> + 'static>(app: A) -> R
let ctx = AppContext::resolve(&context, cli.tag, cli.local_config, cli.config).await?; let ctx = AppContext::resolve(&context, cli.tag, cli.local_config, cli.config).await?;
match cli.verb { match cli.verb {
Verb::Ship => render_deploy(harmony_app::ship(&app, ctx.topology(), &ctx).await?), Verb::Ship { force_conflicts } => render_deploy(
Verb::Deploy => render_deploy(harmony_app::deploy(&app, ctx.topology(), &ctx).await?), harmony_app::ship_with_options(
&app,
ctx.topology(),
&ctx,
harmony_app::DeployOptions { force_conflicts },
)
.await?,
),
Verb::Deploy { force_conflicts } => render_deploy(
harmony_app::deploy_with_options(
&app,
ctx.topology(),
&ctx,
harmony_app::DeployOptions { force_conflicts },
)
.await?,
),
Verb::Status => render_status(harmony_app::status(&app, &ctx).await?), Verb::Status => render_status(harmony_app::status(&app, &ctx).await?),
Verb::Logs { tail } => render_logs(harmony_app::logs(&app, &ctx, Some(tail)).await?), Verb::Logs { tail } => render_logs(harmony_app::logs(&app, &ctx, Some(tail)).await?),
} }

View File

@@ -19,6 +19,7 @@ pub use harmony_config_derive::Config;
pub use source::env::EnvSource; pub use source::env::EnvSource;
pub use source::local_file::LocalFileSource; pub use source::local_file::LocalFileSource;
pub use source::prompt::PromptSource; pub use source::prompt::PromptSource;
pub use source::prompt::{FieldPrompter, FieldType, InquirePrompter};
pub use source::sqlite::SqliteSource; pub use source::sqlite::SqliteSource;
pub use source::store::StoreSource; pub use source::store::StoreSource;
pub use source::toml_file::TomlFileSource; pub use source::toml_file::TomlFileSource;
@@ -171,16 +172,46 @@ impl ConfigClient {
} }
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> { pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
match self.get::<T>().await { self.get_or_fill_missing::<T>(&InquirePrompter).await
Ok(config) => Ok(config), }
Err(ConfigError::NotFound { .. }) => {
let config = PromptSource::new().prompt_for::<T>().await?; /// Like [`get`](Self::get), but when no source holds a *complete* `T`,
/// prompt for the fields that are missing — keeping any partial values
/// already stored (e.g. after a `Secret` struct gains a field) — then
/// persist the merged result. Prompting is delegated to `prompter` so the
/// flow is testable without a terminal.
async fn get_or_fill_missing<T: Config>(
&self,
prompter: &dyn FieldPrompter,
) -> Result<T, ConfigError> {
let mut partial: Option<serde_json::Map<String, serde_json::Value>> = None;
for source in &self.sources {
if let Some(value) = source.get(T::CLASS, T::KEY).await? {
match serde_json::from_value::<T>(value.clone()) {
Ok(config) => return Ok(config),
Err(e) => {
warn!(
"Incomplete or stale value for {} in a source ({e}); \
will prompt for any missing fields",
T::KEY
);
// Remember the first partial object so we fill its gaps
// instead of re-prompting fields we already have.
if partial.is_none()
&& let serde_json::Value::Object(map) = value
{
partial = Some(map);
}
}
}
}
}
let config = PromptSource::new()
.prompt_filling::<T>(partial.unwrap_or_default(), prompter)
.await?;
self.set(&config).await?; self.set(&config).await?;
Ok(config) Ok(config)
} }
Err(e) => Err(e),
}
}
pub async fn set<T: Config>(&self, config: &T) -> Result<(), ConfigError> { pub async fn set<T: Config>(&self, config: &T) -> Result<(), ConfigError> {
let value = serde_json::to_value(config).map_err(|e| ConfigError::Serialization { let value = serde_json::to_value(config).map_err(|e| ConfigError::Serialization {
@@ -198,19 +229,20 @@ impl ConfigClient {
/// Build an OpenBao-backed `StoreSource` purely from env — the default chain. /// Build an OpenBao-backed `StoreSource` purely from env — the default chain.
async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> { async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
openbao_source(namespace, None, None).await openbao_source(namespace, None, None, None, None).await
} }
/// Build an OpenBao-backed `StoreSource`. `openbao_url` and `zitadel_sso_url` /// Build an OpenBao-backed `StoreSource`. Explicit arguments override their env
/// override their env vars (`OPENBAO_URL`/`VAULT_ADDR`, `HARMONY_SSO_URL`) when /// vars, so deploy contexts can provide OpenBao/Zitadel coordinates without
/// `Some`, else fall through to the env — so a caller that knows the endpoints /// mutating process env. Returns `None` (with a `warn!`) when no OpenBao URL is
/// (e.g. a deploy context) supplies them without mutating process env. Returns /// resolvable or the connection fails, so a missing OpenBao degrades the chain
/// `None` (with a `warn!`) when no OpenBao URL is resolvable or the connection /// instead of breaking startup.
/// fails, so a missing OpenBao degrades the chain instead of breaking startup.
pub async fn openbao_source( pub async fn openbao_source(
namespace: &str, namespace: &str,
openbao_url: Option<String>, openbao_url: Option<String>,
zitadel_sso_url: Option<String>, zitadel_sso_url: Option<String>,
zitadel_audience: Option<String>,
openbao_jwt_role: Option<String>,
) -> Option<Arc<dyn ConfigSource>> { ) -> Option<Arc<dyn ConfigSource>> {
let env = |k: &str| std::env::var(k).ok(); let env = |k: &str| std::env::var(k).ok();
@@ -233,7 +265,7 @@ pub async fn openbao_source(
match ( match (
key_path.is_some() || key_json.is_some(), key_path.is_some() || key_json.is_some(),
sso_url.clone(), sso_url.clone(),
env("HARMONY_ZITADEL_AUDIENCE"), zitadel_audience.or_else(|| env("HARMONY_ZITADEL_AUDIENCE")),
) { ) {
(true, Some(oidc_issuer_url), Some(audience)) => { (true, Some(oidc_issuer_url), Some(audience)) => {
Some(harmony_secret::ZitadelJwtBearerConfig { Some(harmony_secret::ZitadelJwtBearerConfig {
@@ -257,7 +289,7 @@ pub async fn openbao_source(
password: env("OPENBAO_PASSWORD"), password: env("OPENBAO_PASSWORD"),
zitadel_sso_url: 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: openbao_jwt_role.or_else(|| env("OPENBAO_JWT_ROLE")),
jwt_auth_mount: env("OPENBAO_JWT_AUTH_MOUNT"), jwt_auth_mount: env("OPENBAO_JWT_AUTH_MOUNT"),
zitadel_jwt_bearer, zitadel_jwt_bearer,
}) })
@@ -432,6 +464,76 @@ mod tests {
} }
} }
/// A `FieldPrompter` double: returns canned answers and records which
/// fields it was asked for, so a test can assert only the missing fields
/// were prompted.
struct FakePrompter {
answers: std::collections::HashMap<&'static str, serde_json::Value>,
asked: std::sync::Mutex<Vec<String>>,
}
impl FieldPrompter for FakePrompter {
fn prompt(
&self,
_key: &str,
field: &str,
_ty: FieldType,
_is_secret: bool,
) -> Result<serde_json::Value, ConfigError> {
self.asked.lock().unwrap().push(field.to_string());
self.answers
.get(field)
.cloned()
.ok_or_else(|| ConfigError::PromptError(format!("no canned answer for `{field}`")))
}
}
#[tokio::test]
async fn get_or_prompt_fills_only_missing_secret_fields_and_persists() {
// A Secret struct that has GAINED the `token` field since the stored
// value was written.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Config)]
#[config(secret)]
struct EvolvingSecret {
username: String,
token: String,
}
// Store the OLD shape — only `username` existed back then.
let mut data = std::collections::HashMap::new();
data.insert(
"EvolvingSecret".to_string(),
serde_json::json!({ "username": "alice" }),
);
let mock = Arc::new(MockSource::with_data(data));
let client = ConfigClient::new(vec![mock.clone()]);
let fake = FakePrompter {
answers: std::collections::HashMap::from([("token", serde_json::json!("s3cr3t"))]),
asked: std::sync::Mutex::new(Vec::new()),
};
let got: EvolvingSecret = client.get_or_fill_missing(&fake).await.unwrap();
// Only the missing field was prompted; the existing value was kept.
assert_eq!(*fake.asked.lock().unwrap(), vec!["token".to_string()]);
assert_eq!(got.username, "alice");
assert_eq!(got.token, "s3cr3t");
// The merged value was written back to the store.
let stored = mock
.data
.lock()
.unwrap()
.get("EvolvingSecret")
.cloned()
.unwrap();
assert_eq!(
stored,
serde_json::json!({ "username": "alice", "token": "s3cr3t" })
);
}
#[tokio::test] #[tokio::test]
async fn test_get_returns_value_when_found_in_first_source() { async fn test_get_returns_value_when_found_in_first_source() {
let config = TestConfig { let config = TestConfig {

View File

@@ -9,6 +9,95 @@ use crate::{Config, ConfigClass, ConfigError, ConfigSource};
// terminal ownership, so concurrent prompts would corrupt the terminal. // terminal ownership, so concurrent prompts would corrupt the terminal.
static PROMPT_MUTEX: Mutex<()> = Mutex::const_new(()); static PROMPT_MUTEX: Mutex<()> = Mutex::const_new(());
/// The primitive shape of a flat config field, resolved from its JsonSchema.
/// Decouples schema-walking from the actual input widget so the latter can be
/// swapped out (e.g. for tests) — see [`FieldPrompter`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldType {
String,
Integer,
Number,
Boolean,
}
/// Produces a value for a single config field. The real implementation
/// ([`InquirePrompter`]) reads it interactively; tests inject a double so the
/// fill-missing flow can be exercised without a terminal.
pub trait FieldPrompter: Send + Sync {
fn prompt(
&self,
key: &str,
field: &str,
ty: FieldType,
is_secret: bool,
) -> Result<serde_json::Value, ConfigError>;
}
/// Reads each field interactively via `inquire`: secret strings are masked
/// (`Password`), other primitives use type-appropriate input.
pub struct InquirePrompter;
impl FieldPrompter for InquirePrompter {
fn prompt(
&self,
key: &str,
field: &str,
ty: FieldType,
is_secret: bool,
) -> Result<serde_json::Value, ConfigError> {
let label = format!("{field}:");
match ty {
FieldType::String if is_secret => {
// Masked mode echoes `*` per keystroke (inquire's default
// Hidden mode shows nothing until enter).
let raw = Password::new(&label)
.with_display_mode(PasswordDisplayMode::Masked)
.without_confirmation()
.prompt()
.map_err(|e| {
ConfigError::PromptError(format!(
"Password prompt for `{key}::{field}` failed: {e}"
))
})?;
Ok(serde_json::Value::String(raw))
}
FieldType::String => {
let raw = Text::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Text prompt for `{key}::{field}` failed: {e}"
))
})?;
Ok(serde_json::Value::String(raw))
}
FieldType::Integer => {
// i64 covers Config integer fields; serde narrows on deserialize.
let n = CustomType::<i64>::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Integer prompt for `{key}::{field}` failed: {e}"
))
})?;
Ok(serde_json::json!(n))
}
FieldType::Number => {
let n = CustomType::<f64>::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Number prompt for `{key}::{field}` failed: {e}"
))
})?;
Ok(serde_json::json!(n))
}
FieldType::Boolean => {
let b = CustomType::<bool>::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Boolean prompt for `{key}::{field}` failed: {e}"
))
})?;
Ok(serde_json::Value::Bool(b))
}
}
}
}
pub struct PromptSource; pub struct PromptSource;
impl PromptSource { impl PromptSource {
@@ -16,16 +105,38 @@ impl PromptSource {
Self Self
} }
/// Prompt for a `T`. `Standard` structs go through `interactive_parse` /// Prompt for a full `T` (all fields). Equivalent to filling an empty value.
/// (full nested support); `Secret` structs go through a flat-field
/// walker that reads `#[config(secret)]` fields via `inquire::Password`.
pub async fn prompt_for<T: Config>(&self) -> Result<T, ConfigError> { pub async fn prompt_for<T: Config>(&self) -> Result<T, ConfigError> {
self.prompt_filling::<T>(serde_json::Map::new(), &InquirePrompter)
.await
}
/// Prompt for the fields of `T` that are **absent** from `existing`, keeping
/// the values already present, then deserialize the merged object. This is
/// what lets a `Secret` struct gain a field without re-prompting the rest.
///
/// `Secret` structs go through a flat-field walker (only the missing fields
/// are prompted, via `prompter`); `Standard` structs always go through the
/// full interactive parser (partial fill of nested structs is unsupported).
pub async fn prompt_filling<T: Config>(
&self,
existing: serde_json::Map<String, serde_json::Value>,
prompter: &dyn FieldPrompter,
) -> Result<T, ConfigError> {
let _guard = PROMPT_MUTEX.lock().await; let _guard = PROMPT_MUTEX.lock().await;
let banner = format!( let banner = if existing.is_empty() {
format!(
"── Configuring `{}` ({:?}) — please fill the fields below ──", "── Configuring `{}` ({:?}) — please fill the fields below ──",
T::KEY, T::KEY,
T::CLASS, T::CLASS,
); )
} else {
format!(
"── `{}` ({:?}) is missing fields — fill them in (existing values are kept) ──",
T::KEY,
T::CLASS,
)
};
// inquire renders on stderr; match that channel and pad with blank // inquire renders on stderr; match that channel and pad with blank
// lines so the banner stays separate from preceding log output. // lines so the banner stays separate from preceding log output.
eprintln!(); eprintln!();
@@ -35,7 +146,7 @@ impl PromptSource {
ConfigClass::Standard => { ConfigClass::Standard => {
T::parse_to_obj().map_err(|e| ConfigError::PromptError(e.to_string())) T::parse_to_obj().map_err(|e| ConfigError::PromptError(e.to_string()))
} }
ConfigClass::Secret => prompt_secret_struct::<T>(), ConfigClass::Secret => prompt_secret_struct_filling::<T>(existing, prompter),
} }
} }
} }
@@ -66,10 +177,13 @@ impl ConfigSource for PromptSource {
} }
} }
// Walks a Secret struct's schema, reading `T::SECRET_FIELDS` via Password // Walks a Secret struct's schema and prompts (via `prompter`) for every field
// and other fields via type-appropriate prompts. Only flat primitive // NOT already present in `json`, leaving existing values untouched. Only flat
// fields are supported; anything else returns a clear PromptError. // primitive fields are supported; anything else returns a clear PromptError.
fn prompt_secret_struct<T: Config>() -> Result<T, ConfigError> { fn prompt_secret_struct_filling<T: Config>(
mut json: serde_json::Map<String, serde_json::Value>,
prompter: &dyn FieldPrompter,
) -> Result<T, ConfigError> {
let root: RootSchema = schemars::schema_for!(T); let root: RootSchema = schemars::schema_for!(T);
let object = root.schema.object.as_deref().ok_or_else(|| { let object = root.schema.object.as_deref().ok_or_else(|| {
ConfigError::PromptError(format!( ConfigError::PromptError(format!(
@@ -79,11 +193,15 @@ fn prompt_secret_struct<T: Config>() -> Result<T, ConfigError> {
)) ))
})?; })?;
let mut json = serde_json::Map::with_capacity(object.properties.len());
for (field_name, field_schema) in &object.properties { for (field_name, field_schema) in &object.properties {
// Already stored (e.g. from a previous version of the struct) — keep it.
if json.contains_key(field_name) {
continue;
}
let field_object = schema_object(field_schema, T::KEY, field_name)?; let field_object = schema_object(field_schema, T::KEY, field_name)?;
let ty = field_type::<T>(field_name, field_object)?;
let is_secret = T::SECRET_FIELDS.contains(&field_name.as_str()); let is_secret = T::SECRET_FIELDS.contains(&field_name.as_str());
let value = prompt_field::<T>(field_name, field_object, is_secret)?; let value = prompter.prompt(T::KEY, field_name, ty, is_secret)?;
json.insert(field_name.clone(), value); json.insert(field_name.clone(), value);
} }
@@ -109,14 +227,13 @@ fn schema_object<'a>(
} }
} }
// Picks the inquire widget for one field from its JsonSchema. Secret // Resolves one field's primitive `FieldType` from its JsonSchema. Returns Err
// strings use Password; other primitives use type-appropriate input. // for unsupported shapes (multi-type/nullable, nested, arrays) — never a silent
// Returns Err for unsupported shapes — never a silent unmasked fallback. // fallback.
fn prompt_field<T: Config>( fn field_type<T: Config>(
field_name: &str, field_name: &str,
schema: &SchemaObject, schema: &SchemaObject,
is_secret: bool, ) -> Result<FieldType, ConfigError> {
) -> Result<serde_json::Value, ConfigError> {
let instance_type = schema.instance_type.as_ref().ok_or_else(|| { let instance_type = schema.instance_type.as_ref().ok_or_else(|| {
ConfigError::PromptError(format!( ConfigError::PromptError(format!(
"Secret struct `{}` field `{field_name}` is not a flat primitive \ "Secret struct `{}` field `{field_name}` is not a flat primitive \
@@ -136,61 +253,11 @@ fn prompt_field<T: Config>(
} }
}; };
let label = format!("{field_name}:");
match single { match single {
InstanceType::String => { InstanceType::String => Ok(FieldType::String),
if is_secret { InstanceType::Integer => Ok(FieldType::Integer),
// Masked mode echoes `*` per keystroke (inquire's default InstanceType::Number => Ok(FieldType::Number),
// Hidden mode shows nothing until enter). InstanceType::Boolean => Ok(FieldType::Boolean),
let raw = Password::new(&label)
.with_display_mode(PasswordDisplayMode::Masked)
.without_confirmation()
.prompt()
.map_err(|e| {
ConfigError::PromptError(format!(
"Password prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::Value::String(raw))
} else {
let raw = Text::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Text prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::Value::String(raw))
}
}
InstanceType::Integer => {
// i64 covers Config integer fields; serde narrows on deserialize.
let n = CustomType::<i64>::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Integer prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::json!(n))
}
InstanceType::Number => {
let n = CustomType::<f64>::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Number prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::json!(n))
}
InstanceType::Boolean => {
let b = CustomType::<bool>::new(&label).prompt().map_err(|e| {
ConfigError::PromptError(format!(
"Boolean prompt for `{}::{field_name}` failed: {e}",
T::KEY
))
})?;
Ok(serde_json::Value::Bool(b))
}
other => Err(ConfigError::PromptError(format!( other => Err(ConfigError::PromptError(format!(
"Secret struct `{}` field `{field_name}` has unsupported type \ "Secret struct `{}` field `{field_name}` has unsupported type \
`{other:?}`; the walker only handles flat primitives.", `{other:?}`; the walker only handles flat primitives.",

View File

@@ -46,7 +46,14 @@ pub struct CachedToken {
#[derive(Deserialize)] #[derive(Deserialize)]
struct TokenResponse { struct TokenResponse {
access_token: String, /// The verifiable JWT we exchange for an OpenBao token (requested via the
/// `openid` scope). Zitadel's `access_token` is **opaque** by default — not
/// a usable OpenBao JWT — so we deliberately use the id_token, which carries
/// the service user in `sub` and the project id in `aud` (the latter via the
/// `urn:zitadel:iam:org:project:id:<aud>:aud` scope), exactly what the
/// OpenBao JWT role binds on.
#[serde(default)]
id_token: Option<String>,
#[serde(default)] #[serde(default)]
expires_in: Option<i64>, expires_in: Option<i64>,
} }
@@ -112,10 +119,16 @@ impl ZitadelJwtBearer {
let tr: TokenResponse = resp.json().await.context("parsing token response")?; let tr: TokenResponse = resp.json().await.context("parsing token response")?;
let expires_in = tr.expires_in.unwrap_or(3600); let expires_in = tr.expires_in.unwrap_or(3600);
let token = tr.access_token.clone(); // Send the id_token (a JWT), NOT the opaque access_token — OpenBao's JWT
// auth must be able to verify it against Zitadel's JWKS.
let token = tr.id_token.context(
"Zitadel token response had no id_token — the `openid` scope is \
required (it is in build_scope) and the service user must be \
allowed to receive an id_token",
)?;
*self.cache.lock().unwrap() = Some(CachedToken { *self.cache.lock().unwrap() = Some(CachedToken {
access_token: tr.access_token, access_token: token.clone(),
expires_at_unix: now + expires_in, expires_at_unix: now + expires_in,
}); });