feat/compose-build-args-zitadel-readiness-ci-auth #341
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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/"),
|
||||||
|
|||||||
@@ -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/"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
219
harmony/src/modules/github_runner.rs
Normal file
219
harmony/src/modules/github_runner.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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![
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
125
harmony/src/modules/registry_pull_secret.rs
Normal file
125
harmony/src/modules/registry_pull_secret.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
.sample_iter(&rand::distr::Alphanumeric)
|
// Zitadel DB undecryptable. Resolve in priority order:
|
||||||
.take(32)
|
// 1. the in-cluster Secret, if present (authoritative — it's what the
|
||||||
.map(char::from)
|
// running instance encrypted its data with),
|
||||||
.collect::<String>();
|
// 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)
|
||||||
|
.take(32)
|
||||||
|
.map(char::from)
|
||||||
|
.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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -2254,44 +2254,65 @@ 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 pods = k8s
|
let label = format!("app.kubernetes.io/name={svc}");
|
||||||
.list_resources::<k8s_openapi::api::core::v1::Pod>(
|
// On a cold cluster the Zitadel Deployment pod is not Running yet
|
||||||
Some(&self.score.namespace),
|
// (image pull + DB migration take a minute or more), so poll for
|
||||||
Some(kube::api::ListParams::default().labels(&label)),
|
// it instead of failing on the first miss — otherwise a one-shot
|
||||||
)
|
// `ship` aborts here and only a re-run (pod now up) succeeds.
|
||||||
.await
|
let pod = {
|
||||||
.map_err(|e| InterpretError::new(format!("listing '{svc}' pods: {e}")))?;
|
use std::time::{Duration, Instant};
|
||||||
// The chart's init/setup Job pods share this label; only the
|
let timeout = Duration::from_secs(300);
|
||||||
// serving Deployment pod is Running, so filter on phase.
|
let interval = Duration::from_secs(3);
|
||||||
let pod = pods
|
let deadline = Instant::now() + timeout;
|
||||||
.items
|
loop {
|
||||||
.into_iter()
|
let pods = k8s
|
||||||
.find(|p| p.status.as_ref().and_then(|s| s.phase.as_deref()) == Some("Running"))
|
.list_resources::<k8s_openapi::api::core::v1::Pod>(
|
||||||
.and_then(|p| p.metadata.name)
|
Some(&self.score.namespace),
|
||||||
.ok_or_else(|| {
|
Some(kube::api::ListParams::default().labels(&label)),
|
||||||
InterpretError::new(format!(
|
)
|
||||||
"no Running pod for service '{svc}' in namespace '{}'",
|
.await
|
||||||
self.score.namespace
|
.map_err(|e| InterpretError::new(format!("listing '{svc}' pods: {e}")))?;
|
||||||
))
|
// The chart's init/setup Job pods share this label; only
|
||||||
})?;
|
// the serving Deployment pod is Running, so filter on phase.
|
||||||
// Zitadel serves http on 8080 in-cluster (chart default).
|
let running = pods
|
||||||
let handle = k8s
|
.items
|
||||||
.port_forward(&pod, &self.score.namespace, 0, 8080)
|
.into_iter()
|
||||||
.await
|
.find(|p| {
|
||||||
.map_err(|e| InterpretError::new(format!("port-forward to '{pod}': {e}")))?;
|
p.status.as_ref().and_then(|s| s.phase.as_deref()) == Some("Running")
|
||||||
let endpoint = format!("http://127.0.0.1:{}", handle.port());
|
})
|
||||||
info!("[ZitadelSetup] Provisioning via port-forward {endpoint} -> {svc}");
|
.and_then(|p| p.metadata.name);
|
||||||
_pf = Some(handle);
|
if let Some(name) = running {
|
||||||
let mut score = self.score.clone();
|
break name;
|
||||||
score.endpoint = Some(endpoint);
|
}
|
||||||
std::borrow::Cow::Owned(ZitadelSetupInterpret { score })
|
if Instant::now() >= deadline {
|
||||||
} else {
|
return Err(InterpretError::new(format!(
|
||||||
_pf = None;
|
"no Running pod for service '{svc}' in namespace '{}' within {}s",
|
||||||
std::borrow::Cow::Borrowed(self)
|
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).
|
||||||
|
let handle = k8s
|
||||||
|
.port_forward(&pod, &self.score.namespace, 0, 8080)
|
||||||
|
.await
|
||||||
|
.map_err(|e| InterpretError::new(format!("port-forward to '{pod}': {e}")))?;
|
||||||
|
let endpoint = format!("http://127.0.0.1:{}", handle.port());
|
||||||
|
info!("[ZitadelSetup] Provisioning via port-forward {endpoint} -> {svc}");
|
||||||
|
_pf = Some(handle);
|
||||||
|
let mut score = self.score.clone();
|
||||||
|
score.endpoint = Some(endpoint);
|
||||||
|
std::borrow::Cow::Owned(ZitadelSetupInterpret { score })
|
||||||
|
} else {
|
||||||
|
_pf = None;
|
||||||
|
std::borrow::Cow::Borrowed(self)
|
||||||
|
};
|
||||||
|
|
||||||
let client = me.http_client().map_err(InterpretError::new)?;
|
let client = me.http_client().map_err(InterpretError::new)?;
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,21 +128,12 @@ 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
|
format!(
|
||||||
// falling through to env when unset).
|
"loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \
|
||||||
let access: ClusterAccess =
|
— is HARMONY_ZITADEL_KEY_JSON set for this context?"
|
||||||
openbao_client(ns, def.openbao_url.clone(), def.zitadel_url.clone())
|
)
|
||||||
.await
|
})?;
|
||||||
.with_context(|| format!("reaching OpenBao for context '{name}'"))?
|
|
||||||
.get()
|
|
||||||
.await
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \
|
|
||||||
— are the Zitadel key + OpenBao URL/role set?"
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Some(write_kubeconfig(access.kubeconfig.as_bytes())?)
|
Some(write_kubeconfig(access.kubeconfig.as_bytes())?)
|
||||||
}
|
}
|
||||||
_ => bail!(
|
_ => bail!(
|
||||||
@@ -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(
|
||||||
|
namespace,
|
||||||
|
def.openbao_url.clone(),
|
||||||
|
def.zitadel_url.clone(),
|
||||||
|
def.zitadel_audience.clone(),
|
||||||
|
Some(
|
||||||
|
def.openbao_role
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{namespace}-cd")),
|
||||||
|
),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.with_context(|| {
|
.with_context(|| format!("reaching OpenBao for namespace '{namespace}'"))?;
|
||||||
format!(
|
sources.push(source);
|
||||||
"no OpenBao source for '{namespace}' — set `openbao_url`/OPENBAO_URL \
|
} else {
|
||||||
and the Zitadel auth env (HARMONY_ZITADEL_KEY_*, AUDIENCE, OPENBAO_JWT_ROLE)"
|
let dir = local_config_dir
|
||||||
)
|
.or_else(LocalFileSource::default_path)
|
||||||
})?;
|
.context("local contexts need a config directory")?;
|
||||||
Ok(ConfigClient::new(vec![source]))
|
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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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?),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +172,45 @@ 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`,
|
||||||
self.set(&config).await?;
|
/// prompt for the fields that are missing — keeping any partial values
|
||||||
Ok(config)
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
}
|
||||||
|
let config = PromptSource::new()
|
||||||
|
.prompt_filling::<T>(partial.unwrap_or_default(), prompter)
|
||||||
|
.await?;
|
||||||
|
self.set(&config).await?;
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set<T: Config>(&self, config: &T) -> Result<(), ConfigError> {
|
pub async fn set<T: Config>(&self, config: &T) -> Result<(), ConfigError> {
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
"── Configuring `{}` ({:?}) — please fill the fields below ──",
|
format!(
|
||||||
T::KEY,
|
"── Configuring `{}` ({:?}) — please fill the fields below ──",
|
||||||
T::CLASS,
|
T::KEY,
|
||||||
);
|
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.",
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user