From 56c1b763738788ef2ae9a91c2a138c34d67413b8 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 04:27:40 -0400 Subject: [PATCH 01/20] chore(workspace): make private crates standalone, drop private_repos/* glob Globbing private_repos/* into the public workspace leaked gitignored crate names into the committed Cargo.lock. Each private crate is now its own standalone workspace; the public root no longer references them. --- Cargo.lock | 5 +---- Cargo.toml | 1 - private_repos/example/Cargo.toml | 3 +++ 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ce839da..28b6b1bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2785,10 +2785,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "example" -version = "0.0.0" - [[package]] name = "example-application-monitoring-with-tenant" version = "0.1.0" @@ -4415,6 +4411,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "toml", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 46da8293..f425d87f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ resolver = "2" members = [ "examples/*", - "private_repos/*", "harmony", "harmony_zitadel_auth", "harmony_zitadel_jwt", diff --git a/private_repos/example/Cargo.toml b/private_repos/example/Cargo.toml index 69b6c720..ea976a20 100644 --- a/private_repos/example/Cargo.toml +++ b/private_repos/example/Cargo.toml @@ -1,3 +1,6 @@ +# Standalone workspace — kept out of the public repo's workspace + Cargo.lock. +[workspace] + [package] name = "example" edition = "2024" -- 2.39.5 From a6985ece819f225845a92135fc868d9acc9e6e57 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 04:28:01 -0400 Subject: [PATCH 02/20] feat(config): add TomlFileSource for offline/local config Human-friendly sibling of LocalFileSource: reads /.toml, so multi-line values (kubeconfigs, PEM) need no escaping. Wired via an explicit ConfigClient::new chain; cleartext on disk, local/offline use only. --- harmony_config/Cargo.toml | 1 + harmony_config/src/lib.rs | 1 + harmony_config/src/source/mod.rs | 1 + harmony_config/src/source/toml_file.rs | 62 ++++++++++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 harmony_config/src/source/toml_file.rs diff --git a/harmony_config/Cargo.toml b/harmony_config/Cargo.toml index b95830e2..1a0da565 100644 --- a/harmony_config/Cargo.toml +++ b/harmony_config/Cargo.toml @@ -10,6 +10,7 @@ harmony_secret = { version = "0.1.0", path = "../harmony_secret" } harmony_config_derive = { version = "0.1.0", path = "../harmony_config_derive" } serde = { version = "1.0.209", features = ["derive", "rc"] } serde_json = "1.0.127" +toml.workspace = true thiserror.workspace = true async-trait.workspace = true tokio.workspace = true diff --git a/harmony_config/src/lib.rs b/harmony_config/src/lib.rs index 572e9b36..f610767e 100644 --- a/harmony_config/src/lib.rs +++ b/harmony_config/src/lib.rs @@ -21,6 +21,7 @@ pub use source::local_file::LocalFileSource; pub use source::prompt::PromptSource; pub use source::sqlite::SqliteSource; pub use source::store::StoreSource; +pub use source::toml_file::TomlFileSource; #[derive(Debug, Error)] pub enum ConfigError { diff --git a/harmony_config/src/source/mod.rs b/harmony_config/src/source/mod.rs index c8e77588..2058f7aa 100644 --- a/harmony_config/src/source/mod.rs +++ b/harmony_config/src/source/mod.rs @@ -3,3 +3,4 @@ pub mod local_file; pub mod prompt; pub mod sqlite; pub mod store; +pub mod toml_file; diff --git a/harmony_config/src/source/toml_file.rs b/harmony_config/src/source/toml_file.rs new file mode 100644 index 00000000..31eb1c3f --- /dev/null +++ b/harmony_config/src/source/toml_file.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use std::path::PathBuf; +use tokio::fs; + +use crate::{ConfigClass, ConfigError, ConfigSource}; + +/// Local TOML-backed config source (`/.toml`). +/// +/// The human-friendly sibling of [`LocalFileSource`](crate::LocalFileSource): +/// TOML's triple-quoted literals hold multi-line values (kubeconfigs, PEM +/// keys) without escaping. Same caveat — cleartext on disk, ignores +/// `ConfigClass` — so it is for local, offline runs only, wired via an +/// explicit [`ConfigClient::new`](crate::ConfigClient::new), never the +/// default chain. +pub struct TomlFileSource { + base_path: PathBuf, +} + +impl TomlFileSource { + pub fn new(base_path: PathBuf) -> Self { + Self { base_path } + } + + fn file_path_for(&self, key: &str) -> PathBuf { + self.base_path.join(format!("{key}.toml")) + } +} + +#[async_trait] +impl ConfigSource for TomlFileSource { + async fn get( + &self, + _class: ConfigClass, + key: &str, + ) -> Result, ConfigError> { + let path = self.file_path_for(key); + match fs::read_to_string(&path).await { + Ok(contents) => toml::from_str(&contents) + .map(Some) + .map_err(|e| ConfigError::FileError(format!("Invalid TOML in {path:?}: {e}"))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(ConfigError::FileError(format!( + "Failed to read config file {path:?}: {e}" + ))), + } + } + + async fn set( + &self, + _class: ConfigClass, + key: &str, + value: &serde_json::Value, + ) -> Result<(), ConfigError> { + fs::create_dir_all(&self.base_path).await?; + let path = self.file_path_for(key); + let contents = toml::to_string_pretty(value).map_err(|e| { + ConfigError::FileError(format!("Failed to serialize {key} to TOML: {e}")) + })?; + fs::write(&path, contents).await?; + Ok(()) + } +} -- 2.39.5 From cde433005bf75ae860c19ea95fca221807ac6e84 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 04:28:02 -0400 Subject: [PATCH 03/20] refactor(examples/compose): Profile-driven config, publish bricks, app-secret via Score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile {Local,Prod} supplies storage/replicas/rolling/TLS + publish target - publish split into build_images/push_to_registry/import_to_k3d behind PublishTarget - app_secrets applied inside ComposeAppScore (envFrom), no handrolled manifest (ADR-023) - PVC access mode derived from update strategy (RWX rolling, RWO recreate) — k3d-deployable - PublicEndpoint::from_compose dedups port extraction --- .../compose_java_react/src/bin/publish.rs | 30 +++- examples/compose_java_react/src/chart.rs | 55 +++++- examples/compose_java_react/src/lib.rs | 4 +- examples/compose_java_react/src/main.rs | 28 +-- examples/compose_java_react/src/profile.rs | 40 +++++ examples/compose_java_react/src/publish.rs | 160 ++++++++++++++---- examples/compose_java_react/src/score.rs | 56 +++++- 7 files changed, 305 insertions(+), 68 deletions(-) create mode 100644 examples/compose_java_react/src/profile.rs diff --git a/examples/compose_java_react/src/bin/publish.rs b/examples/compose_java_react/src/bin/publish.rs index 9fb0b085..779bd6c5 100644 --- a/examples/compose_java_react/src/bin/publish.rs +++ b/examples/compose_java_react/src/bin/publish.rs @@ -1,13 +1,16 @@ -//! `compose-publish` — build + push the per-service images and the -//! hydrated chart from the imported compose. `docker`/`helm` must be on -//! PATH and logged in to the registry. +//! `compose-publish` — build the per-service images and (unless `--no-push`) +//! push them + the hydrated chart to the registry. `docker`/`helm` must be +//! on PATH. use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use clap::Parser; -use example_compose_java_react::{ComposeApp, DeployConfig, HarmonyManifest, publish::publish}; +use example_compose_java_react::{ + ComposeApp, DeployConfig, HarmonyManifest, + publish::{build_images, push_to_registry}, +}; #[derive(Parser, Debug)] #[command( @@ -25,9 +28,13 @@ struct Cli { project: String, #[arg(long, default_value = "0.1.0")] version: String, - /// Build + package only; skip both pushes (local k3d smoke-test). + /// Build only; skip the push (local smoke-test). #[arg(long)] no_push: bool, + #[arg(long, env = "REGISTRY_USER")] + user: Option, + #[arg(long, env = "REGISTRY_TOKEN")] + token: Option, } fn main() -> Result<()> { @@ -52,5 +59,14 @@ fn main() -> Result<()> { rolling: true, }; - publish(&app, &deploy, !cli.no_push) + build_images(&app, &deploy)?; + if !cli.no_push { + let (Some(user), Some(token)) = (cli.user, cli.token) else { + bail!( + "--user/--token (or REGISTRY_USER/REGISTRY_TOKEN) required to push; --no-push to build only" + ); + }; + push_to_registry(&app, &deploy, &user, &token)?; + } + Ok(()) } diff --git a/examples/compose_java_react/src/chart.rs b/examples/compose_java_react/src/chart.rs index e6288733..f17aeef4 100644 --- a/examples/compose_java_react/src/chart.rs +++ b/examples/compose_java_react/src/chart.rs @@ -2,8 +2,9 @@ //! //! Typed `k8s_openapi` resources serialized at build time (ADR-018, the //! fleet operator chart pattern) — no `{{ .Values }}` templating. One -//! Deployment + Service per compose service, one RWX PVC per mounted -//! named volume. Ingress is layered at deploy time by the +//! Deployment + Service per compose service, one PVC per mounted named +//! volume (RWX under RollingUpdate, RWO under Recreate). Ingress is +//! layered at deploy time by the //! [`Score`](crate::score), not baked here, so the host stays a deploy //! knob. @@ -14,9 +15,10 @@ use k8s_openapi::api::apps::v1::{ Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment, }; use k8s_openapi::api::core::v1::{ - Container, ContainerPort, EnvVar, PersistentVolumeClaim, PersistentVolumeClaimSpec, - PersistentVolumeClaimVolumeSource, PodSpec, PodTemplateSpec, Service, ServicePort, ServiceSpec, - Volume, VolumeMount, VolumeResourceRequirements, + Container, ContainerPort, EnvFromSource, EnvVar, PersistentVolumeClaim, + PersistentVolumeClaimSpec, PersistentVolumeClaimVolumeSource, PodSpec, PodTemplateSpec, + SecretEnvSource, Service, ServicePort, ServiceSpec, Volume, VolumeMount, + VolumeResourceRequirements, }; use k8s_openapi::apimachinery::pkg::api::resource::Quantity; use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; @@ -48,6 +50,7 @@ pub struct DeployConfig { } const ACCESS_MODE_RWX: &str = "ReadWriteMany"; +const ACCESS_MODE_RWO: &str = "ReadWriteOnce"; /// The published image ref a service's Deployment should pull: built /// services get our registry coordinates, prebuilt `image:` services are @@ -65,6 +68,14 @@ pub fn service_image(cfg: &DeployConfig, svc: &ComposeService) -> String { } } +/// Name of the per-app Opaque Secret the deploy applies (from OpenBao, at +/// deploy time) and every container loads via `envFrom`. The chart only +/// *references* it (optional), so secret values never enter a published +/// chart — the [`Score`](crate::score) applies the Secret separately. +pub fn app_secret_name(app_name: &str) -> String { + format!("{app_name}-secrets") +} + /// Build + write the chart to `out_dir`; returns the chart directory /// `helm install ` wants. pub fn build_chart( @@ -200,6 +211,16 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment { image_pull_policy: Some("IfNotPresent".to_string()), ports: (!ports.is_empty()).then_some(ports), env: (!env.is_empty()).then_some(env), + // App secrets load here; `optional` so a secretless app + // needs no Secret. One app-wide bag — per-service + // targeting is deferred (Rule of Three). + env_from: Some(vec![EnvFromSource { + secret_ref: Some(SecretEnvSource { + name: app_secret_name(&cfg.app_name), + optional: Some(true), + }), + ..Default::default() + }]), volume_mounts: (!volume_mounts.is_empty()).then_some(volume_mounts), ..Default::default() }], @@ -253,7 +274,18 @@ fn pvc(name: &str, cfg: &DeployConfig) -> PersistentVolumeClaim { ..Default::default() }, spec: Some(PersistentVolumeClaimSpec { - access_modes: Some(vec![ACCESS_MODE_RWX.to_string()]), + // RollingUpdate keeps old+new pods alive together, so the volume + // must be shared (RWX); Recreate runs one pod at a time, so RWO + // suffices — and RWO is what non-distributed stores (k3d + // local-path) actually offer. + access_modes: Some(vec![ + if cfg.rolling { + ACCESS_MODE_RWX + } else { + ACCESS_MODE_RWO + } + .to_string(), + ]), storage_class_name: cfg.storage_class.clone(), resources: Some(VolumeResourceRequirements { requests: Some(BTreeMap::from([( @@ -354,6 +386,17 @@ mod tests { assert!(pvc.contains("2Gi"), "{pvc}"); } + #[test] + fn pvc_is_readwriteonce_when_recreate() { + let (app, mut cfg, _t) = fixture(); + cfg.rolling = false; + let tmp = tempfile::tempdir().unwrap(); + build_chart(&app, &cfg, tmp.path()).unwrap(); + let pvc = read(&tmp, "pvc-data.yaml"); + assert!(pvc.contains("ReadWriteOnce"), "{pvc}"); + assert!(!pvc.contains("ReadWriteMany"), "{pvc}"); + } + #[test] fn rolling_strategy_and_replicas_render() { let (_, _, tmp) = fixture(); diff --git a/examples/compose_java_react/src/lib.rs b/examples/compose_java_react/src/lib.rs index 9b648ed1..978d90c3 100644 --- a/examples/compose_java_react/src/lib.rs +++ b/examples/compose_java_react/src/lib.rs @@ -6,11 +6,13 @@ pub mod chart; pub mod compose; +pub mod profile; pub mod publish; pub mod score; -pub use chart::DeployConfig; +pub use chart::{DeployConfig, service_image}; pub use compose::ComposeApp; +pub use profile::Profile; pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart}; use std::path::Path; diff --git a/examples/compose_java_react/src/main.rs b/examples/compose_java_react/src/main.rs index f625edd6..480ffad3 100644 --- a/examples/compose_java_react/src/main.rs +++ b/examples/compose_java_react/src/main.rs @@ -83,25 +83,14 @@ async fn main() -> Result<()> { rolling: !cli.recreate, }; - // The host is a deploy-only knob; the port is read from the compose - // service so it stays the single source of truth for the app shape. - let public_endpoint = cli.host.map(|host| -> Result { - let svc = app - .service(&cli.public_service) - .with_context(|| format!("no compose service '{}'", cli.public_service))?; - let port = svc - .ports - .first() - .with_context(|| format!("service '{}' exposes no port", cli.public_service))? - .container; - Ok(PublicEndpoint { - service: cli.public_service.clone(), - port, - host, - cluster_issuer: cli.cluster_issuer, - }) - }); - let public_endpoint = public_endpoint.transpose()?; + // The host is a deploy-only knob; the port comes from compose. + let public_endpoint = match cli.host { + Some(host) => Some( + PublicEndpoint::from_compose(&app, &cli.public_service, host, cli.cluster_issuer) + .map_err(|e| anyhow::anyhow!(e))?, + ), + None => None, + }; let score = ComposeAppScore { namespace: cli.namespace, @@ -114,6 +103,7 @@ async fn main() -> Result<()> { public_endpoint, app, deploy, + app_secrets: Default::default(), }; harmony_cli::run( diff --git a/examples/compose_java_react/src/profile.rs b/examples/compose_java_react/src/profile.rs new file mode 100644 index 00000000..7e6741d1 --- /dev/null +++ b/examples/compose_java_react/src/profile.rs @@ -0,0 +1,40 @@ +//! Deploy profile — the typed "stack" (ADR-026 §7): one value that supplies +//! every environment-shaped knob, so a deploy crate branches on the profile +//! instead of carrying a flag soup. Two real instances (local vs prod) +//! justify the type (Rule of Three). + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub enum Profile { + /// Local k3d: single writer, ReadWriteOnce, plain HTTP. + Local, + /// A tenant cluster: replicated, RWX shared storage, TLS. + Prod, +} + +impl Profile { + pub fn replicas(self) -> i32 { + match self { + Profile::Local => 1, + Profile::Prod => 2, + } + } + + /// Prod rolls (old+new pods coexist → needs RWX); Local recreates one + /// pod at a time (RWO, safe for single-writer stores like sqlite). + pub fn rolling(self) -> bool { + self == Profile::Prod + } + + pub fn storage_class(self) -> String { + match self { + Profile::Local => "local-path", + Profile::Prod => "cephfs", + } + .to_string() + } + + /// Prod terminates TLS via cert-manager; Local serves plain HTTP. + pub fn cluster_issuer(self) -> Option { + (self == Profile::Prod).then(|| "letsencrypt-prod".to_string()) + } +} diff --git a/examples/compose_java_react/src/publish.rs b/examples/compose_java_react/src/publish.rs index d1f4a8b0..caf80929 100644 --- a/examples/compose_java_react/src/publish.rs +++ b/examples/compose_java_react/src/publish.rs @@ -1,23 +1,38 @@ -//! Build + push the per-service images and the hydrated chart. -//! -//! The fleet `release.rs` pattern, generalized over the compose -//! services: each service that builds from source is built then pushed -//! with docker; prebuilt `image:` services are left alone; then the -//! chart is hydrated, `helm package`d, and pushed to the OCI registry. -//! Plain `anyhow` — this is binary glue, not library API. +//! Build the per-service images, then distribute them to a target: push to +//! an OCI registry (CD) or import into a local k3d cluster (dev). The build +//! is identical either way — only distribution differs (ADR-026 §4). Plain +//! `anyhow` — binary glue, not library API. +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use crate::chart::{DeployConfig, build_chart, service_image}; use crate::compose::ComposeApp; -/// Build + push every built service image and the chart. With -/// `push = false`, builds + packages only (a local k3d smoke-test). -/// `docker` and `helm` must be on PATH and logged in to the registry. -pub fn publish(app: &ComposeApp, cfg: &DeployConfig, push: bool) -> Result<()> { +/// Where built images (and, for a registry, the chart) are distributed. +pub enum PublishTarget { + /// Push images + chart to `cfg.registry` (CD path). + Registry { user: String, token: String }, + /// Import images into a k3d cluster; the chart is rendered at deploy + /// time (no OCI chart), so nothing is pushed (local dev). + K3dImport { cluster: String }, +} + +/// Build the images, then distribute them to `target`. +pub fn publish(app: &ComposeApp, cfg: &DeployConfig, target: &PublishTarget) -> Result<()> { + build_images(app, cfg)?; + match target { + PublishTarget::Registry { user, token } => push_to_registry(app, cfg, user, token), + PublishTarget::K3dImport { cluster } => import_to_k3d(app, cfg, cluster), + } +} + +/// `docker build` each service that builds from source; prebuilt `image:` +/// services are left for the kubelet to pull. +pub fn build_images(app: &ComposeApp, cfg: &DeployConfig) -> Result<()> { for svc in &app.services { let Some(context) = &svc.build_context else { log::info!("service '{}' uses prebuilt image, skipping build", svc.name); @@ -32,35 +47,112 @@ pub fn publish(app: &ComposeApp, cfg: &DeployConfig, push: bool) -> Result<()> { } cmd.arg(context); run(cmd)?; - if push { - log::info!("docker push {image}"); - run({ - let mut c = Command::new("docker"); - c.arg("push").arg(&image); - c - })?; - } } + Ok(()) +} +/// Log in, push each built image, then package + push the hydrated chart. +pub fn push_to_registry( + app: &ComposeApp, + cfg: &DeployConfig, + user: &str, + token: &str, +) -> Result<()> { + registry_login(&cfg.registry, user, token)?; + for svc in &app.services { + if svc.build_context.is_none() { + continue; + } + let image = service_image(cfg, svc); + log::info!("docker push {image}"); + run({ + let mut c = Command::new("docker"); + c.arg("push").arg(&image); + c + })?; + } let tmp = tempfile::tempdir().context("chart tempdir")?; let chart_dir = build_chart(app, cfg, tmp.path()).map_err(|e| anyhow::anyhow!("hydrate chart: {e}"))?; let tgz = helm_package(&chart_dir, tmp.path())?; - if push { - let oci_repo = format!("oci://{}/{}", cfg.registry, cfg.project); - log::info!("helm push {} {oci_repo}", tgz.display()); - run({ - let mut c = Command::new("helm"); - c.arg("push").arg(&tgz).arg(&oci_repo); - c - })?; - } + let oci_repo = format!("oci://{}/{}", cfg.registry, cfg.project); + log::info!("helm push {} {oci_repo}", tgz.display()); + run({ + let mut c = Command::new("helm"); + c.arg("push").arg(&tgz).arg(&oci_repo); + c + }) +} - log::info!( - "published app={} version={} pushed={push}", - cfg.app_name, - cfg.version - ); +/// Load locally-built images into a k3d cluster's nodes — k3d has no +/// registry, so without this the `IfNotPresent` pulls find nothing. +pub fn import_to_k3d(app: &ComposeApp, cfg: &DeployConfig, cluster: &str) -> Result<()> { + let images: Vec = app + .services + .iter() + .filter(|s| s.build_context.is_some()) + .map(|s| service_image(cfg, s)) + .collect(); + if images.is_empty() { + return Ok(()); + } + // Prefer harmony's managed k3d (the local-dev install location); fall + // back to a `k3d` on PATH for runners that ship their own. + let managed = harmony::config::HARMONY_DATA_DIR.join("k3d").join("k3d"); + let k3d: &std::ffi::OsStr = if managed.exists() { + managed.as_os_str() + } else { + "k3d".as_ref() + }; + log::info!("k3d image import {} -> {cluster}", images.join(" ")); + let status = Command::new(k3d) + .args(["image", "import", "-c", cluster]) + .args(&images) + .status() + .context("spawn k3d (installed and on PATH?)")?; + if !status.success() { + bail!("k3d image import failed ({status})"); + } + Ok(()) +} + +fn registry_login(registry: &str, user: &str, token: &str) -> Result<()> { + login( + "docker", + &["login", registry, "-u", user, "--password-stdin"], + token, + )?; + login( + "helm", + &[ + "registry", + "login", + registry, + "-u", + user, + "--password-stdin", + ], + token, + ) +} + +/// Feed the secret on stdin so it never lands in argv or the process table. +fn login(bin: &str, args: &[&str], password: &str) -> Result<()> { + let mut child = Command::new(bin) + .args(args) + .stdin(Stdio::piped()) + .spawn() + .with_context(|| format!("spawn {bin} (installed and on PATH?)"))?; + child + .stdin + .take() + .context("child stdin unavailable")? + .write_all(password.as_bytes()) + .with_context(|| format!("writing password to {bin}"))?; + let status = child.wait().with_context(|| format!("{bin} login"))?; + if !status.success() { + bail!("{bin} registry login failed ({status})"); + } Ok(()) } diff --git a/examples/compose_java_react/src/score.rs b/examples/compose_java_react/src/score.rs index f24b39be..420e13cf 100644 --- a/examples/compose_java_react/src/score.rs +++ b/examples/compose_java_react/src/score.rs @@ -7,10 +7,13 @@ //! `ApplicationScore`, no ArgoCD — the same build/publish/deploy split the //! fleet stack uses. +use std::collections::BTreeMap; use std::str::FromStr; use async_trait::async_trait; use harmony_types::id::Id; +use k8s_openapi::api::core::v1::Secret; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use log::info; use serde::Serialize; @@ -19,10 +22,11 @@ use harmony::interpret::{Interpret, InterpretError, InterpretName, InterpretStat use harmony::inventory::Inventory; use harmony::modules::helm::chart::{HelmChartScore, NonBlankString}; use harmony::modules::k8s::ingress::K8sIngressScore; +use harmony::modules::k8s::resource::K8sResourceScore; use harmony::score::Score; use harmony::topology::{HelmCommand, K8sclient, Topology}; -use crate::chart::{DeployConfig, build_chart}; +use crate::chart::{DeployConfig, app_secret_name, build_chart}; use crate::compose::ComposeApp; /// The already-published OCI chart to install (the CD path). When set, @@ -47,6 +51,31 @@ pub struct PublicEndpoint { pub cluster_issuer: Option, } +impl PublicEndpoint { + /// Route `host` to a compose `service`; the port is read from compose + /// (single source of truth for the app shape), TLS issuer passed in. + pub fn from_compose( + app: &ComposeApp, + service: &str, + host: impl Into, + cluster_issuer: Option, + ) -> Result { + let port = app + .service(service) + .ok_or_else(|| format!("no compose service '{service}'"))? + .ports + .first() + .ok_or_else(|| format!("service '{service}' exposes no port"))? + .container; + Ok(Self { + service: service.to_string(), + port, + host: host.into(), + cluster_issuer, + }) + } +} + #[derive(Debug, Clone, Serialize)] pub struct ComposeAppScore { pub namespace: String, @@ -57,6 +86,12 @@ pub struct ComposeAppScore { /// (dev/e2e); `Some` installs the published OCI chart (CD). pub published_chart: Option, pub public_endpoint: Option, + /// App secrets (from OpenBao) applied as one Opaque `-secrets` + /// Secret before the workload, loaded by every container via `envFrom`. + /// Empty → no Secret (the chart's reference is optional). `skip`: secret + /// values must never reach Score display/logs. + #[serde(skip)] + pub app_secrets: BTreeMap, } impl Score for ComposeAppScore { @@ -85,6 +120,25 @@ impl Interpret for ComposeAppInterpret ) -> Result { let s = &self.score; + // App secrets first, so the workload's `envFrom` can load them. The + // values come from OpenBao at deploy time — never baked into the + // (possibly published) chart, which only references the Secret. + if !s.app_secrets.is_empty() { + info!("Applying {} app secret(s)", s.app_secrets.len()); + let secret = Secret { + metadata: ObjectMeta { + name: Some(app_secret_name(&s.deploy.app_name)), + namespace: Some(s.namespace.clone()), + ..Default::default() + }, + string_data: Some(s.app_secrets.clone()), + ..Default::default() + }; + K8sResourceScore::single(secret, Some(s.namespace.clone())) + .interpret(inventory, topology) + .await?; + } + // Install from the published OCI chart (CD) or a chart rendered // from the imported compose (dev/e2e). Each branch keeps its // tempdir alive across the install. -- 2.39.5 From 87509ca99a803cf3ba8171505fa4181f167fc6b2 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 04:28:02 -0400 Subject: [PATCH 04/20] docs(adr): add ADR-027 multi-tenant cloud identity One shared Zitadel project as universal audience; tenant scoping via the groups claim; per-tenant onboarding is provisioning, not app/auth changes. Index 026 + 027. --- docs/adr/027-multi-tenant-identity.md | 159 ++++++++++++++++++++++++++ docs/adr/README.md | 2 + 2 files changed, 161 insertions(+) create mode 100644 docs/adr/027-multi-tenant-identity.md diff --git a/docs/adr/027-multi-tenant-identity.md b/docs/adr/027-multi-tenant-identity.md new file mode 100644 index 00000000..4d29466d --- /dev/null +++ b/docs/adr/027-multi-tenant-identity.md @@ -0,0 +1,159 @@ +# Architecture Decision Record: Multi-Tenant Cloud Identity + +Initial Author: Jean-Gabriel Gill-Couture + +Initial Date: 2026-06-20 + +Last Updated Date: 2026-06-20 + +## Status + +Accepted. Phase 0 in use by the first tenant (`folk`); see the +[Folk CD setup guide](../../private_repos/folk_ci_deploy/SETUP.md) for the +concrete, worked example. + +## Context + +NationTech runs an open cloud: NationTech staff hold roles in one Zitadel +org (`cloud-admin`, `cloud-billing`, …), and customers create **tenants**. +A tenant owns a slice of every backing service — secrets under +`secret/data//*` in OpenBao, a Harbor project ``, Ceph RGW +buckets `-*`. The hard constraint: + +> A user (or CI identity) created in Zitadel must gain access to its tenant's +> secrets, registry, and storage **without adding a new auth method to any +> service, and without modifying any app, per tenant.** + +The open question that triggered this ADR was Zitadel structure: one +**org per tenant**, or one shared **project** with per-tenant roles? Phrased +that way it looks like a service-integration choice. It is not. + +## Decision + +**Every backing service trusts the *one* Zitadel issuer + *one* shared +audience exactly once, then authorizes on claims. Tenant scoping lives +entirely in a frozen claim contract; the org/project structure is an +internal isolation choice that can change behind it.** + +### The claim contract (frozen — apps bind to this, never to tenant structure) + +| Claim | Meaning | +|---|---| +| issuer | `https://sso.nationtech.io` — the single root of trust. | +| `aud` | Resource Id of the **shared `cloud` project** — the single audience every service pins as `bound_audiences`. Never per-tenant. | +| `groups` | string array of `:` entries, e.g. `folk:deployer`. The tenant slug is the universal resource prefix; the capability is a fixed tier. | + +Capability tiers are a fixed vocabulary: `owner` (full tenant control), +`deployer` (CI — push images, read deploy secrets, apply workloads), +`viewer` (read-only). The **tenant slug** is a human handle NationTech +assigns (`folk`), decoupled from any Zitadel UUID, and is the literal prefix +in OpenBao paths, the Harbor project name, and the Ceph bucket prefix. + +### Zitadel structure (Phase 0 — single org, shared project) + +- **One org**: `nationtech`. NationTech creates all identities for now. +- **One shared project**, `cloud`. Its Resource Id is the audience for + *everything*. One API application (Private Key JWT) under it makes that id + a valid `aud`. +- **Per-tenant = roles, not structure.** Each tenant contributes roles + `:owner` / `:deployer` / `:viewer` to the shared project. + Granting a user or service user one of these roles is the entire + "add user to tenant" operation. +- One project-level **Action** flattens granted role keys into the `groups` + claim (Zitadel's roles claim is a map; a string-array `groups` is what the + groups model below consumes — same Action as ADR-025). + +### Service integration (reuses ADR-025's groups model verbatim) + +Each service is configured **once**, never per tenant: + +- **OpenBao** — JWT auth configured against the one issuer; **one** shared + jwt role pinning `bound_audiences = ` and + `groups_claim = groups`. Per tenant+capability there is one OpenBao + *external group* `:` and one policy granting its subtree + (`secret/data//*`), attached to that group. A token carrying + `folk:deployer` resolves the policy at request time — no role edit, no + re-auth config. (Identical mechanism and write-cost properties to + ADR-025's fleet device access.) +- **Harbor / Ceph RGW** — same shape: bind the one OIDC issuer + audience + once; map `groups` membership to the tenant's project / bucket-prefix + policy. + +Onboarding tenant N is therefore pure **provisioning** — add roles + Action +entry, add one external group + policy per service — with **zero** app code, +auth-method, or audience change. This is the constraint, satisfied. + +### Phasing (the structure evolves; the contract does not) + +| Phase | Trigger | Change | Claim contract | +|---|---|---|---| +| **0 (now)** | tenant #1 | single org, shared `cloud` project; tenant encoded as `:` role keys flattened into `groups`. Zero new code. | unchanged | +| **1** | tenants #2–3 | add a first-class `tenant` claim (Zitadel Action) + OpenBao `claim_mappings` + **one** templated policy `secret/data/{{…metadata.tenant}}/*`, collapsing per-tenant groups into one policy. One-time, not per-tenant. | `tenant` becomes a distinct claim; `groups` keeps capability | +| **2** | self-service onboarding | **org per tenant** + Zitadel project grants (B2B); slug becomes org metadata; audience stays the shared `cloud` project so services still bind one `aud`. | unchanged — apps never notice | + +## Rationale + +- **Org-vs-project is not a service question.** Because every service + validates one issuer + one audience and reads claims, the Zitadel + structure is free to change behind the claim contract. Picking the + *minimal* structure now (single org) and deferring org-per-tenant to + when self-service actually demands it is YAGNI applied to identity. +- **One audience is the whole trick.** If audience were per-tenant, every + service would need a new `bound_audiences` per tenant — exactly the + per-tenant service change the constraint forbids. A single shared-project + audience is what makes onboarding pure provisioning. +- **Reuse, don't reinvent.** The group→policy authorization, its O(1) + per-change write cost, and the roles→`groups` Action already exist and are + argued in ADR-025. Multi-tenant cloud access and fleet device access are + the same problem (signed group claim gates a prefixed secret subtree); they + share one mechanism. +- **Slug as prefix, not UUID.** Binding resources to a human slug keeps + OpenBao paths, Harbor projects, and buckets legible and stable across the + Phase 2 org migration, where the UUID *does* change. + +## Consequences + +**Pros** +- New tenant: provisioning only — no app, auth-config, or audience change. +- One issuer + one audience across all services; one place to reason about + trust. +- Authorization is human-auditable: role grants in Zitadel, group→resource + policies on each service. +- The Phase 2 org migration is invisible to apps (they read claims). + +**Cons / accepted trade-offs** +- **Phase 0 isolation is logical, not org-hard.** All tenants share one org + and project; a Zitadel-admin compromise spans tenants. Acceptable while + NationTech creates every identity; Phase 2's org-per-tenant closes it. +- **Zitadel Action required** until first-class groups/claims GA — small, + version-controlled server-side JS in the token path (shared with ADR-025). +- **Capability tiers are coarse** (`owner`/`deployer`/`viewer`). Finer grants + mean more group→policy pairs; revisit only at a real third instance. + +## Alternatives considered + +- **Org per tenant from day one (Phase 2 now).** Correct end state, but + Zitadel org creation + project grants are unbuilt Scores and self-service + isn't needed yet — premature. Deferred, not rejected. +- **Per-tenant audience (a project per tenant).** Forces every service to + add a `bound_audiences` per tenant — the precise per-service change the + constraint forbids. Rejected. +- **Tenant in OpenBao only (out-of-band mapping).** Drops Zitadel as the + source of truth for who-belongs-to-what; reintroduces a second place to + manage membership. Rejected. + +## Additional Notes + +Verified Score gaps as of 2026-06-20 (build order tracks the phases): the +Zitadel Score supports projects, roles, single-org user grants, machine +users + keys, API apps, and the `groups` flatten Action — it lacks org +creation, project grants, and arbitrary custom claims. The OpenBao Score +supports JWT config + the external-groups model (ADR-025) — it lacks +`claim_mappings` and templated-policy metadata population. Phase 0 needs none +of the gaps; Phase 1 needs the OpenBao ones; Phase 2 needs the Zitadel ones. + +Relationship to other ADRs — **ADR-025**: identical groups-as-boundary +mechanism, reused not forked. **ADR-020/020-1**: `harmony_config` / +OpenBao+Zitadel as the single config+secret entry point. **ADR-023**: all +Zitadel/OpenBao configuration lands via Scores. **ADR-011**: multi-tenant +cluster isolation, the workload-side counterpart of this identity-side split. diff --git a/docs/adr/README.md b/docs/adr/README.md index b652ec64..f8df20b3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,8 @@ Every ADR follows this structure: | [024](./024-fleet-platform-capability-decomposition.md) | Fleet Platform Capability Decomposition | Accepted | | [025](./025-fleet-device-secret-access.md) | Fleet Device Secret Access | Accepted | | [025-3](./025-device-secret-access/025-3-groups-as-security-boundary.md) | Groups as the Security Boundary — Scale & Security Analysis | Accepted | +| [026](./026-application-lifecycle-cli.md) | Application Lifecycle CLI | Accepted | +| [027](./027-multi-tenant-identity.md) | Multi-Tenant Cloud Identity | Accepted | ## Contributing -- 2.39.5 From 0f6ef8731fda0c19016dd511bcd89c7f46c83646 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 04:45:16 -0400 Subject: [PATCH 05/20] feat(topology): env-free kubeconfig config + pod log capability K8sAnywhereConfig::kubeconfig(path, ctx) lets programmatic callers (the app layer, TUI, web) target a cluster without mutating process env. K8sClient gains pod_logs for operational read verbs. --- examples/compose_java_react/src/profile.rs | 40 ------------------- harmony-k8s/src/pod.rs | 20 +++++++++- .../topology/k8s_anywhere/k8s_anywhere.rs | 16 ++++++++ 3 files changed, 35 insertions(+), 41 deletions(-) delete mode 100644 examples/compose_java_react/src/profile.rs diff --git a/examples/compose_java_react/src/profile.rs b/examples/compose_java_react/src/profile.rs deleted file mode 100644 index 7e6741d1..00000000 --- a/examples/compose_java_react/src/profile.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Deploy profile — the typed "stack" (ADR-026 §7): one value that supplies -//! every environment-shaped knob, so a deploy crate branches on the profile -//! instead of carrying a flag soup. Two real instances (local vs prod) -//! justify the type (Rule of Three). - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] -pub enum Profile { - /// Local k3d: single writer, ReadWriteOnce, plain HTTP. - Local, - /// A tenant cluster: replicated, RWX shared storage, TLS. - Prod, -} - -impl Profile { - pub fn replicas(self) -> i32 { - match self { - Profile::Local => 1, - Profile::Prod => 2, - } - } - - /// Prod rolls (old+new pods coexist → needs RWX); Local recreates one - /// pod at a time (RWO, safe for single-writer stores like sqlite). - pub fn rolling(self) -> bool { - self == Profile::Prod - } - - pub fn storage_class(self) -> String { - match self { - Profile::Local => "local-path", - Profile::Prod => "cephfs", - } - .to_string() - } - - /// Prod terminates TLS via cert-manager; Local serves plain HTTP. - pub fn cluster_issuer(self) -> Option { - (self == Profile::Prod).then(|| "letsencrypt-prod".to_string()) - } -} diff --git a/harmony-k8s/src/pod.rs b/harmony-k8s/src/pod.rs index c8f5b239..8981d943 100644 --- a/harmony-k8s/src/pod.rs +++ b/harmony-k8s/src/pod.rs @@ -3,7 +3,7 @@ use std::time::Duration; use k8s_openapi::api::core::v1::Pod; use kube::{ Error, - api::{Api, AttachParams, ListParams}, + api::{Api, AttachParams, ListParams, LogParams}, error::DiscoveryError, runtime::reflector::Lookup, }; @@ -32,6 +32,24 @@ impl ExecOutput { } impl K8sClient { + /// Recent logs for a pod. `tail_lines` bounds the output (None = all). + pub async fn pod_logs( + &self, + namespace: &str, + pod: &str, + tail_lines: Option, + ) -> Result { + let api: Api = Api::namespaced(self.client.clone(), namespace); + api.logs( + pod, + &LogParams { + tail_lines, + ..Default::default() + }, + ) + .await + } + pub async fn get_pod(&self, name: &str, namespace: Option<&str>) -> Result, Error> { let api: Api = match namespace { Some(ns) => Api::namespaced(self.client.clone(), ns), diff --git a/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs b/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs index 1f74b426..eaaf6d26 100644 --- a/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs +++ b/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs @@ -1139,6 +1139,22 @@ pub struct K8sAnywhereConfig { } impl K8sAnywhereConfig { + /// Talk to an existing cluster via an explicit kubeconfig file — no env + /// vars, no local-k3d management. For programmatic callers (the app/UX + /// layer, TUI, web) that resolve the target themselves and must not + /// mutate process-global env to select a cluster. + pub fn kubeconfig(path: impl Into, k8s_context: Option) -> Self { + Self { + kubeconfig: Some(path.into()), + use_system_kubeconfig: false, + autoinstall: false, + use_local_k3d: false, + harmony_profile: "dev".to_string(), + k8s_context, + public_domain: None, + } + } + /// Reads an environment variable `env_var` and parses its content : /// Comma-separated `key=value` pairs, e.g., /// `kubeconfig=/path/to/primary.kubeconfig,context=primary-ctx` -- 2.39.5 From 7af236c74a26576fe6e09712f20b8c326ff8d08f Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 04:45:16 -0400 Subject: [PATCH 06/20] =?UTF-8?q?feat(app):=20harmony=5Fapp=20=E2=80=94=20?= =?UTF-8?q?UI-agnostic=20application=20lifecycle=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home the modules::application feature was missing (ADR-026): a HarmonyApp describes identity + how to build its Scores per context; ship/deploy/status/ logs are verbs returning structured results, never printed. CLI/TUI/web are front-ends over these verbs — no UI coupling. AppContext is the selected target+profile; converges the same Scores wherever it points. --- Cargo.lock | 18 ++++ Cargo.toml | 1 + harmony_app/Cargo.toml | 17 ++++ harmony_app/src/app.rs | 163 +++++++++++++++++++++++++++++++++++++ harmony_app/src/context.rs | 118 +++++++++++++++++++++++++++ harmony_app/src/lib.rs | 26 ++++++ harmony_app/src/profile.rs | 13 +++ 7 files changed, 356 insertions(+) create mode 100644 harmony_app/Cargo.toml create mode 100644 harmony_app/src/app.rs create mode 100644 harmony_app/src/context.rs create mode 100644 harmony_app/src/lib.rs create mode 100644 harmony_app/src/profile.rs diff --git a/Cargo.lock b/Cargo.lock index 28b6b1bf..8b23027d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2837,6 +2837,7 @@ dependencies = [ "env_logger", "fqdn", "harmony", + "harmony_app", "harmony_cli", "harmony_types", "k8s-openapi", @@ -4325,6 +4326,21 @@ dependencies = [ "url", ] +[[package]] +name = "harmony_app" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "harmony", + "harmony-k8s", + "k8s-openapi", + "log", + "serde", + "tempfile", + "tokio", +] + [[package]] name = "harmony_assets" version = "0.1.0" @@ -4355,11 +4371,13 @@ dependencies = [ name = "harmony_cli" version = "0.1.0" dependencies = [ + "anyhow", "assert_cmd", "async-trait", "clap", "console", "harmony", + "harmony_app", "harmony_tui", "indicatif", "inquire 0.7.5", diff --git a/Cargo.toml b/Cargo.toml index f425d87f..19b6987c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "harmony_agent/deploy", "harmony_node_readiness", "harmony-k8s", + "harmony_app", "harmony_assets", "opnsense-codegen", "opnsense-api", "fleet/harmony-fleet-operator", "fleet/harmony-fleet-agent", diff --git a/harmony_app/Cargo.toml b/harmony_app/Cargo.toml new file mode 100644 index 00000000..d1b08778 --- /dev/null +++ b/harmony_app/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "harmony_app" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true + +[dependencies] +harmony = { path = "../harmony" } +harmony-k8s = { path = "../harmony-k8s" } +async-trait.workspace = true +anyhow.workspace = true +tokio.workspace = true +serde = { workspace = true } +tempfile.workspace = true +log.workspace = true +k8s-openapi.workspace = true diff --git a/harmony_app/src/app.rs b/harmony_app/src/app.rs new file mode 100644 index 00000000..38b65668 --- /dev/null +++ b/harmony_app/src/app.rs @@ -0,0 +1,163 @@ +//! The [`HarmonyApp`] trait and the lifecycle **verbs** as plain async +//! functions returning structured results — the UI-agnostic core every +//! front-end (CLI, TUI, web) drives. + +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use harmony::inventory::Inventory; +use harmony::maestro::Maestro; +use harmony::score::Score; +use harmony::topology::K8sAnywhereTopology; +use k8s_openapi::api::apps::v1::Deployment; +use k8s_openapi::api::core::v1::Pod; + +use crate::context::AppContext; + +/// Identity-only description of an app (ADR-026 §3): what it's called and +/// where it runs. No behavioral knobs — those come from the context/profile. +#[derive(Debug, Clone)] +pub struct AppIdentity { + pub name: String, + pub namespace: String, +} + +/// A deployable application. An app crate implements this once; every UI +/// drives it through the verbs below. The topology is fixed to +/// `K8sAnywhereTopology` for now (the common app target); generalizing over +/// `Topology` is a later step. +#[async_trait] +pub trait HarmonyApp: Send + Sync { + fn identity(&self) -> AppIdentity; + + /// The desired state for this context — the app composes its Scores, + /// branching on `ctx.profile()`. Called by `deploy`/`ship`. + async fn scores(&self, ctx: &AppContext) -> Result>>>; + + /// Build + distribute images for `ctx` (push to a registry, or import to + /// k3d). Default: nothing to build. Called by `ship`. + async fn publish(&self, _ctx: &AppContext) -> Result<()> { + Ok(()) + } +} + +// ---- structured results (rendered by the front-end, never printed here) ---- + +#[derive(Debug, Clone, serde::Serialize)] +pub struct StepOutcome { + pub name: String, + pub message: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct DeployReport { + pub steps: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct WorkloadStatus { + pub name: String, + pub ready: i32, + pub desired: i32, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct StatusReport { + pub namespace: String, + pub workloads: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PodLogs { + pub pod: String, + pub logs: String, +} + +// ---- the verbs ---- + +/// Converge the app's Scores onto the context's cluster (no build). +pub async fn deploy(app: &dyn HarmonyApp, ctx: &AppContext) -> Result { + let scores = app.scores(ctx).await?; + let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), ctx.topology()); + maestro.register_all(scores); + maestro + .prepare_topology() + .await + .map_err(|e| anyhow!("topology preparation failed: {e}"))?; + + let to_run: Vec>> = { + let scores = maestro.scores(); + let guard = scores.read().map_err(|_| anyhow!("scores lock poisoned"))?; + guard.iter().map(|s| s.clone_box()).collect() + }; + + let mut steps = Vec::new(); + for s in to_run { + let name = s.name(); + let outcome = maestro + .interpret(s) + .await + .map_err(|e| anyhow!("{name}: {e}"))?; + steps.push(StepOutcome { + name, + message: outcome.message, + }); + } + Ok(DeployReport { steps }) +} + +/// Build + publish, then deploy (ADR-026 §4). +pub async fn ship(app: &dyn HarmonyApp, ctx: &AppContext) -> Result { + app.publish(ctx).await?; + deploy(app, ctx).await +} + +/// Workload readiness in the app's namespace (operational, read-only). +pub async fn status(app: &dyn HarmonyApp, ctx: &AppContext) -> Result { + let id = app.identity(); + let client = ctx.k8s_client().await?; + let deployments = client + .list_resources::(Some(&id.namespace), None) + .await + .map_err(|e| anyhow!("listing deployments: {e}"))?; + let workloads = deployments + .items + .iter() + .map(|d| WorkloadStatus { + name: d.metadata.name.clone().unwrap_or_default(), + desired: d.spec.as_ref().and_then(|s| s.replicas).unwrap_or(0), + ready: d + .status + .as_ref() + .and_then(|s| s.ready_replicas) + .unwrap_or(0), + }) + .collect(); + Ok(StatusReport { + namespace: id.namespace, + workloads, + }) +} + +/// Recent logs from each pod in the app's namespace (operational, read-only). +pub async fn logs( + app: &dyn HarmonyApp, + ctx: &AppContext, + tail: Option, +) -> Result> { + let id = app.identity(); + let client = ctx.k8s_client().await?; + let pods = client + .list_resources::(Some(&id.namespace), None) + .await + .map_err(|e| anyhow!("listing pods: {e}"))?; + let mut out = Vec::new(); + for p in pods.items { + let pod = p.metadata.name.unwrap_or_default(); + let logs = client + .pod_logs(&id.namespace, &pod, tail) + .await + .unwrap_or_else(|e| format!("")); + out.push(PodLogs { pod, logs }); + } + Ok(out) +} diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs new file mode 100644 index 00000000..fb8ceebb --- /dev/null +++ b/harmony_app/src/context.rs @@ -0,0 +1,118 @@ +//! [`AppContext`] — the selected deploy target (ADR-026 §1/§10): a profile +//! plus enough to reach a cluster, resolved *without* mutating process-global +//! env (so a TUI/web can hold several at once). MVP resolves a built-in +//! `local` context against a k3d cluster; named remote contexts (a context +//! store + credential broker) are the next slice. + +use std::ffi::OsString; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology}; +use harmony_k8s::K8sClient; +use tempfile::NamedTempFile; + +use crate::profile::Profile; + +pub struct AppContext { + name: String, + profile: Profile, + version: String, + local_config_dir: Option, + k3d_cluster: Option, + /// Resolved cluster credential. Kept alive: the kubeconfig file must + /// outlive every client/topology built from its path. + kubeconfig: PathBuf, + _kubeconfig_guard: Option, +} + +impl AppContext { + /// Resolve a context by name. MVP: `local` → the given k3d cluster. + pub fn resolve( + name: &str, + version: impl Into, + local_config_dir: Option, + k3d_cluster: Option, + ) -> Result { + match name { + "local" => { + let cluster = k3d_cluster + .clone() + .context("the 'local' context needs a k3d cluster (--k3d-cluster)")?; + let guard = k3d_kubeconfig(&cluster)?; + Ok(Self { + name: name.to_string(), + profile: Profile::Local, + version: version.into(), + local_config_dir, + k3d_cluster, + kubeconfig: guard.path().to_path_buf(), + _kubeconfig_guard: Some(guard), + }) + } + other => bail!( + "context '{other}' is not configured (MVP supports only 'local'; \ + a context store + remote credential broker is the next slice)" + ), + } + } + + pub fn name(&self) -> &str { + &self.name + } + pub fn profile(&self) -> Profile { + self.profile + } + pub fn version(&self) -> &str { + &self.version + } + pub fn local_config_dir(&self) -> Option<&Path> { + self.local_config_dir.as_deref() + } + pub fn k3d_cluster(&self) -> Option<&str> { + self.k3d_cluster.as_deref() + } + + /// The converge target — built from the context's kubeconfig, no env. + pub fn topology(&self) -> K8sAnywhereTopology { + K8sAnywhereTopology::with_config(K8sAnywhereConfig::kubeconfig( + self.kubeconfig.to_string_lossy().to_string(), + None, + )) + } + + /// A read client for operational verbs (status/logs) — no topology prep. + pub async fn k8s_client(&self) -> Result { + K8sClient::from_kubeconfig(&self.kubeconfig.to_string_lossy()) + .await + .context("building k8s client from the context kubeconfig") + } +} + +/// Write the k3d cluster's kubeconfig to a temp file. Prefers harmony's +/// managed k3d binary, falling back to a `k3d` on PATH. +fn k3d_kubeconfig(cluster: &str) -> Result { + let managed = harmony::config::HARMONY_DATA_DIR.join("k3d").join("k3d"); + let k3d: OsString = if managed.exists() { + managed.into_os_string() + } else { + "k3d".into() + }; + let out = Command::new(&k3d) + .args(["kubeconfig", "get", cluster]) + .output() + .context("spawn k3d (installed and on PATH?)")?; + if !out.status.success() { + bail!( + "k3d kubeconfig get {cluster} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + let mut file = + NamedTempFile::with_prefix("harmony-ctx-").context("create kubeconfig tempfile")?; + file.write_all(&out.stdout).context("write kubeconfig")?; + file.flush().context("flush kubeconfig")?; + Ok(file) +} diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs new file mode 100644 index 00000000..d17def4b --- /dev/null +++ b/harmony_app/src/lib.rs @@ -0,0 +1,26 @@ +//! The Harmony **application** layer — the lifecycle of a deployable app +//! (build, publish, deploy, ship, status, logs) expressed once, +//! **independent of any UI** (ADR-026). +//! +//! This is the home the old `modules::application` feature was looking for: +//! not a Score, and not bound to the CLI. A [`HarmonyApp`] describes *what an +//! app is* (identity + how to build its Scores for a given context); the +//! free functions [`ship`]/[`deploy`]/[`status`]/[`logs`] are the verbs, +//! and they return **structured results**, never printed output. The CLI, +//! a future TUI, and a web UI are all just front-ends that call these verbs +//! and render the results — none of them owns the logic. +//! +//! A [`Context`](context::AppContext) is the selected target + profile +//! (ADR-026 §1/§10): the verbs converge the *same* Scores wherever the +//! context points; only the context changes between local and prod. + +pub mod app; +pub mod context; +pub mod profile; + +pub use app::{ + AppIdentity, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, WorkloadStatus, + deploy, logs, ship, status, +}; +pub use context::AppContext; +pub use profile::Profile; diff --git a/harmony_app/src/profile.rs b/harmony_app/src/profile.rs new file mode 100644 index 00000000..4edcce3f --- /dev/null +++ b/harmony_app/src/profile.rs @@ -0,0 +1,13 @@ +//! The deploy profile — the typed environment tag a [`Context`] carries +//! (ADR-026 §7). The app's `scores()` branches on it; the *meaning* of each +//! profile (storage class, replicas, TLS, …) lives in the app/deploy code, +//! not here, so this stays a small generic tag. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Profile { + /// Local k3d: single writer, ReadWriteOnce, plain HTTP. + Local, + /// A tenant cluster: replicated, RWX shared storage, TLS. + Prod, +} -- 2.39.5 From c5280dbf0455f297787937c74d0afba2a986bee8 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 04:45:16 -0400 Subject: [PATCH 07/20] =?UTF-8?q?feat(cli):=20app=5Fmain=20=E2=80=94=20ver?= =?UTF-8?q?b=20front-end=20(harmony=20app=20)=20over=20harmony=5Fapp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses argv, resolves a context (required, no default per ADR-026 §1), calls the harmony_app verbs, renders. A per-app binary becomes: fn main() { app_main(MyApp) }. --- harmony_cli/Cargo.toml | 2 + harmony_cli/src/app.rs | 94 ++++++++++++++++++++++++++++++++++++++++++ harmony_cli/src/lib.rs | 1 + 3 files changed, 97 insertions(+) create mode 100644 harmony_cli/src/app.rs diff --git a/harmony_cli/Cargo.toml b/harmony_cli/Cargo.toml index 1493df19..9bc6b3d4 100644 --- a/harmony_cli/Cargo.toml +++ b/harmony_cli/Cargo.toml @@ -11,8 +11,10 @@ tui = ["dep:harmony_tui"] [dependencies] assert_cmd = "2.0.17" +anyhow = { workspace = true } clap = { version = "4.5.35", features = ["derive"] } harmony = { path = "../harmony" } +harmony_app = { path = "../harmony_app" } harmony_tui = { path = "../harmony_tui", optional = true } inquire.workspace = true tokio.workspace = true diff --git a/harmony_cli/src/app.rs b/harmony_cli/src/app.rs new file mode 100644 index 00000000..9617019c --- /dev/null +++ b/harmony_cli/src/app.rs @@ -0,0 +1,94 @@ +//! `app_main` — the **CLI front-end** for the app lifecycle (ADR-026 §11 +//! `harmony app `). It only parses argv, resolves a context, calls the +//! UI-agnostic verbs in [`harmony_app`], and renders the structured result. +//! A TUI or web UI is a sibling front-end over the same verbs — no logic +//! lives here. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use harmony_app::{AppContext, HarmonyApp}; + +#[derive(Parser, Debug)] +#[command(disable_version_flag = true)] +struct AppCli { + #[command(subcommand)] + verb: Verb, + + /// Target context — ADR-026 §1: required, no default (so you never + /// deploy to the wrong cluster by accident). + #[arg(long, global = true)] + context: Option, + + /// Release tag for build/publish/deploy. + #[arg(long, global = true, default_value = "0.1.0")] + version: String, + + /// Offline secret dir for a local context (`/.toml`). + #[arg(long, global = true)] + local_config: Option, + + /// k3d cluster backing a local context. + #[arg(long, global = true)] + k3d_cluster: Option, +} + +#[derive(Subcommand, Debug)] +enum Verb { + /// Build + publish + deploy. + Ship, + /// Converge the app's Scores (no build). + Deploy, + /// Workload readiness in the app's namespace. + Status, + /// Recent logs from the app's pods. + Logs { + #[arg(long, default_value_t = 50)] + tail: i64, + }, +} + +/// Entry point for a per-app deploy binary: `fn main() { app_main(MyApp) }`. +pub async fn app_main(app: A) -> Result<()> { + crate::cli_logger::init(); + crate::cli_reporter::init(); + let cli = AppCli::parse(); + + let context = cli + .context + .context("--context is required (ADR-026: no default context)")?; + let ctx = AppContext::resolve(&context, cli.version, cli.local_config, cli.k3d_cluster)?; + + match cli.verb { + Verb::Ship => render_deploy(harmony_app::ship(&app, &ctx).await?), + Verb::Deploy => render_deploy(harmony_app::deploy(&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?), + } + Ok(()) +} + +fn render_deploy(report: harmony_app::DeployReport) { + println!("\n🚀 Deployed:"); + for step in report.steps { + println!(" ✅ {} — {}", step.name, step.message); + } +} + +fn render_status(report: harmony_app::StatusReport) { + println!("\n📊 {} workloads:", report.namespace); + if report.workloads.is_empty() { + println!(" (none)"); + } + for w in report.workloads { + let mark = if w.ready == w.desired { "✅" } else { "⏳" }; + println!(" {mark} {} {}/{}", w.name, w.ready, w.desired); + } +} + +fn render_logs(pods: Vec) { + for p in pods { + println!("\n=== {} ===\n{}", p.pod, p.logs.trim_end()); + } +} diff --git a/harmony_cli/src/lib.rs b/harmony_cli/src/lib.rs index d1f6de33..69b66e59 100644 --- a/harmony_cli/src/lib.rs +++ b/harmony_cli/src/lib.rs @@ -7,6 +7,7 @@ use harmony::{score::Score, topology::Topology}; use inquire::Confirm; use tracing::debug; +pub mod app; pub mod cli_logger; // FIXME: Don't make me pub mod cli_reporter; pub mod progress; -- 2.39.5 From 7af6540a3a4bffdb5ab15429865e4e9c27e7d96c Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 04:45:16 -0400 Subject: [PATCH 08/20] refactor(examples/compose): use harmony_app::Profile; DeployConfig::for_profile Profile tag is now the shared harmony_app one; the k8s-knob mapping (storage/ replicas/RWX vs RWO/TLS) lives in DeployConfig::for_profile + cluster_issuer_for. --- examples/compose_java_react/Cargo.toml | 1 + examples/compose_java_react/src/chart.rs | 34 ++++++++++++++++++++++++ examples/compose_java_react/src/lib.rs | 5 ++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/examples/compose_java_react/Cargo.toml b/examples/compose_java_react/Cargo.toml index b6d5072f..1c3752d6 100644 --- a/examples/compose_java_react/Cargo.toml +++ b/examples/compose_java_react/Cargo.toml @@ -22,6 +22,7 @@ path = "src/bin/publish.rs" [dependencies] harmony = { path = "../../harmony" } +harmony_app = { path = "../../harmony_app" } harmony_cli = { path = "../../harmony_cli" } harmony_types = { path = "../../harmony_types" } diff --git a/examples/compose_java_react/src/chart.rs b/examples/compose_java_react/src/chart.rs index f17aeef4..03b0b92e 100644 --- a/examples/compose_java_react/src/chart.rs +++ b/examples/compose_java_react/src/chart.rs @@ -26,6 +26,7 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use harmony::modules::application::helm::{HelmChart, HelmResourceKind}; +use harmony_app::Profile; use crate::compose::{ComposeApp, ComposeService}; @@ -49,6 +50,39 @@ pub struct DeployConfig { pub rolling: bool, } +impl DeployConfig { + /// Map the generic [`Profile`] tag to this app's k8s knobs — prod + /// replicates on RWX `cephfs`; local is single-writer RWO `local-path`. + /// This is where "what a profile means" lives (ADR-026 §7), per app. + pub fn for_profile( + app_name: impl Into, + registry: impl Into, + project: impl Into, + version: impl Into, + profile: Profile, + ) -> Self { + let prod = profile == Profile::Prod; + let version = version.into(); + Self { + app_name: app_name.into(), + chart_version: version.clone(), + registry: registry.into(), + project: project.into(), + version, + storage_class: Some(if prod { "cephfs" } else { "local-path" }.to_string()), + volume_size: "1Gi".to_string(), + replicas: if prod { 2 } else { 1 }, + rolling: prod, + } + } +} + +/// cert-manager ClusterIssuer for a profile: prod terminates TLS, local +/// serves plain HTTP. +pub fn cluster_issuer_for(profile: Profile) -> Option { + (profile == Profile::Prod).then(|| "letsencrypt-prod".to_string()) +} + const ACCESS_MODE_RWX: &str = "ReadWriteMany"; const ACCESS_MODE_RWO: &str = "ReadWriteOnce"; diff --git a/examples/compose_java_react/src/lib.rs b/examples/compose_java_react/src/lib.rs index 978d90c3..cefc8cdc 100644 --- a/examples/compose_java_react/src/lib.rs +++ b/examples/compose_java_react/src/lib.rs @@ -6,13 +6,12 @@ pub mod chart; pub mod compose; -pub mod profile; pub mod publish; pub mod score; -pub use chart::{DeployConfig, service_image}; +pub use chart::{DeployConfig, cluster_issuer_for, service_image}; pub use compose::ComposeApp; -pub use profile::Profile; +pub use harmony_app::Profile; pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart}; use std::path::Path; -- 2.39.5 From d5a778a761f0aac1fc45c3b4191713a5dc8a961f Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 05:04:42 -0400 Subject: [PATCH 09/20] feat(app): in-repo context store + OpenBao-brokered cluster access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contexts are defined in .harmony/contexts.toml (found by walking up from cwd), so collaborators and CI need zero local setup. A context owns cluster access: k3d (local) or a kubeconfig brokered from OpenBao via harmony_config (Zitadel-authenticated, the CI/prod path) as a ClusterAccess secret — separate from the app's own secrets. --context required (ADR-026 §1). --- Cargo.lock | 3 + harmony_app/Cargo.toml | 3 + harmony_app/src/context.rs | 138 +++++++++++++++++++++++++++---------- harmony_app/src/lib.rs | 2 +- harmony_cli/src/app.rs | 6 +- 5 files changed, 109 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b23027d..e3373bcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4334,11 +4334,14 @@ dependencies = [ "async-trait", "harmony", "harmony-k8s", + "harmony_config", "k8s-openapi", "log", + "schemars 0.8.22", "serde", "tempfile", "tokio", + "toml", ] [[package]] diff --git a/harmony_app/Cargo.toml b/harmony_app/Cargo.toml index d1b08778..bd6e94e7 100644 --- a/harmony_app/Cargo.toml +++ b/harmony_app/Cargo.toml @@ -8,10 +8,13 @@ license.workspace = true [dependencies] harmony = { path = "../harmony" } harmony-k8s = { path = "../harmony-k8s" } +harmony_config = { path = "../harmony_config" } async-trait.workspace = true anyhow.workspace = true tokio.workspace = true serde = { workspace = true } +schemars = "0.8" tempfile.workspace = true +toml.workspace = true log.workspace = true k8s-openapi.workspace = true diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index fb8ceebb..1d8265ef 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -1,8 +1,10 @@ -//! [`AppContext`] — the selected deploy target (ADR-026 §1/§10): a profile -//! plus enough to reach a cluster, resolved *without* mutating process-global -//! env (so a TUI/web can hold several at once). MVP resolves a built-in -//! `local` context against a k3d cluster; named remote contexts (a context -//! store + credential broker) are the next slice. +//! [`AppContext`] — the selected deploy target (ADR-026 §1/§10). Contexts are +//! defined **in-repo** at `.harmony/contexts.toml` (found by walking up from +//! the cwd), so collaborators and CI need zero local setup. A context owns +//! *cluster access* — either a local k3d cluster, or a kubeconfig brokered +//! from OpenBao via `harmony_config` (Zitadel-authenticated, the CI path). +//! App *secrets* are separate (the app loads those); the context only says +//! how to reach the cluster. use std::ffi::OsString; use std::io::Write; @@ -11,52 +13,96 @@ use std::process::Command; use anyhow::{Context, Result, bail}; use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology}; +use harmony_config::{Config, ConfigClient}; use harmony_k8s::K8sClient; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use tempfile::NamedTempFile; use crate::profile::Profile; +/// `.harmony/contexts.toml`: `[contexts.]` blocks. +#[derive(Debug, Deserialize)] +struct ContextsFile { + contexts: std::collections::HashMap, +} + +/// One named context. Cluster access is exactly one of `k3d` (local) or +/// `openbao_namespace` (kubeconfig from OpenBao, CI/prod). +#[derive(Debug, Deserialize)] +struct ContextDef { + profile: Profile, + k3d: Option, + openbao_namespace: Option, +} + +/// Cluster credential brokered from OpenBao — its own secret +/// (`secret//ClusterAccess`), separate from the app's secrets, so +/// "how to reach the cluster" and "the app's config" stay distinct. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Config)] +pub struct ClusterAccess { + #[config(secret)] + pub kubeconfig: String, +} + pub struct AppContext { name: String, profile: Profile, version: String, local_config_dir: Option, k3d_cluster: Option, - /// Resolved cluster credential. Kept alive: the kubeconfig file must - /// outlive every client/topology built from its path. kubeconfig: PathBuf, - _kubeconfig_guard: Option, + /// The kubeconfig file must outlive every client/topology built from it. + _kubeconfig_guard: NamedTempFile, } impl AppContext { - /// Resolve a context by name. MVP: `local` → the given k3d cluster. - pub fn resolve( + /// Resolve a context by name from the in-repo `.harmony/contexts.toml`. + pub async fn resolve( name: &str, version: impl Into, local_config_dir: Option, - k3d_cluster: Option, ) -> Result { - match name { - "local" => { - let cluster = k3d_cluster - .clone() - .context("the 'local' context needs a k3d cluster (--k3d-cluster)")?; - let guard = k3d_kubeconfig(&cluster)?; - Ok(Self { - name: name.to_string(), - profile: Profile::Local, - version: version.into(), - local_config_dir, - k3d_cluster, - kubeconfig: guard.path().to_path_buf(), - _kubeconfig_guard: Some(guard), - }) + let path = find_contexts_file()?; + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("reading {}", path.display()))?; + let file: ContextsFile = + toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?; + let def = file.contexts.get(name).with_context(|| { + format!( + "context '{name}' not in {} (have: {})", + path.display(), + file.contexts.keys().cloned().collect::>().join(", ") + ) + })?; + + let guard = match (&def.k3d, &def.openbao_namespace) { + (Some(cluster), None) => k3d_kubeconfig(cluster)?, + (None, Some(ns)) => { + let access: ClusterAccess = ConfigClient::for_namespace(ns) + .await + .get() + .await + .with_context(|| { + format!( + "loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \ + — are the Zitadel key + OPENBAO_* env vars set?)" + ) + })?; + write_kubeconfig(access.kubeconfig.as_bytes())? } - other => bail!( - "context '{other}' is not configured (MVP supports only 'local'; \ - a context store + remote credential broker is the next slice)" - ), - } + _ => bail!("context '{name}' must set exactly one of `k3d` or `openbao_namespace`"), + }; + + Ok(Self { + name: name.to_string(), + profile: def.profile, + version: version.into(), + local_config_dir, + k3d_cluster: def.k3d.clone(), + kubeconfig: guard.path().to_path_buf(), + _kubeconfig_guard: guard, + }) } pub fn name(&self) -> &str { @@ -91,7 +137,29 @@ impl AppContext { } } -/// Write the k3d cluster's kubeconfig to a temp file. Prefers harmony's +/// Walk up from the cwd for `.harmony/contexts.toml` (git/cargo style). +fn find_contexts_file() -> Result { + let mut dir = std::env::current_dir().context("current dir")?; + loop { + let candidate = dir.join(".harmony").join("contexts.toml"); + if candidate.is_file() { + return Ok(candidate); + } + if !dir.pop() { + bail!("no .harmony/contexts.toml found (searched up from the cwd)"); + } + } +} + +fn write_kubeconfig(contents: &[u8]) -> Result { + let mut file = + NamedTempFile::with_prefix("harmony-ctx-").context("create kubeconfig tempfile")?; + file.write_all(contents).context("write kubeconfig")?; + file.flush().context("flush kubeconfig")?; + Ok(file) +} + +/// The k3d cluster's kubeconfig, written to a temp file. Prefers harmony's /// managed k3d binary, falling back to a `k3d` on PATH. fn k3d_kubeconfig(cluster: &str) -> Result { let managed = harmony::config::HARMONY_DATA_DIR.join("k3d").join("k3d"); @@ -110,9 +178,5 @@ fn k3d_kubeconfig(cluster: &str) -> Result { String::from_utf8_lossy(&out.stderr) ); } - let mut file = - NamedTempFile::with_prefix("harmony-ctx-").context("create kubeconfig tempfile")?; - file.write_all(&out.stdout).context("write kubeconfig")?; - file.flush().context("flush kubeconfig")?; - Ok(file) + write_kubeconfig(&out.stdout) } diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index d17def4b..5bb35711 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -22,5 +22,5 @@ pub use app::{ AppIdentity, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, WorkloadStatus, deploy, logs, ship, status, }; -pub use context::AppContext; +pub use context::{AppContext, ClusterAccess}; pub use profile::Profile; diff --git a/harmony_cli/src/app.rs b/harmony_cli/src/app.rs index 9617019c..853c648f 100644 --- a/harmony_cli/src/app.rs +++ b/harmony_cli/src/app.rs @@ -28,10 +28,6 @@ struct AppCli { /// Offline secret dir for a local context (`/.toml`). #[arg(long, global = true)] local_config: Option, - - /// k3d cluster backing a local context. - #[arg(long, global = true)] - k3d_cluster: Option, } #[derive(Subcommand, Debug)] @@ -58,7 +54,7 @@ pub async fn app_main(app: A) -> Result<()> { let context = cli .context .context("--context is required (ADR-026: no default context)")?; - let ctx = AppContext::resolve(&context, cli.version, cli.local_config, cli.k3d_cluster)?; + let ctx = AppContext::resolve(&context, cli.version, cli.local_config).await?; match cli.verb { Verb::Ship => render_deploy(harmony_app::ship(&app, &ctx).await?), -- 2.39.5 From 67c11682bf6199c3e392fdd47fe431ca8fa42d5d Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 05:24:24 -0400 Subject: [PATCH 10/20] fix(cli): rename --version to --tag; drop ADR jargon from help/errors; simplify deploy --version no longer hijacked (it prints version again); the release tag is --tag. User-facing help/errors no longer cite ADRs. deploy() clones scores before register instead of reading them back through a lock. --- harmony_app/src/app.rs | 9 +++------ harmony_cli/src/app.rs | 17 +++++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/harmony_app/src/app.rs b/harmony_app/src/app.rs index 38b65668..dde4f644 100644 --- a/harmony_app/src/app.rs +++ b/harmony_app/src/app.rs @@ -77,6 +77,9 @@ pub struct PodLogs { /// Converge the app's Scores onto the context's cluster (no build). pub async fn deploy(app: &dyn HarmonyApp, ctx: &AppContext) -> Result { let scores = app.scores(ctx).await?; + let to_run: Vec>> = + scores.iter().map(|s| s.clone_box()).collect(); + let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), ctx.topology()); maestro.register_all(scores); maestro @@ -84,12 +87,6 @@ pub async fn deploy(app: &dyn HarmonyApp, ctx: &AppContext) -> Result>> = { - let scores = maestro.scores(); - let guard = scores.read().map_err(|_| anyhow!("scores lock poisoned"))?; - guard.iter().map(|s| s.clone_box()).collect() - }; - let mut steps = Vec::new(); for s in to_run { let name = s.name(); diff --git a/harmony_cli/src/app.rs b/harmony_cli/src/app.rs index 853c648f..e3d9bddf 100644 --- a/harmony_cli/src/app.rs +++ b/harmony_cli/src/app.rs @@ -11,21 +11,22 @@ use clap::{Parser, Subcommand}; use harmony_app::{AppContext, HarmonyApp}; #[derive(Parser, Debug)] -#[command(disable_version_flag = true)] +#[command(version)] struct AppCli { #[command(subcommand)] verb: Verb, - /// Target context — ADR-026 §1: required, no default (so you never - /// deploy to the wrong cluster by accident). + /// Target cluster context (required; there is no default, so you never + /// deploy to the wrong cluster). Defined in `.harmony/contexts.toml`. #[arg(long, global = true)] context: Option, - /// Release tag for build/publish/deploy. + /// Release tag for the images + chart (build/publish/deploy). #[arg(long, global = true, default_value = "0.1.0")] - version: String, + tag: String, - /// Offline secret dir for a local context (`/.toml`). + /// Directory holding a local context's offline secrets file + /// (`/DeploySecrets.toml`). #[arg(long, global = true)] local_config: Option, } @@ -53,8 +54,8 @@ pub async fn app_main(app: A) -> Result<()> { let context = cli .context - .context("--context is required (ADR-026: no default context)")?; - let ctx = AppContext::resolve(&context, cli.version, cli.local_config).await?; + .context("--context is required (there is no default context)")?; + let ctx = AppContext::resolve(&context, cli.tag, cli.local_config).await?; match cli.verb { Verb::Ship => render_deploy(harmony_app::ship(&app, &ctx).await?), -- 2.39.5 From e9539571d8b22520812032b6eb576330a5692477 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 05:46:21 -0400 Subject: [PATCH 11/20] feat(examples/compose): declarative ComposeDeploy authoring (manifest-style DX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare an app fluently — from_dir().expose().secret() — and it implements HarmonyApp, so app_main gives ship/deploy/status/logs over any context. The example's main is now ~20 lines of pure declaration; the flag soup is gone. TDD: 4 tests pin the spec→Score derivation (local renders/RWO/HTTP, prod publishes/replicated/TLS, defaults, secrets). Verified end-to-end on k3d. --- .../compose_java_react/.harmony/contexts.toml | 6 + examples/compose_java_react/src/deploy.rs | 218 ++++++++++++++++++ examples/compose_java_react/src/lib.rs | 2 + examples/compose_java_react/src/main.rs | 130 ++--------- 4 files changed, 244 insertions(+), 112 deletions(-) create mode 100644 examples/compose_java_react/.harmony/contexts.toml create mode 100644 examples/compose_java_react/src/deploy.rs diff --git a/examples/compose_java_react/.harmony/contexts.toml b/examples/compose_java_react/.harmony/contexts.toml new file mode 100644 index 00000000..f1ae2082 --- /dev/null +++ b/examples/compose_java_react/.harmony/contexts.toml @@ -0,0 +1,6 @@ +# Deploy contexts for this example (checked in — zero config). +# k3d cluster create compose-local && kubectl create ns timesheet +# compose-deploy ship --context local +[contexts.local] +profile = "local" +k3d = "compose-local" diff --git a/examples/compose_java_react/src/deploy.rs b/examples/compose_java_react/src/deploy.rs new file mode 100644 index 00000000..11473ace --- /dev/null +++ b/examples/compose_java_react/src/deploy.rs @@ -0,0 +1,218 @@ +//! [`ComposeDeploy`] — declarative authoring for a compose app. You *declare* +//! intent (import compose, expose a service, name a profile's worth of knobs) +//! and it implements [`HarmonyApp`], so `app_main` gives you +//! ship/deploy/status/logs over any context. The mature foundation +//! (composable Scores, contexts, profiles) with manifest-style DX. + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use harmony::score::Score; +use harmony::topology::K8sAnywhereTopology; +use harmony_app::{AppContext, AppIdentity, HarmonyApp, Profile}; + +use crate::chart::{DeployConfig, cluster_issuer_for}; +use crate::compose::ComposeApp; +use crate::score::{ComposeAppScore, PublicEndpoint, PublishedChart}; + +struct Endpoint { + service: String, + host: String, +} + +/// A compose app, declared. Build it fluently, then hand it to `app_main`. +pub struct ComposeDeploy { + name: String, + namespace: String, + project: String, + registry: String, + app: ComposeApp, + expose: Option, + app_secrets: BTreeMap, +} + +impl ComposeDeploy { + /// Import the app from a compose dir. `namespace`/`project` default to the + /// name; override fluently. + pub fn from_dir(name: impl Into, dir: impl AsRef) -> Result { + let app = ComposeApp::from_dir(dir.as_ref()).map_err(|e| e.to_string())?; + Ok(Self::from_compose(name, app)) + } + + pub fn from_compose(name: impl Into, app: ComposeApp) -> Self { + let name = name.into(); + Self { + namespace: name.clone(), + project: name.clone(), + registry: "localhost".to_string(), + name, + app, + expose: None, + app_secrets: BTreeMap::new(), + } + } + + pub fn namespace(mut self, ns: impl Into) -> Self { + self.namespace = ns.into(); + self + } + pub fn project(mut self, p: impl Into) -> Self { + self.project = p.into(); + self + } + pub fn registry(mut self, r: impl Into) -> Self { + self.registry = r.into(); + self + } + /// Route a compose service through an Ingress (the port comes from compose). + pub fn expose(mut self, service: impl Into, host: impl Into) -> Self { + self.expose = Some(Endpoint { + service: service.into(), + host: host.into(), + }); + self + } + pub fn secret(mut self, key: impl Into, value: impl Into) -> Self { + self.app_secrets.insert(key.into(), value.into()); + self + } + + fn deploy_config(&self, profile: Profile, version: &str) -> DeployConfig { + DeployConfig::for_profile(&self.name, &self.registry, &self.project, version, profile) + } + + /// Derive the deploy Score for a profile (pure — the testable core). + pub fn score(&self, profile: Profile, version: &str) -> Result { + let public_endpoint = match &self.expose { + Some(e) => Some(PublicEndpoint::from_compose( + &self.app, + &e.service, + &e.host, + cluster_issuer_for(profile), + )?), + None => None, + }; + Ok(ComposeAppScore { + namespace: self.namespace.clone(), + release_name: self.name.clone(), + // Prod installs the published OCI chart; local renders from compose. + published_chart: (profile == Profile::Prod).then(|| PublishedChart { + registry: self.registry.clone(), + project: self.project.clone(), + version: version.to_string(), + }), + public_endpoint, + app: self.app.clone(), + deploy: self.deploy_config(profile, version), + app_secrets: self.app_secrets.clone(), + }) + } +} + +#[async_trait] +impl HarmonyApp for ComposeDeploy { + fn identity(&self) -> AppIdentity { + AppIdentity { + name: self.name.clone(), + namespace: self.namespace.clone(), + } + } + + async fn scores(&self, ctx: &AppContext) -> Result>>> { + let score = self + .score(ctx.profile(), ctx.version()) + .map_err(|e| anyhow!(e))?; + Ok(vec![Box::new(score)]) + } + + async fn publish(&self, ctx: &AppContext) -> Result<()> { + use crate::publish::{PublishTarget, publish}; + let target = match ctx.profile() { + Profile::Local => PublishTarget::K3dImport { + cluster: ctx.k3d_cluster().unwrap_or(&self.name).to_string(), + }, + // Push needs registry creds; for this example they come from env. + // (A prod app would load them from its secret store.) + Profile::Prod => PublishTarget::Registry { + user: std::env::var("REGISTRY_USER") + .map_err(|_| anyhow!("REGISTRY_USER required to push"))?, + token: std::env::var("REGISTRY_TOKEN") + .map_err(|_| anyhow!("REGISTRY_TOKEN required to push"))?, + }, + }; + publish( + &self.app, + &self.deploy_config(ctx.profile(), ctx.version()), + &target, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> ComposeApp { + ComposeApp::parse( + "name: t\nservices:\n frontend:\n build: .\n ports: [\"8081:80\"]\n", + Path::new("/p"), + ) + .unwrap() + } + + #[test] + fn namespace_and_project_default_to_name() { + let s = ComposeDeploy::from_compose("timesheet", fixture()) + .score(Profile::Local, "0.1.0") + .unwrap(); + assert_eq!(s.namespace, "timesheet"); + assert_eq!(s.release_name, "timesheet"); + } + + #[test] + fn local_profile_renders_locally_rwo_http() { + let s = ComposeDeploy::from_compose("ts", fixture()) + .expose("frontend", "ts.local") + .score(Profile::Local, "1.0.0") + .unwrap(); + assert!(s.published_chart.is_none(), "local renders from compose"); + assert_eq!(s.deploy.replicas, 1); + assert!(!s.deploy.rolling); + let ep = s.public_endpoint.unwrap(); + assert_eq!(ep.service, "frontend"); + assert_eq!(ep.host, "ts.local"); + assert_eq!(ep.port, 80, "port read from compose"); + assert!(ep.cluster_issuer.is_none(), "local = plain HTTP"); + } + + #[test] + fn prod_profile_publishes_replicated_tls() { + let s = ComposeDeploy::from_compose("ts", fixture()) + .registry("hub.x") + .project("p") + .expose("frontend", "ts.x") + .score(Profile::Prod, "2.0.0") + .unwrap(); + let pc = s.published_chart.expect("prod installs published chart"); + assert_eq!(pc.registry, "hub.x"); + assert_eq!(pc.project, "p"); + assert_eq!(pc.version, "2.0.0"); + assert_eq!(s.deploy.replicas, 2); + assert!(s.deploy.rolling); + assert_eq!( + s.public_endpoint.unwrap().cluster_issuer.as_deref(), + Some("letsencrypt-prod") + ); + } + + #[test] + fn secrets_are_carried_to_the_score() { + let s = ComposeDeploy::from_compose("ts", fixture()) + .secret("DB_PASSWORD", "dev") + .score(Profile::Local, "0.1.0") + .unwrap(); + assert_eq!(s.app_secrets.get("DB_PASSWORD").unwrap(), "dev"); + } +} diff --git a/examples/compose_java_react/src/lib.rs b/examples/compose_java_react/src/lib.rs index cefc8cdc..38501671 100644 --- a/examples/compose_java_react/src/lib.rs +++ b/examples/compose_java_react/src/lib.rs @@ -6,11 +6,13 @@ pub mod chart; pub mod compose; +pub mod deploy; pub mod publish; pub mod score; pub use chart::{DeployConfig, cluster_issuer_for, service_image}; pub use compose::ComposeApp; +pub use deploy::ComposeDeploy; pub use harmony_app::Profile; pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart}; diff --git a/examples/compose_java_react/src/main.rs b/examples/compose_java_react/src/main.rs index 480ffad3..2c5178a0 100644 --- a/examples/compose_java_react/src/main.rs +++ b/examples/compose_java_react/src/main.rs @@ -1,117 +1,23 @@ -//! `compose-deploy` — converge the compose-imported app onto a cluster -//! (`helm upgrade --install`). The same Scores run against local k3d and -//! a remote tenant; only the topology (selected by env) changes. +//! `compose-deploy` — deploy this Java+React app to any context. +//! +//! The whole app, declared: +//! +//! compose-deploy ship --context local # build + import to k3d + deploy +//! compose-deploy status --context local +//! compose-deploy logs --context local +//! +//! Contexts live in `.harmony/contexts.toml`. `ship`/`deploy`/`status`/`logs` +//! and the context model all come from `harmony_app` — this file only +//! *declares* the app. -use std::path::PathBuf; - -use anyhow::{Context, Result}; -use clap::Parser; -use harmony::inventory::Inventory; -use harmony::topology::K8sAnywhereTopology; -use harmony_cli::Args as HarmonyCliArgs; - -use example_compose_java_react::{ - ComposeApp, ComposeAppScore, DeployConfig, HarmonyManifest, PublicEndpoint, PublishedChart, -}; - -#[derive(Parser, Debug)] -#[command( - name = "compose-deploy", - about = "Deploy a docker-compose app to k8s via Harmony" -)] -struct Cli { - /// Project root holding `Harmony.toml` (defaults to this example). - #[arg(long, default_value = env!("CARGO_MANIFEST_DIR"))] - project_dir: PathBuf, - /// Directory holding `docker-compose.yml` (defaults to `/app`). - #[arg(long)] - compose_dir: Option, - #[arg(long, default_value = "timesheet")] - namespace: String, - #[arg(long, default_value = "hub.nationtech.io")] - registry: String, - #[arg(long, default_value = "harmony")] - project: String, - #[arg(long, default_value = "0.1.0")] - version: String, - /// RWX storage class (e.g. ceph `cephfs`). Omit for the cluster default. - #[arg(long)] - storage_class: Option, - #[arg(long, default_value = "1Gi")] - volume_size: String, - #[arg(long, default_value_t = 2)] - replicas: i32, - /// Recreate instead of RollingUpdate — safe for single-writer sqlite. - #[arg(long)] - recreate: bool, - /// Install the published OCI chart instead of rendering from compose. - #[arg(long)] - published: bool, - /// Compose service to expose via Ingress. - #[arg(long, default_value = "frontend")] - public_service: String, - /// Ingress host; omit to keep the app cluster-internal. - #[arg(long)] - host: Option, - /// cert-manager ClusterIssuer for TLS; omit for plain HTTP. - #[arg(long)] - cluster_issuer: Option, - #[command(flatten)] - harmony_cli: HarmonyCliArgs, -} +use example_compose_java_react::ComposeDeploy; #[tokio::main] -async fn main() -> Result<()> { - harmony_cli::cli_logger::init(); - let cli = Cli::parse(); +async fn main() -> anyhow::Result<()> { + let app = ComposeDeploy::from_dir("timesheet", concat!(env!("CARGO_MANIFEST_DIR"), "/app")) + .map_err(|e| anyhow::anyhow!(e))? + .expose("frontend", "timesheet.example.harmony.mcd") + .secret("DB_PASSWORD", "changeme"); - let manifest = HarmonyManifest::from_dir(&cli.project_dir)?; - let compose_dir = cli - .compose_dir - .unwrap_or_else(|| cli.project_dir.join("app")); - let app = ComposeApp::from_dir(&compose_dir).context("importing docker-compose")?; - - let deploy = DeployConfig { - app_name: manifest.app.name.clone(), - chart_version: cli.version.clone(), - registry: cli.registry.clone(), - project: cli.project.clone(), - version: cli.version.clone(), - storage_class: cli.storage_class, - volume_size: cli.volume_size, - replicas: cli.replicas, - rolling: !cli.recreate, - }; - - // The host is a deploy-only knob; the port comes from compose. - let public_endpoint = match cli.host { - Some(host) => Some( - PublicEndpoint::from_compose(&app, &cli.public_service, host, cli.cluster_issuer) - .map_err(|e| anyhow::anyhow!(e))?, - ), - None => None, - }; - - let score = ComposeAppScore { - namespace: cli.namespace, - release_name: manifest.app.name, - published_chart: cli.published.then_some(PublishedChart { - registry: cli.registry, - project: cli.project, - version: cli.version, - }), - public_endpoint, - app, - deploy, - app_secrets: Default::default(), - }; - - harmony_cli::run( - Inventory::autoload(), - K8sAnywhereTopology::from_env(), - vec![Box::new(score)], - Some(cli.harmony_cli), - ) - .await - .map_err(|e| anyhow::anyhow!("{e}")) + harmony_cli::app::app_main(app).await } -- 2.39.5 From 716fb58c39d35da006745a18e5d36c5d3dd26734 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 06:21:31 -0400 Subject: [PATCH 12/20] =?UTF-8?q?feat(examples/compose):=20.with(Capabilit?= =?UTF-8?q?y)=20=E2=80=94=20composable=20add-ons,=20reference-wired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Capability contributes Scores + injects env wired by k8s reference (service DNS / secretKeyRef), so .with(Postgres::managed()) deploys a CNPG cluster and wires DATABASE_URL from its generated -db-app secret — the password never passes through Harmony, so the chart stays publishable. No change to the Score/Interpret/Outcome contract (typed outputs deferred until a non-reference value needs wiring). TDD: capability tests; verified end-to-end on k3d (cluster healthy, DATABASE_URL referenced, app pods ready). --- .../compose_java_react/src/bin/publish.rs | 1 + examples/compose_java_react/src/chart.rs | 10 +- examples/compose_java_react/src/deploy.rs | 143 +++++++++++++++++- examples/compose_java_react/src/lib.rs | 2 +- examples/compose_java_react/src/main.rs | 4 +- .../compose_java_react/tests/helm_render.rs | 1 + 6 files changed, 154 insertions(+), 7 deletions(-) diff --git a/examples/compose_java_react/src/bin/publish.rs b/examples/compose_java_react/src/bin/publish.rs index 779bd6c5..259fb667 100644 --- a/examples/compose_java_react/src/bin/publish.rs +++ b/examples/compose_java_react/src/bin/publish.rs @@ -57,6 +57,7 @@ fn main() -> Result<()> { volume_size: "1Gi".to_string(), replicas: 1, rolling: true, + extra_env: vec![], }; build_images(&app, &deploy)?; diff --git a/examples/compose_java_react/src/chart.rs b/examples/compose_java_react/src/chart.rs index 03b0b92e..8ed08347 100644 --- a/examples/compose_java_react/src/chart.rs +++ b/examples/compose_java_react/src/chart.rs @@ -48,6 +48,10 @@ pub struct DeployConfig { /// `true` → RollingUpdate (needs RWX so old+new pods share volumes); /// `false` → Recreate (safe for single-writer stores like sqlite). pub rolling: bool, + /// Extra env injected into every service container by capabilities — + /// wired by reference (e.g. a DB URL via `secretKeyRef`), never by value, + /// so the chart stays publishable. + pub extra_env: Vec, } impl DeployConfig { @@ -73,6 +77,7 @@ impl DeployConfig { volume_size: "1Gi".to_string(), replicas: if prod { 2 } else { 1 }, rolling: prod, + extra_env: Vec::new(), } } } @@ -172,7 +177,7 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment { }) .collect(); - let env: Vec = svc + let mut env: Vec = svc .env .iter() .map(|(k, v)| EnvVar { @@ -181,6 +186,8 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment { ..Default::default() }) .collect(); + // Capability-contributed env (e.g. a DB connection by `secretKeyRef`). + env.extend(cfg.extra_env.iter().cloned()); let volume_mounts: Vec = svc .mounts @@ -352,6 +359,7 @@ mod tests { volume_size: "2Gi".to_string(), replicas: 2, rolling: true, + extra_env: vec![], }; let tmp = tempfile::tempdir().unwrap(); build_chart(&app, &cfg, tmp.path()).unwrap(); diff --git a/examples/compose_java_react/src/deploy.rs b/examples/compose_java_react/src/deploy.rs index 11473ace..5e923f2c 100644 --- a/examples/compose_java_react/src/deploy.rs +++ b/examples/compose_java_react/src/deploy.rs @@ -9,9 +9,12 @@ use std::path::Path; use anyhow::{Result, anyhow}; use async_trait::async_trait; +use harmony::modules::postgresql::K8sPostgreSQLScore; +use harmony::modules::postgresql::capability::PostgreSQLConfig; use harmony::score::Score; use harmony::topology::K8sAnywhereTopology; use harmony_app::{AppContext, AppIdentity, HarmonyApp, Profile}; +use k8s_openapi::api::core::v1::{EnvVar, EnvVarSource, SecretKeySelector}; use crate::chart::{DeployConfig, cluster_issuer_for}; use crate::compose::ComposeApp; @@ -22,6 +25,28 @@ struct Endpoint { host: String, } +/// What a capability needs to know about the app it augments. +pub struct AppRef<'a> { + pub name: &'a str, + pub namespace: &'a str, + pub profile: Profile, +} + +/// An add-on a deployment can declare with `.with(...)` — it contributes its +/// own Scores (e.g. a database) and injects env into the app's containers, +/// **wired by k8s reference** (service DNS, `secretKeyRef`), never by value. +/// That is what lets `.with()` work today without typed Score outputs (see +/// the module-level note / ADR-026): the generated password lives only in a +/// cluster Secret the app references by name. +pub trait Capability: Send + Sync { + fn scores(&self, _app: &AppRef) -> Vec>> { + vec![] + } + fn env(&self, _app: &AppRef) -> Vec { + vec![] + } +} + /// A compose app, declared. Build it fluently, then hand it to `app_main`. pub struct ComposeDeploy { name: String, @@ -31,6 +56,7 @@ pub struct ComposeDeploy { app: ComposeApp, expose: Option, app_secrets: BTreeMap, + capabilities: Vec>, } impl ComposeDeploy { @@ -51,6 +77,7 @@ impl ComposeDeploy { app, expose: None, app_secrets: BTreeMap::new(), + capabilities: Vec::new(), } } @@ -79,6 +106,21 @@ impl ComposeDeploy { self } + /// Add a capability (e.g. `Postgres::managed()`): it deploys its own + /// Scores and wires itself into the app by reference. + pub fn with(mut self, capability: impl Capability + 'static) -> Self { + self.capabilities.push(Box::new(capability)); + self + } + + fn app_ref(&self, profile: Profile) -> AppRef<'_> { + AppRef { + name: &self.name, + namespace: &self.namespace, + profile, + } + } + fn deploy_config(&self, profile: Profile, version: &str) -> DeployConfig { DeployConfig::for_profile(&self.name, &self.registry, &self.project, version, profile) } @@ -94,6 +136,12 @@ impl ComposeDeploy { )?), None => None, }; + let mut deploy = self.deploy_config(profile, version); + deploy.extra_env = self + .capabilities + .iter() + .flat_map(|c| c.env(&self.app_ref(profile))) + .collect(); Ok(ComposeAppScore { namespace: self.namespace.clone(), release_name: self.name.clone(), @@ -105,7 +153,7 @@ impl ComposeDeploy { }), public_endpoint, app: self.app.clone(), - deploy: self.deploy_config(profile, version), + deploy, app_secrets: self.app_secrets.clone(), }) } @@ -121,10 +169,15 @@ impl HarmonyApp for ComposeDeploy { } async fn scores(&self, ctx: &AppContext) -> Result>>> { - let score = self + let app_score = self .score(ctx.profile(), ctx.version()) .map_err(|e| anyhow!(e))?; - Ok(vec![Box::new(score)]) + let mut scores: Vec>> = vec![Box::new(app_score)]; + let app_ref = self.app_ref(ctx.profile()); + for capability in &self.capabilities { + scores.extend(capability.scores(&app_ref)); + } + Ok(scores) } async fn publish(&self, ctx: &AppContext) -> Result<()> { @@ -150,6 +203,54 @@ impl HarmonyApp for ComposeDeploy { } } +/// A managed PostgreSQL database (CNPG). Deploys a cluster `-db` and +/// wires `DATABASE_URL` into the app from CNPG's generated `-db-app` +/// secret — **by reference**, so the password never passes through Harmony +/// and the published chart stays value-free. +pub struct Postgres { + instances: u32, +} + +impl Postgres { + pub fn managed() -> Self { + Self { instances: 1 } + } + pub fn instances(mut self, n: u32) -> Self { + self.instances = n; + self + } + fn cluster(app: &AppRef) -> String { + format!("{}-db", app.name) + } +} + +impl Capability for Postgres { + fn scores(&self, app: &AppRef) -> Vec>> { + let config = PostgreSQLConfig { + cluster_name: Self::cluster(app), + namespace: app.namespace.to_string(), + instances: self.instances, + ..Default::default() + }; + vec![Box::new(K8sPostgreSQLScore { config })] + } + + fn env(&self, app: &AppRef) -> Vec { + vec![EnvVar { + name: "DATABASE_URL".to_string(), + value_from: Some(EnvVarSource { + secret_key_ref: Some(SecretKeySelector { + name: format!("{}-app", Self::cluster(app)), + key: "uri".to_string(), + optional: Some(true), + }), + ..Default::default() + }), + ..Default::default() + }] + } +} + #[cfg(test)] mod tests { use super::*; @@ -215,4 +316,40 @@ mod tests { .unwrap(); assert_eq!(s.app_secrets.get("DB_PASSWORD").unwrap(), "dev"); } + + #[test] + fn postgres_capability_wires_database_url_by_reference() { + let s = ComposeDeploy::from_compose("ts", fixture()) + .with(Postgres::managed()) + .score(Profile::Local, "0.1.0") + .unwrap(); + let db = s + .deploy + .extra_env + .iter() + .find(|e| e.name == "DATABASE_URL") + .expect("DATABASE_URL injected"); + let sel = db + .value_from + .as_ref() + .unwrap() + .secret_key_ref + .as_ref() + .unwrap(); + assert_eq!(sel.name, "ts-db-app", "CNPG app secret, by convention"); + assert_eq!(sel.key, "uri"); + assert!(db.value.is_none(), "wired by reference, never a value"); + } + + #[test] + fn postgres_capability_contributes_a_cluster_score() { + let app = AppRef { + name: "ts", + namespace: "ts", + profile: Profile::Local, + }; + let scores = Postgres::managed().scores(&app); + assert_eq!(scores.len(), 1); + assert!(scores[0].name().contains("PostgreSQL")); + } } diff --git a/examples/compose_java_react/src/lib.rs b/examples/compose_java_react/src/lib.rs index 38501671..6cf1c95a 100644 --- a/examples/compose_java_react/src/lib.rs +++ b/examples/compose_java_react/src/lib.rs @@ -12,7 +12,7 @@ pub mod score; pub use chart::{DeployConfig, cluster_issuer_for, service_image}; pub use compose::ComposeApp; -pub use deploy::ComposeDeploy; +pub use deploy::{Capability, ComposeDeploy, Postgres}; pub use harmony_app::Profile; pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart}; diff --git a/examples/compose_java_react/src/main.rs b/examples/compose_java_react/src/main.rs index 2c5178a0..514f77db 100644 --- a/examples/compose_java_react/src/main.rs +++ b/examples/compose_java_react/src/main.rs @@ -10,14 +10,14 @@ //! and the context model all come from `harmony_app` — this file only //! *declares* the app. -use example_compose_java_react::ComposeDeploy; +use example_compose_java_react::{ComposeDeploy, Postgres}; #[tokio::main] async fn main() -> anyhow::Result<()> { let app = ComposeDeploy::from_dir("timesheet", concat!(env!("CARGO_MANIFEST_DIR"), "/app")) .map_err(|e| anyhow::anyhow!(e))? .expose("frontend", "timesheet.example.harmony.mcd") - .secret("DB_PASSWORD", "changeme"); + .with(Postgres::managed()); // deploys a CNPG cluster + wires DATABASE_URL harmony_cli::app::app_main(app).await } diff --git a/examples/compose_java_react/tests/helm_render.rs b/examples/compose_java_react/tests/helm_render.rs index 07326a37..39ea17ec 100644 --- a/examples/compose_java_react/tests/helm_render.rs +++ b/examples/compose_java_react/tests/helm_render.rs @@ -35,6 +35,7 @@ fn generated_chart_passes_helm_lint_and_template() { volume_size: "1Gi".to_string(), replicas: 2, rolling: true, + extra_env: vec![], }; let tmp = tempfile::tempdir().unwrap(); -- 2.39.5 From cdf0cdad40e1177dfb5dbfc1642e27be0f6cc26f Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 14:50:50 -0400 Subject: [PATCH 13/20] refactor(app): promote Capability + Postgres into harmony_app (framework menu) Capabilities were example-local (a discoverability trapdoor per review). Move the Capability trait, AppRef, and the Postgres capability into harmony_app::capabilities so .with(...) is a real, framework-level menu; ComposeDeploy now consumes the framework trait. Pure capability tests live in harmony_app; ComposeDeploy keeps the integration test. No behavior change. Foundation for the Monitoring + ZitadelAuth capabilities (folk prod). --- examples/compose_java_react/src/deploy.rs | 88 +--------------- examples/compose_java_react/src/lib.rs | 3 +- harmony_app/src/capabilities.rs | 118 ++++++++++++++++++++++ harmony_app/src/lib.rs | 2 + 4 files changed, 124 insertions(+), 87 deletions(-) create mode 100644 harmony_app/src/capabilities.rs diff --git a/examples/compose_java_react/src/deploy.rs b/examples/compose_java_react/src/deploy.rs index 5e923f2c..0c182b92 100644 --- a/examples/compose_java_react/src/deploy.rs +++ b/examples/compose_java_react/src/deploy.rs @@ -9,12 +9,9 @@ use std::path::Path; use anyhow::{Result, anyhow}; use async_trait::async_trait; -use harmony::modules::postgresql::K8sPostgreSQLScore; -use harmony::modules::postgresql::capability::PostgreSQLConfig; use harmony::score::Score; use harmony::topology::K8sAnywhereTopology; -use harmony_app::{AppContext, AppIdentity, HarmonyApp, Profile}; -use k8s_openapi::api::core::v1::{EnvVar, EnvVarSource, SecretKeySelector}; +use harmony_app::{AppContext, AppIdentity, AppRef, Capability, HarmonyApp, Profile}; use crate::chart::{DeployConfig, cluster_issuer_for}; use crate::compose::ComposeApp; @@ -25,28 +22,6 @@ struct Endpoint { host: String, } -/// What a capability needs to know about the app it augments. -pub struct AppRef<'a> { - pub name: &'a str, - pub namespace: &'a str, - pub profile: Profile, -} - -/// An add-on a deployment can declare with `.with(...)` — it contributes its -/// own Scores (e.g. a database) and injects env into the app's containers, -/// **wired by k8s reference** (service DNS, `secretKeyRef`), never by value. -/// That is what lets `.with()` work today without typed Score outputs (see -/// the module-level note / ADR-026): the generated password lives only in a -/// cluster Secret the app references by name. -pub trait Capability: Send + Sync { - fn scores(&self, _app: &AppRef) -> Vec>> { - vec![] - } - fn env(&self, _app: &AppRef) -> Vec { - vec![] - } -} - /// A compose app, declared. Build it fluently, then hand it to `app_main`. pub struct ComposeDeploy { name: String, @@ -203,57 +178,10 @@ impl HarmonyApp for ComposeDeploy { } } -/// A managed PostgreSQL database (CNPG). Deploys a cluster `-db` and -/// wires `DATABASE_URL` into the app from CNPG's generated `-db-app` -/// secret — **by reference**, so the password never passes through Harmony -/// and the published chart stays value-free. -pub struct Postgres { - instances: u32, -} - -impl Postgres { - pub fn managed() -> Self { - Self { instances: 1 } - } - pub fn instances(mut self, n: u32) -> Self { - self.instances = n; - self - } - fn cluster(app: &AppRef) -> String { - format!("{}-db", app.name) - } -} - -impl Capability for Postgres { - fn scores(&self, app: &AppRef) -> Vec>> { - let config = PostgreSQLConfig { - cluster_name: Self::cluster(app), - namespace: app.namespace.to_string(), - instances: self.instances, - ..Default::default() - }; - vec![Box::new(K8sPostgreSQLScore { config })] - } - - fn env(&self, app: &AppRef) -> Vec { - vec![EnvVar { - name: "DATABASE_URL".to_string(), - value_from: Some(EnvVarSource { - secret_key_ref: Some(SecretKeySelector { - name: format!("{}-app", Self::cluster(app)), - key: "uri".to_string(), - optional: Some(true), - }), - ..Default::default() - }), - ..Default::default() - }] - } -} - #[cfg(test)] mod tests { use super::*; + use harmony_app::Postgres; fn fixture() -> ComposeApp { ComposeApp::parse( @@ -340,16 +268,4 @@ mod tests { assert_eq!(sel.key, "uri"); assert!(db.value.is_none(), "wired by reference, never a value"); } - - #[test] - fn postgres_capability_contributes_a_cluster_score() { - let app = AppRef { - name: "ts", - namespace: "ts", - profile: Profile::Local, - }; - let scores = Postgres::managed().scores(&app); - assert_eq!(scores.len(), 1); - assert!(scores[0].name().contains("PostgreSQL")); - } } diff --git a/examples/compose_java_react/src/lib.rs b/examples/compose_java_react/src/lib.rs index 6cf1c95a..abfc3207 100644 --- a/examples/compose_java_react/src/lib.rs +++ b/examples/compose_java_react/src/lib.rs @@ -12,8 +12,9 @@ pub mod score; pub use chart::{DeployConfig, cluster_issuer_for, service_image}; pub use compose::ComposeApp; -pub use deploy::{Capability, ComposeDeploy, Postgres}; +pub use deploy::ComposeDeploy; pub use harmony_app::Profile; +pub use harmony_app::{Capability, Postgres}; pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart}; use std::path::Path; diff --git a/harmony_app/src/capabilities.rs b/harmony_app/src/capabilities.rs new file mode 100644 index 00000000..14ec9e7e --- /dev/null +++ b/harmony_app/src/capabilities.rs @@ -0,0 +1,118 @@ +//! Composable **capabilities** — the `.with(...)` menu. A capability +//! contributes its own Scores (e.g. a database) and injects env into the +//! app's containers, **wired by k8s reference** (service DNS, `secretKeyRef`), +//! so generated secrets never pass through Harmony and a published chart stays +//! value-free. This is the framework-level, discoverable home: `Postgres` +//! today; `Monitoring` and `ZitadelAuth` next. +//! +//! To add one: implement [`Capability`] — `scores()` (what it deploys) and/or +//! `env()` (how the app reaches it, by reference) — then `app.with(MyThing)`. + +use harmony::modules::postgresql::K8sPostgreSQLScore; +use harmony::modules::postgresql::capability::PostgreSQLConfig; +use harmony::score::Score; +use harmony::topology::K8sAnywhereTopology; +use k8s_openapi::api::core::v1::{EnvVar, EnvVarSource, SecretKeySelector}; + +use crate::Profile; + +/// What a capability needs to know about the app it augments. +pub struct AppRef<'a> { + pub name: &'a str, + pub namespace: &'a str, + pub profile: Profile, +} + +/// An add-on a deployment declares with `.with(...)`: it deploys its own +/// Scores and injects env wired by k8s reference, never by value. +pub trait Capability: Send + Sync { + fn scores(&self, _app: &AppRef) -> Vec>> { + vec![] + } + fn env(&self, _app: &AppRef) -> Vec { + vec![] + } +} + +/// A managed PostgreSQL database (CNPG). Deploys a cluster `-db` and +/// wires `DATABASE_URL` into the app from CNPG's generated `-db-app` +/// secret — by reference, so the password never passes through Harmony. +pub struct Postgres { + instances: u32, +} + +impl Postgres { + pub fn managed() -> Self { + Self { instances: 1 } + } + pub fn instances(mut self, n: u32) -> Self { + self.instances = n; + self + } + fn cluster(app: &AppRef) -> String { + format!("{}-db", app.name) + } +} + +impl Capability for Postgres { + fn scores(&self, app: &AppRef) -> Vec>> { + let config = PostgreSQLConfig { + cluster_name: Self::cluster(app), + namespace: app.namespace.to_string(), + instances: self.instances, + ..Default::default() + }; + vec![Box::new(K8sPostgreSQLScore { config })] + } + + fn env(&self, app: &AppRef) -> Vec { + vec![EnvVar { + name: "DATABASE_URL".to_string(), + value_from: Some(EnvVarSource { + secret_key_ref: Some(SecretKeySelector { + name: format!("{}-app", Self::cluster(app)), + key: "uri".to_string(), + optional: Some(true), + }), + ..Default::default() + }), + ..Default::default() + }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> AppRef<'static> { + AppRef { + name: "ts", + namespace: "ts", + profile: Profile::Local, + } + } + + #[test] + fn postgres_wires_database_url_by_reference() { + let env = Postgres::managed().env(&app()); + let db = env.iter().find(|e| e.name == "DATABASE_URL").unwrap(); + let sel = db + .value_from + .as_ref() + .unwrap() + .secret_key_ref + .as_ref() + .unwrap(); + assert_eq!(sel.name, "ts-db-app", "CNPG app secret, by convention"); + assert_eq!(sel.key, "uri"); + assert!(db.value.is_none(), "wired by reference, never a value"); + } + + #[test] + fn postgres_contributes_a_cluster_score() { + let scores = Postgres::managed().scores(&app()); + assert_eq!(scores.len(), 1); + assert!(scores[0].name().contains("PostgreSQL")); + } +} diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index 5bb35711..b421ffa9 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -15,6 +15,7 @@ //! context points; only the context changes between local and prod. pub mod app; +pub mod capabilities; pub mod context; pub mod profile; @@ -22,5 +23,6 @@ pub use app::{ AppIdentity, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, WorkloadStatus, deploy, logs, ship, status, }; +pub use capabilities::{AppRef, Capability, Postgres}; pub use context::{AppContext, ClusterAccess}; pub use profile::Profile; -- 2.39.5 From 51b6e02a5a9f327335b980ffa6354047c91384ea Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 15:01:25 -0400 Subject: [PATCH 14/20] =?UTF-8?q?feat(app):=20Monitoring=20capability=20?= =?UTF-8?q?=E2=80=94=20namespace-scoped=20downtime=20alert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .with(Monitoring::new().alert(receiver)) composes HelmPrometheusAlertingScore with a kube-state-metrics deployment-availability alert (kube_deployment_status_replicas_available{namespace} == 0, 5m) — no app-side /metrics or ServiceMonitor needed. Composition mirrors examples/monitoring (proven pattern); rule helper unit-tested. Live alert-firing validation needs kube-prometheus on the cluster. Addresses the downtime-rule intent (task #4). --- examples/compose_java_react/src/lib.rs | 2 +- harmony_app/src/capabilities.rs | 80 ++++++++++++++++++++++++++ harmony_app/src/lib.rs | 2 +- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/examples/compose_java_react/src/lib.rs b/examples/compose_java_react/src/lib.rs index abfc3207..2b4e7dcd 100644 --- a/examples/compose_java_react/src/lib.rs +++ b/examples/compose_java_react/src/lib.rs @@ -14,7 +14,7 @@ pub use chart::{DeployConfig, cluster_issuer_for, service_image}; pub use compose::ComposeApp; pub use deploy::ComposeDeploy; pub use harmony_app::Profile; -pub use harmony_app::{Capability, Postgres}; +pub use harmony_app::{Capability, Monitoring, Postgres}; pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart}; use std::path::Path; diff --git a/harmony_app/src/capabilities.rs b/harmony_app/src/capabilities.rs index 14ec9e7e..9ad6eb6f 100644 --- a/harmony_app/src/capabilities.rs +++ b/harmony_app/src/capabilities.rs @@ -8,10 +8,16 @@ //! To add one: implement [`Capability`] — `scores()` (what it deploys) and/or //! `env()` (how the app reaches it, by reference) — then `app.with(MyThing)`. +use harmony::modules::monitoring::alert_rule::prometheus_alert_rule::{ + AlertManagerRuleGroup, PrometheusAlertRule, +}; +use harmony::modules::monitoring::kube_prometheus::helm_prometheus_alert_score::HelmPrometheusAlertingScore; +use harmony::modules::monitoring::kube_prometheus::prometheus::KubePrometheus; use harmony::modules::postgresql::K8sPostgreSQLScore; use harmony::modules::postgresql::capability::PostgreSQLConfig; use harmony::score::Score; use harmony::topology::K8sAnywhereTopology; +use harmony::topology::oberservability::monitoring::AlertReceiver; use k8s_openapi::api::core::v1::{EnvVar, EnvVarSource, SecretKeySelector}; use crate::Profile; @@ -81,6 +87,63 @@ impl Capability for Postgres { } } +/// Application monitoring: a downtime alert routed to the given receivers. +/// The "is it up?" signal is kube-state-metrics deployment availability — +/// scoped to the app's namespace, so it needs no app-side `/metrics` and no +/// ServiceMonitor wiring. (kube-prometheus must be present on the cluster.) +pub struct Monitoring { + receivers: Vec>>, +} + +impl Default for Monitoring { + fn default() -> Self { + Self::new() + } +} + +impl Monitoring { + pub fn new() -> Self { + Self { receivers: vec![] } + } + /// Route alerts to a receiver (e.g. `DiscordWebhook`). Chainable. + pub fn alert(mut self, receiver: impl AlertReceiver + 'static) -> Self { + self.receivers.push(Box::new(receiver)); + self + } +} + +/// The downtime alert for an app: fires when its namespace has a Deployment +/// with no available replicas for 5m. Pure + testable. +fn downtime_alert(app: &AppRef) -> PrometheusAlertRule { + PrometheusAlertRule::new( + &format!("{}Down", app.name), + &format!( + "kube_deployment_status_replicas_available{{namespace=\"{}\"}} == 0", + app.namespace + ), + ) + .for_duration("5m") + .label("severity", "critical") + .annotation( + "summary", + &format!("{} has no available replicas", app.name), + ) +} + +impl Capability for Monitoring { + fn scores(&self, app: &AppRef) -> Vec>> { + let group = AlertManagerRuleGroup::new( + &format!("{}-availability", app.name), + vec![downtime_alert(app)], + ); + vec![Box::new(HelmPrometheusAlertingScore { + receivers: self.receivers.iter().map(|r| r.clone_box()).collect(), + rules: vec![Box::new(group)], + service_monitors: vec![], + })] + } +} + #[cfg(test)] mod tests { use super::*; @@ -115,4 +178,21 @@ mod tests { assert_eq!(scores.len(), 1); assert!(scores[0].name().contains("PostgreSQL")); } + + #[test] + fn monitoring_downtime_alert_is_namespace_scoped() { + let rule = downtime_alert(&app()); + assert!( + rule.expr.contains("namespace=\"ts\""), + "scoped to the app namespace: {}", + rule.expr + ); + assert!(rule.expr.contains("== 0"), "fires on zero replicas"); + } + + #[test] + fn monitoring_contributes_one_alerting_score() { + let scores = Monitoring::new().scores(&app()); + assert_eq!(scores.len(), 1); + } } diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index b421ffa9..1b3ac4cb 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -23,6 +23,6 @@ pub use app::{ AppIdentity, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, WorkloadStatus, deploy, logs, ship, status, }; -pub use capabilities::{AppRef, Capability, Postgres}; +pub use capabilities::{AppRef, Capability, Monitoring, Postgres}; pub use context::{AppContext, ClusterAccess}; pub use profile::Profile; -- 2.39.5 From 4a9bee73ad7115dbf0b606a9c61a4831c7598a11 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 15:38:35 -0400 Subject: [PATCH 15/20] docs+deprecate: Application Capabilities guide; deprecate ApplicationFeature model - docs/guides/application-capabilities.md: the .with(...) menu, by-reference wiring, and a recipe for writing a capability; disambiguates the overloaded 'capability' (topology vs application) flagged in DX review; wired into the book. - Deprecate ApplicationScore, features::Monitoring, features::PackagingDeployment (+ doc-deprecate the ApplicationFeature trait) in favour of harmony_app + ComposeDeploy/.with(...) (ADR-026). Legacy examples now warn; harmony lib is warning-clean (internal self-refs allowed). --- docs/SUMMARY.md | 1 + docs/catalogs/capabilities.md | 8 +- docs/guides/application-capabilities.md | 94 +++++++++++ docs/why-harmony.draft.md | 159 ++++++++++++++++++ docs/why-harmony.manifesto.draft.md | 76 +++++++++ harmony/src/modules/application/feature.rs | 6 + .../application/features/monitoring.rs | 10 ++ .../features/packaging_deployment.rs | 10 ++ harmony/src/modules/application/rust.rs | 9 + 9 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 docs/guides/application-capabilities.md create mode 100644 docs/why-harmony.draft.md create mode 100644 docs/why-harmony.manifesto.draft.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 5b4b7695..c82feea2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -21,6 +21,7 @@ - [Developer Guide](./guides/developer-guide.md) - [Application CLI — Use Cases & Commands](./guides/application-cli.md) +- [Application Capabilities — .with(...)](./guides/application-capabilities.md) - [Writing a Score](./guides/writing-a-score.md) - [Writing a Topology](./guides/writing-a-topology.md) - [Adding Capabilities](./guides/adding-capabilities.md) diff --git a/docs/catalogs/capabilities.md b/docs/catalogs/capabilities.md index 32ec90f3..721aebf9 100644 --- a/docs/catalogs/capabilities.md +++ b/docs/catalogs/capabilities.md @@ -1,4 +1,10 @@ -# Capabilities Catalog +# Capabilities Catalog (Topology) + +> **Note:** this page lists **Topology** capabilities — what a *cluster* can +> do, exposed as trait bounds a `Score` requires. For **application** +> capabilities — add-ons you attach to an app with `.with(...)` (databases, +> monitoring, …) — see +> [Application Capabilities](../guides/application-capabilities.md). A `Capability` is a specific feature or API that a `Topology` offers. `Interpret` logic uses these capabilities to execute a `Score`. diff --git a/docs/guides/application-capabilities.md b/docs/guides/application-capabilities.md new file mode 100644 index 00000000..d1dd2ad6 --- /dev/null +++ b/docs/guides/application-capabilities.md @@ -0,0 +1,94 @@ +# Application Capabilities — `.with(...)` + +> **Status: in progress (feature branch).** The `harmony_app` application +> layer and capabilities described here are landing incrementally. The +> *decisions and rationale* live in +> [ADR-026](../adr/026-application-lifecycle-cli.md); this is the "how". +> Companion to [Application CLI](./application-cli.md). + +A **capability** is an add-on you attach to an app deployment — a database, +monitoring, auth — by declaring it: + +```rust +ComposeDeploy::from_dir("timesheet", "./app")? + .expose("frontend", "timesheet.example") + .with(Postgres::managed()) // a managed database + .with(Monitoring::new().alert(discord)); // a downtime alert +``` + +The app author *declares intent*; the capability deploys what it needs and +wires itself to the app. Everything else — `ship`/`deploy`/`status`/`logs`, +contexts, profiles — comes from the [application CLI](./application-cli.md). + +> **Two different "capabilities" — don't confuse them.** +> - **Topology capabilities** ([catalog](../catalogs/capabilities.md)) are what +> a *cluster* can do — `K8sClient`, `DnsServer`, `HelmCommand` — exposed as +> trait bounds a `Score` requires. Infrastructure-facing. +> - **Application capabilities** (this page) are add-ons a *developer* attaches +> to their app with `.with(...)`. They live in `harmony_app::capabilities`. + +## The menu + +| Capability | Declares | Deploys | Wires into the app | +|---|---|---|---| +| `Postgres::managed()` | a managed PostgreSQL | a CNPG cluster `-db` | `DATABASE_URL` ← `secretKeyRef(-db-app, uri)` | +| `Monitoring::new().alert(r)` | a downtime alert | a Prometheus alert rule + routes it to `r` | nothing (selector-scoped to the app's namespace) | + +(`ZitadelAuth` for OIDC login is next.) + +## How wiring works — by reference, never by value + +A capability never passes a *value* through Harmony. Postgres' generated +password lives only in the CNPG-created `-db-app` Secret; the capability +injects an env var that **references** it (`secretKeyRef`), resolved in-cluster +at runtime. This is what keeps the rendered chart publishable (it contains a +reference, not a secret) and is why the app layer needs no typed Score outputs +today (ADR-026). The trade: wiring must be expressible as a stable k8s +reference (service DNS, Secret/ConfigMap key) — true for k8s-native add-ons. + +## Writing your own capability + +A capability implements one small trait +(`harmony_app::capabilities::Capability`): + +```rust +pub trait Capability { + /// What this capability deploys (a database, an operator, …). + fn scores(&self, app: &AppRef) -> Vec>> { vec![] } + /// Env injected into the app's containers, wired by reference. + fn env(&self, app: &AppRef) -> Vec { vec![] } +} +``` + +`AppRef { name, namespace, profile }` is how the capability learns who it's +augmenting (and so derives names like `-db`). Implement either method or +both, then `app.with(MyThing)`. + +### Recipe (e.g. a `Redis` capability) + +1. **Find the backing Score.** Capabilities *compose* existing Scores — they + do not hand-roll manifests (ADR-023). Look in `harmony::modules::*` for one + (Postgres composes `harmony::modules::postgresql::K8sPostgreSQLScore`). If + none exists, write the Score first ([Writing a Score](./writing-a-score.md)). +2. **Return it from `scores()`**, naming resources off `app` (e.g. cluster + `format!("{}-redis", app.name)`). +3. **Wire it by reference in `env()`.** Determine what stable reference the + Score exposes — a Service DNS name, or a key in a Secret/ConfigMap it + creates — and inject an `EnvVar` pointing at it. (Postgres references CNPG's + `-app` secret's `uri` key.) The convention between a Score's output + and a capability's `env()` is currently by agreement, not type-checked — + read the Score to confirm the names it produces. +4. **Receivers and other inputs** (e.g. an alert `DiscordWebhook`) come from + `harmony::modules::*` too — `Monitoring` takes a + `harmony::modules::monitoring::alert_channel::discord_alert_channel::DiscordWebhook`. + +`Postgres` and `Monitoring` in `harmony_app/src/capabilities.rs` are the two +worked examples to copy. + +## Deprecated: the old `ApplicationFeature` model + +The previous approach — `ApplicationScore { features: vec![Monitoring, …] }` +over `ApplicationFeature` — is **deprecated** in favor of this one. It coupled +app delivery to a fixed feature menu and the (cancelled) ArgoCD path, and +wasn't composable with plain Scores. Migrate `ApplicationScore` → +`ComposeDeploy` (or your own `HarmonyApp`) + `.with(...)` capabilities. diff --git a/docs/why-harmony.draft.md b/docs/why-harmony.draft.md new file mode 100644 index 00000000..e9bbb249 --- /dev/null +++ b/docs/why-harmony.draft.md @@ -0,0 +1,159 @@ + + +# Why Harmony + +## A nagging feeling, continued + +The first essay was about *where* computing should live: not in a handful of +enormous datacenters drinking water and land while sitting mostly idle, but +spread out — in homes, offices, clinics, community spaces — machines that heat +a room while they compute, infrastructure made to *sing*. + +That's the body. This is about the nervous system. + +Because decentralized hardware doesn't matter if the only people who can +operate it are the same large organizations that already run the centralized +kind. A micro-datacenter in a village is just expensive scrap if it takes a +platform team of ten and a year of glue to run a single service on it +reliably. The hardware vision is only liberating if the software to wield it +is within reach of ordinary people and small teams. **Infrastructure is the +root of nearly every software business, and right now the root is owned.** If +we want computing to serve people, we have to give people something that lets +them actually run it. + +That something is Harmony. + +## The motivation, plainly + +I've spent a long career deploying distributed systems for some of the largest +companies in the world. The robustness those systems have — the ability to +survive a failed machine, a bad deploy, a 3 a.m. outage and keep serving — is +not magic. It's the product of expensive teams, hard-won patterns, and an +enormous amount of careful, repetitive work. It is real, and it is *rationed*. +Only the well-resourced get it. + +I find that quietly unjust. Not because anyone is villainous — I don't think +the people at the top of the cloud are evil — but because the *system* is +shaped so that the value of computing flows, by default, upward and inward: to +whoever already owns the most infrastructure. Lock-in, pricing that rewards +scale, defaults that assume you're big — none of it is a conspiracy. It's just +an incline. Everything rolls toward the same few owners because the ground is +tilted. + +This project comes from a simple, almost stubborn conviction: that I should +try to give the best of what I know back to the world, and that the best thing +I know is how to make infrastructure trustworthy. If that knowledge can be put +into code instead of into another consulting invoice, then the robustness that +today costs a platform team can become something every app simply *has* — for +free, by default, the way running water is supposed to be. + +## What we believe + +**Computing should serve people, not skew toward owners.** We are not against +the rich or the large — we will gladly serve a Fortune 100 and a rural clinic +with the same code. What we refuse is to *bake the skew in*. We remove the +tilt rather than tilt it the other way. The playing field, made actually +level, is already radical, because the ground today is sloped. So we serve +whoever comes, on their terms, rich or poor, and we let no one be the default. + +**Robustness is a right, not a privilege.** The ability to deploy something and +have it actually keep working should not be reserved for organizations with an +SRE budget. It should come in the box. + +**Freedom means you can leave.** If your infrastructure only runs on one +vendor's cloud, you don't own it; you rent your own existence. The same +definition should run on your laptop, a cluster in your office, bare metal in a +community space, or any cloud — and you should be able to move it whenever you +like, without a rewrite. + +## What Harmony is + +Harmony is one way to describe what you want running, and have it converge — +the same way — wherever you point it. You write your infrastructure as real +code, not as piles of YAML validated at runtime when it's already too late. A +*Score* says what should exist. A *Topology* is wherever you're putting it — a +laptop, a tenant cluster, bare metal — described by what it can actually *do*. +Ask a Topology for something it can't provide and the program doesn't compile; +the mistake is caught at your desk, not at 3 a.m. And a deploy isn't finished +when a command exits successfully — it's finished when the thing is actually +*working*, proven by a smoke test, the way a careful human would check before +walking away. + +The same Score runs in all of those places. Only the Topology changes. + +## How we differ — honestly, and without mysticism + +There is no shortage of good infrastructure tools. We are not claiming to have +invented robustness or declarative systems; we stand on a generation of work +that did. What we've found is a *seam* that, as far as we can tell, no one is +standing in: + +- Tools that turn bare metal into a running cluster stop at the cluster. +- Tools that deliver applications assume a cluster already exists. +- Tools that compose infrastructure live in the cloud and assume someone else + handled the metal. +- The one project closest to our "infrastructure and application in one real + language" idea targets only the big public clouds — and its company didn't + survive the attempt. + +Nobody we can find spans the whole thing — **bare metal, to running +distributed service, to keeping it alive over time — in a single, coherent, +type-checked model that is honest about the physical machine.** That seam, +aimed first at *decentralized and sovereign* deployments rather than the +hyperscalers, is where Harmony lives. + +The differences that make it possible aren't tricks; they're choices: + +- **Capabilities are described as types**, so the compiler is a colleague that + catches whole classes of misconfiguration before anything runs. +- **One model from the metal to the service to day-two**, instead of four tools + with four mental models taped together. +- **Local equals production**, so "it worked on my machine" stops being a + confession and becomes a guarantee. +- **It's real code**, which means it's reviewable, testable, and yours — not a + configuration dialect you'll be fighting in two years. + +## Where we honestly stand + +We are early, and I want to be the first to say it. The incumbents are years +ahead of us on breadth — the sheer number of clouds and services they can talk +to is a real moat we have not crossed and may never fully cross. We're a small +project written in a language with a smaller pool of contributors than the +mainstream. Anyone who tells you a young tool has already won is selling +something. + +So we don't intend to win by breadth. We intend to be *undeniably good* at one +thing first: running and operating real services on self-hosted and sovereign +clusters, with the robustness usually reserved for the big players, proven by +systems that actually work in the world. Our proof isn't a benchmark or a +funding round. It's a sovereign public-health data hub headed to a community in +Cameroon, with a university partner — a place where depending on a distant +hyperscaler isn't just expensive but a question of whose hands the data is in. +If Harmony can stand up *there*, robustly, owned locally, then the promise is +real. If it can't, no manifesto will save it. + +## The ambition, kept humble + +I don't think of this as building a company so much as removing a tax — the +quiet tax that infrastructure complexity levies on every person with an idea +and no platform team. If software is one of the great levers humans have for +helping each other, then the cost and lock-in of the ground it stands on is a +weight on that lever. Take the weight off, and you don't know in advance what +people will build — a greenhouse controller, a clinic's records, a town's own +small cloud — and that not-knowing is exactly the point. You don't pave a road +because you know who'll walk it. You pave it so they can. + +That's all this is, really: an attempt to make the infrastructure sing — not +for a few, and not against them either, but for anyone who shows up — and to +leave behind a paved road to freedom for all software. + +If we get it right, most people will never think about it. The water will just +run. diff --git a/docs/why-harmony.manifesto.draft.md b/docs/why-harmony.manifesto.draft.md new file mode 100644 index 00000000..ed7adc28 --- /dev/null +++ b/docs/why-harmony.manifesto.draft.md @@ -0,0 +1,76 @@ + + +# Why Harmony + +The first essay was about *where* computing should live. This is about who gets +to run it. + +Decentralized hardware means nothing if only the giants can operate it. A +micro-datacenter in a village is scrap if it takes ten engineers and a year of +glue to run one service reliably. Infrastructure is the root of nearly every +software business — and the root is owned. + +I've spent a career making distributed systems survive failed machines, bad +deploys, 3 a.m. outages. That robustness isn't magic — it's expensive teams and +hard-won patterns. It is real, and it is *rationed*. Only the well-resourced +get it. + +That's the quiet injustice. Not villainy — an incline. Lock-in, pricing that +rewards scale, defaults that assume you're big: nobody had to conspire. The +ground is tilted, and everything rolls toward the same few owners. + +So the conviction is stubborn and simple: put what I know into code instead of +another invoice, and the robustness that costs a platform team becomes +something every app just *has* — for free, by default, like running water. + +**What we believe.** Computing should serve people, not skew toward owners. +We're not against the rich or the large — we'll serve a Fortune 100 and a rural +clinic with the same code. We just refuse to bake the skew in. We remove the +tilt rather than reverse it; a level field is already radical when the ground +is sloped. Robustness is a right, not a privilege — deploy something and have +it keep working shouldn't require an SRE budget. And freedom means you can +leave: if it only runs on one vendor's cloud, you don't own it, you rent your +own existence. + +**What it is.** You describe what should run, as real code, and it converges — +the same way — wherever you point it. Ask the target for something it can't do, +and it won't compile: the mistake is caught at your desk, not at 3 a.m. A +deploy isn't done when a command exits; it's done when the thing actually +works, proven, the way a careful human checks before walking away. + +**How we differ — without mysticism.** We didn't invent robustness; we stand on +a generation that did. What we found is a seam no one occupies. Tools that turn +metal into a cluster stop at the cluster. Tools that ship apps assume a cluster +exists. Tools that compose infrastructure live in the cloud and assume someone +handled the metal. The project closest to "infrastructure and app in one real +language" served only the big clouds — and its company didn't survive. No one +spans the whole of it — bare metal, to running service, to keeping it alive — +in one coherent, type-checked model that's honest about the physical machine. +Aimed first at the sovereign and the self-hosted. That seam is where we live. + +**Where we honestly stand.** Early. The incumbents are years ahead on breadth, +and that's a real moat. We're small. Anyone claiming a young tool has already +won is selling something. So we won't win by breadth — we'll be undeniably good +at one thing: running real services on self-hosted and sovereign clusters, with +the robustness usually reserved for the giants. Our proof isn't a benchmark or +a funding round. It's a sovereign health-data hub headed to a community in +Cameroon — a place where depending on a distant cloud is a question of whose +hands the data is in. If it stands up there, the promise is real. If it can't, +no manifesto will save it. + +**The ambition, kept humble.** This is less a company than the removal of a tax +— the quiet tax infrastructure levies on everyone with an idea and no platform +team. Software is one of our great levers for helping each other; the cost and +lock-in of the ground beneath it is a weight on that lever. Take the weight off +and you can't predict what people will build. That's the point. You don't pave +a road because you know who'll walk it — you pave it so they can. + +Make the infrastructure sing — not for a few, not against them, but for anyone +who shows up. A paved road to freedom for all software. + +If we get it right, most people will never think about it. The water will just +run. diff --git a/harmony/src/modules/application/feature.rs b/harmony/src/modules/application/feature.rs index 9e1b1ae4..9c56b410 100644 --- a/harmony/src/modules/application/feature.rs +++ b/harmony/src/modules/application/feature.rs @@ -6,6 +6,12 @@ use serde::Serialize; use crate::{executors::ExecutorError, topology::Topology}; +/// **Deprecated** (see [`ApplicationScore`](super::ApplicationScore)). Superseded +/// by the `harmony_app` application layer + `.with(...)` capabilities (ADR-026); +/// see `docs/guides/application-capabilities.md`. The trait itself is not yet +/// `#[deprecated]` to avoid warning every internal impl, but it should not be +/// used in new code. +/// /// An ApplicationFeature provided by harmony, such as Backups, Monitoring, MultisiteAvailability, /// ContinuousIntegration, ContinuousDelivery #[async_trait] diff --git a/harmony/src/modules/application/features/monitoring.rs b/harmony/src/modules/application/features/monitoring.rs index 6f5bb214..75c6ae5c 100644 --- a/harmony/src/modules/application/features/monitoring.rs +++ b/harmony/src/modules/application/features/monitoring.rs @@ -1,3 +1,7 @@ +// This whole feature is deprecated (see the `Monitoring` struct); silence +// self-references to the deprecated types within the module. +#![allow(deprecated)] + use crate::modules::application::{ Application, ApplicationFeature, InstallationError, InstallationOutcome, }; @@ -32,6 +36,12 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::sync::Arc; +/// **Deprecated.** Use `harmony_app::capabilities::Monitoring` via +/// `.with(Monitoring::new().alert(...))` (ADR-026). See +/// `docs/guides/application-capabilities.md`. +#[deprecated( + note = "Use harmony_app::capabilities::Monitoring (.with(...)). See docs/guides/application-capabilities.md" +)] #[derive(Debug, Clone)] pub struct Monitoring { pub application: Arc, diff --git a/harmony/src/modules/application/features/packaging_deployment.rs b/harmony/src/modules/application/features/packaging_deployment.rs index 4bea8da9..43ff67ad 100644 --- a/harmony/src/modules/application/features/packaging_deployment.rs +++ b/harmony/src/modules/application/features/packaging_deployment.rs @@ -1,3 +1,7 @@ +// Deprecated feature (see the `PackagingDeployment` struct); silence +// self-references to the deprecated type within the module. +#![allow(deprecated)] + use std::{io::Write, process::Command, sync::Arc}; use async_trait::async_trait; @@ -46,6 +50,12 @@ use crate::{ /// - Harbor as artifact registru /// - ArgoCD to install/upgrade/rollback/inspect k8s resources /// - Kubernetes for runtime orchestration +/// **Deprecated.** Use the `harmony_app` application layer — `ComposeDeploy` +/// publishes + deploys, capabilities attach add-ons (ADR-026). See +/// `docs/guides/application-capabilities.md`. +#[deprecated( + note = "Use harmony_app: ComposeDeploy + .with(...) capabilities. See docs/guides/application-capabilities.md" +)] #[derive(Debug, Default, Clone)] pub struct PackagingDeployment { pub application: Arc, diff --git a/harmony/src/modules/application/rust.rs b/harmony/src/modules/application/rust.rs index aa5c63bf..9515fc85 100644 --- a/harmony/src/modules/application/rust.rs +++ b/harmony/src/modules/application/rust.rs @@ -20,6 +20,14 @@ use crate::{score::Score, topology::Topology}; use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant}; +/// **Deprecated.** Use the `harmony_app` application layer — +/// `ComposeDeploy`/`HarmonyApp` + `.with(...)` capabilities (ADR-026). See +/// `docs/guides/application-capabilities.md`. The feature-menu model couples +/// delivery to a fixed feature set and the cancelled ArgoCD path and does not +/// compose with plain Scores. +#[deprecated( + note = "Use harmony_app: ComposeDeploy/HarmonyApp + .with(...) capabilities (ADR-026). See docs/guides/application-capabilities.md" +)] #[derive(Debug, Serialize, Clone)] pub struct ApplicationScore where @@ -29,6 +37,7 @@ where pub application: Arc, } +#[allow(deprecated)] // the Score impl for the deprecated type stays until removal impl< A: Application + Serialize + Clone + 'static, T: Topology + std::fmt::Debug + Clone + Serialize + 'static, -- 2.39.5 From f244983b403ed558a0b1bbec84c23c00c618a6fb Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 15:46:58 -0400 Subject: [PATCH 16/20] feat(zitadel): create OIDC PKCE (web/SPA) apps, not just DeviceCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ZitadelAppType::WebPkce { redirect_uris, post_logout_redirect_uris } — appType=USER_AGENT, authMethod=NONE (Authorization Code + PKCE, no client secret) — for React/SPA frontends. create/update refactored over shared create_oidc_app/update_oidc_config helpers (DRY). The PKCE body was validated against a live local Zitadel (Management API returned appId + clientId). --- harmony/src/modules/zitadel/setup.rs | 110 +++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/harmony/src/modules/zitadel/setup.rs b/harmony/src/modules/zitadel/setup.rs index 44b7b607..a24c308e 100644 --- a/harmony/src/modules/zitadel/setup.rs +++ b/harmony/src/modules/zitadel/setup.rs @@ -79,6 +79,12 @@ pub enum ZitadelAppType { /// OAuth 2.0 Device Authorization Grant (RFC 8628). /// For CLI tools, SSH sessions, containers, and headless environments. DeviceCode, + /// OIDC Authorization Code + PKCE — a public client (no secret), for + /// SPAs / web frontends (`appType=USER_AGENT`, `authMethod=NONE`). + WebPkce { + redirect_uris: Vec, + post_logout_redirect_uris: Vec, + }, } /// An OIDC application to create in a Zitadel project. @@ -930,16 +936,15 @@ impl ZitadelSetupInterpret { }) } - async fn create_device_code_app( + /// POST an OIDC app `body` and return its `clientId`. Shared by the + /// DeviceCode and WebPkce builders. + async fn create_oidc_app( &self, client: &reqwest::Client, pat: &str, project_id: &str, - app_name: &str, + body: serde_json::Value, ) -> Result { - let mut body = self.device_code_oidc_config_body(); - body["name"] = serde_json::json!(app_name); - let resp = self .post( client, @@ -965,6 +970,51 @@ impl ZitadelSetupInterpret { .ok_or_else(|| "No clientId in app response".to_string()) } + async fn create_device_code_app( + &self, + client: &reqwest::Client, + pat: &str, + project_id: &str, + app_name: &str, + ) -> Result { + let mut body = self.device_code_oidc_config_body(); + body["name"] = serde_json::json!(app_name); + self.create_oidc_app(client, pat, project_id, body).await + } + + /// OIDC config for a PKCE public client (SPA / web frontend). Validated + /// against a live Zitadel: `USER_AGENT` + auth method `NONE` is + /// Authorization Code + PKCE with no client secret. + fn web_pkce_oidc_config_body( + &self, + redirect_uris: &[String], + post_logout_redirect_uris: &[String], + ) -> serde_json::Value { + serde_json::json!({ + "redirectUris": redirect_uris, + "postLogoutRedirectUris": post_logout_redirect_uris, + "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], + "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], + "appType": "OIDC_APP_TYPE_USER_AGENT", + "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", + "idTokenUserinfoAssertion": true + }) + } + + async fn create_web_pkce_app( + &self, + client: &reqwest::Client, + pat: &str, + project_id: &str, + app_name: &str, + redirect_uris: &[String], + post_logout_redirect_uris: &[String], + ) -> Result { + let mut body = self.web_pkce_oidc_config_body(redirect_uris, post_logout_redirect_uris); + body["name"] = serde_json::json!(app_name); + self.create_oidc_app(client, pat, project_id, body).await + } + /// Reconciles an existing app's OIDC config to the desired shape. /// `ZitadelSetupScore` owns the app definition declaratively, so any /// drift (manual UI edits, schema changes between harmony versions) @@ -977,12 +1027,14 @@ impl ZitadelSetupInterpret { /// config byte-for-byte. We treat that as the success case — the /// declared state already holds — instead of letting reconciliation /// fail every re-run after the first. - async fn update_device_code_oidc_config( + /// PUT an app's OIDC config to `body`. Shared by DeviceCode/WebPkce. + async fn update_oidc_config( &self, client: &reqwest::Client, pat: &str, project_id: &str, app_id: &str, + body: serde_json::Value, ) -> Result<(), String> { let resp = self .put( @@ -990,7 +1042,7 @@ impl ZitadelSetupInterpret { &format!("/management/v1/projects/{project_id}/apps/{app_id}/oidc_config"), ) .bearer_auth(pat) - .json(&self.device_code_oidc_config_body()) + .json(&body) .send() .await .map_err(|e| format!("Failed to update OIDC config: {e}"))?; @@ -1007,6 +1059,23 @@ impl ZitadelSetupInterpret { Err(format!("Update OIDC config failed: {body}")) } + async fn update_device_code_oidc_config( + &self, + client: &reqwest::Client, + pat: &str, + project_id: &str, + app_id: &str, + ) -> Result<(), String> { + self.update_oidc_config( + client, + pat, + project_id, + app_id, + self.device_code_oidc_config_body(), + ) + .await + } + async fn ensure_app( &self, client: &reqwest::Client, @@ -1036,6 +1105,19 @@ impl ZitadelSetupInterpret { .update_device_code_oidc_config(client, pat, &project_id, &found.id) .await .map_err(InterpretError::new)?, + ZitadelAppType::WebPkce { + redirect_uris, + post_logout_redirect_uris, + } => self + .update_oidc_config( + client, + pat, + &project_id, + &found.id, + self.web_pkce_oidc_config_body(redirect_uris, post_logout_redirect_uris), + ) + .await + .map_err(InterpretError::new)?, } config .apps @@ -1048,6 +1130,20 @@ impl ZitadelSetupInterpret { .create_device_code_app(client, pat, &project_id, &app.app_name) .await .map_err(InterpretError::new)?, + ZitadelAppType::WebPkce { + redirect_uris, + post_logout_redirect_uris, + } => self + .create_web_pkce_app( + client, + pat, + &project_id, + &app.app_name, + redirect_uris, + post_logout_redirect_uris, + ) + .await + .map_err(InterpretError::new)?, }; info!( -- 2.39.5 From 32c6777d5756a84929a112d489eb8344637c5d36 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 16:28:49 -0400 Subject: [PATCH 17/20] =?UTF-8?q?feat(app):=20ZitadelAuth=20capability=20?= =?UTF-8?q?=E2=80=94=20PKCE=20OIDC=20login,=20validated=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ZitadelClientIdExportScore (harmony): reads a provisioned app's client_id from the Zitadel cache and writes it to a ConfigMap — the Zitadel→app bridge, by reference (client_id is non-secret under PKCE). - ZitadelAuth capability (harmony_app): .with(ZitadelAuth::oidc(issuer) .project(..).redirect(..)) provisions a PKCE app (USER_AGENT/auth NONE) via ZitadelSetupScore + the export, and injects OIDC_ISSUER (plain) + OIDC_CLIENT_ID (configMapKeyRef). Keeps .secret(...) for other config. - Validated end-to-end against a live local Zitadel + k3d (gated #[ignore] test): PKCE app created in Zitadel; client_id mirrored into the ConfigMap. --- harmony/src/modules/zitadel/mod.rs | 4 +- harmony/src/modules/zitadel/setup.rs | 151 ++++++++++++++++++++++++ harmony_app/src/capabilities.rs | 170 ++++++++++++++++++++++++++- harmony_app/src/lib.rs | 2 +- 4 files changed, 323 insertions(+), 4 deletions(-) diff --git a/harmony/src/modules/zitadel/mod.rs b/harmony/src/modules/zitadel/mod.rs index db1a0d15..fb34375e 100644 --- a/harmony/src/modules/zitadel/mod.rs +++ b/harmony/src/modules/zitadel/mod.rs @@ -6,8 +6,8 @@ pub use admin_auth::{ADMIN_API_SCOPES, DeviceCodeError, DeviceCodeFlowConfig, de pub use device_groups::ZitadelDeviceGroups; pub use setup::{ MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication, - ZitadelClientConfig, ZitadelMachineUser, ZitadelRole, ZitadelScheme, ZitadelSetupScore, - mint_device_credentials, + ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelMachineUser, ZitadelRole, + ZitadelScheme, ZitadelSetupScore, mint_device_credentials, }; use harmony_k8s::KubernetesDistribution; diff --git a/harmony/src/modules/zitadel/setup.rs b/harmony/src/modules/zitadel/setup.rs index a24c308e..75f902a3 100644 --- a/harmony/src/modules/zitadel/setup.rs +++ b/harmony/src/modules/zitadel/setup.rs @@ -1965,10 +1965,161 @@ impl Interpret for ZitadelSetupInterpret { } } +// --------------------------------------------------------------------------- +// ZitadelClientIdExportScore — publish a provisioned client_id in-cluster +// --------------------------------------------------------------------------- + +/// Reads a provisioned OIDC app's `client_id` from the Zitadel client cache +/// (written by [`ZitadelSetupScore`] earlier in the same run) and writes it +/// to a ConfigMap, so the deployed app can reference its client_id by +/// `configMapKeyRef`. The client_id is non-secret (PKCE), so a ConfigMap is +/// the right home. This is the Zitadel→app bridge the `ZitadelAuth` +/// capability composes after provisioning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZitadelClientIdExportScore { + /// OIDC app name whose client_id to export (matches the provisioned app). + pub app_name: String, + pub configmap_name: String, + pub namespace: String, + /// Key under which to store the client_id (e.g. `OIDC_CLIENT_ID`). + pub key: String, +} + +impl Score for ZitadelClientIdExportScore { + fn name(&self) -> String { + format!("ZitadelClientIdExport({})", self.app_name) + } + fn create_interpret(&self) -> Box> { + Box::new(ZitadelClientIdExportInterpret { + score: self.clone(), + }) + } +} + +#[derive(Debug, Clone)] +struct ZitadelClientIdExportInterpret { + score: ZitadelClientIdExportScore, +} + +#[async_trait] +impl Interpret for ZitadelClientIdExportInterpret { + async fn execute( + &self, + inventory: &Inventory, + topology: &T, + ) -> Result { + use crate::modules::k8s::resource::K8sResourceScore; + use k8s_openapi::api::core::v1::ConfigMap; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use std::collections::BTreeMap; + + let s = &self.score; + let config = ZitadelClientConfig::load().ok_or_else(|| { + InterpretError::new( + "Zitadel client cache not found — run ZitadelSetupScore first".to_string(), + ) + })?; + let client_id = config.client_id(&s.app_name).ok_or_else(|| { + InterpretError::new(format!( + "client_id for app '{}' not in cache — was it provisioned?", + s.app_name + )) + })?; + + let mut data = BTreeMap::new(); + data.insert(s.key.clone(), client_id.clone()); + let cm = ConfigMap { + metadata: ObjectMeta { + name: Some(s.configmap_name.clone()), + namespace: Some(s.namespace.clone()), + ..Default::default() + }, + data: Some(data), + ..Default::default() + }; + K8sResourceScore::single(cm, Some(s.namespace.clone())) + .interpret(inventory, topology) + .await?; + + Ok(Outcome::success(format!( + "Exported client_id for '{}' to ConfigMap {}/{}", + s.app_name, s.namespace, s.configmap_name + ))) + } + fn get_name(&self) -> InterpretName { + InterpretName::Custom("ZitadelClientIdExport") + } + fn get_version(&self) -> Version { + Version::from("0.1.0").expect("static version") + } + fn get_status(&self) -> InterpretStatus { + InterpretStatus::QUEUED + } + fn get_children(&self) -> Vec { + vec![] + } +} + #[cfg(test)] mod tests { use super::*; + /// Live validation against a local Zitadel (docker) + k3d. Provisions a + /// PKCE app and exports its client_id to a ConfigMap, then the caller + /// checks both via the Zitadel API / `kubectl`. Run: + /// HARMONY_TEST_KUBECONFIG= cargo test -p harmony \ + /// pkce_provision_and_export_live -- --ignored --nocapture + #[tokio::test] + #[ignore = "needs live Zitadel at :8080 + k3d with iam-admin-pat seeded in ns timesheet"] + async fn pkce_provision_and_export_live() { + use crate::score::Score; + use crate::topology::{K8sAnywhereConfig, K8sAnywhereTopology, Topology}; + + let kubeconfig = + std::env::var("HARMONY_TEST_KUBECONFIG").expect("set HARMONY_TEST_KUBECONFIG"); + let topo = + K8sAnywhereTopology::with_config(K8sAnywhereConfig::kubeconfig(kubeconfig, None)); + topo.ensure_ready().await.expect("prepare topology"); + let inv = crate::inventory::Inventory::autoload(); + + let setup = ZitadelSetupScore { + host: "localhost".to_string(), + scheme: Default::default(), + port: None, + skip_tls: false, + endpoint: Some("http://localhost:8080".to_string()), + namespace: "timesheet".to_string(), + admin_org_id: None, + applications: vec![ZitadelApplication { + project_name: "folk".to_string(), + app_name: "timesheet".to_string(), + app_type: ZitadelAppType::WebPkce { + redirect_uris: vec!["https://timesheet.nationtech.io/callback".to_string()], + post_logout_redirect_uris: vec![], + }, + }], + api_apps: vec![], + roles: vec![], + machine_users: vec![], + groups_claim_action: false, + }; + setup + .interpret(&inv, &topo) + .await + .expect("provision PKCE app"); + + let export = ZitadelClientIdExportScore { + app_name: "timesheet".to_string(), + configmap_name: "timesheet-oidc".to_string(), + namespace: "timesheet".to_string(), + key: "OIDC_CLIENT_ID".to_string(), + }; + export + .interpret(&inv, &topo) + .await + .expect("export client_id"); + } + #[test] fn user_grant_key_round_trips_uniquely() { let k1 = ZitadelClientConfig::user_grant_key("alice", "fleet"); diff --git a/harmony_app/src/capabilities.rs b/harmony_app/src/capabilities.rs index 9ad6eb6f..38eb7313 100644 --- a/harmony_app/src/capabilities.rs +++ b/harmony_app/src/capabilities.rs @@ -15,10 +15,13 @@ use harmony::modules::monitoring::kube_prometheus::helm_prometheus_alert_score:: use harmony::modules::monitoring::kube_prometheus::prometheus::KubePrometheus; use harmony::modules::postgresql::K8sPostgreSQLScore; use harmony::modules::postgresql::capability::PostgreSQLConfig; +use harmony::modules::zitadel::{ + ZitadelAppType, ZitadelApplication, ZitadelClientIdExportScore, ZitadelSetupScore, +}; use harmony::score::Score; use harmony::topology::K8sAnywhereTopology; use harmony::topology::oberservability::monitoring::AlertReceiver; -use k8s_openapi::api::core::v1::{EnvVar, EnvVarSource, SecretKeySelector}; +use k8s_openapi::api::core::v1::{ConfigMapKeySelector, EnvVar, EnvVarSource, SecretKeySelector}; use crate::Profile; @@ -144,6 +147,131 @@ impl Capability for Monitoring { } } +/// OIDC login via the tenant's Zitadel — a PKCE public client (no secret), +/// for SPA/web frontends. Provisions the app in Zitadel, publishes its +/// `client_id` to the `-oidc` ConfigMap, and injects `OIDC_ISSUER` +/// (plain) + `OIDC_CLIENT_ID` (by `configMapKeyRef`) into the app — wired by +/// reference. For other config values, keep using `.secret(...)`. +pub struct ZitadelAuth { + issuer: String, + project: Option, + redirect_uris: Vec, + post_logout_redirect_uris: Vec, + endpoint: Option, + pat_namespace: String, +} + +impl ZitadelAuth { + /// `issuer` is the app-facing OIDC issuer URL (e.g. the tenant's Zitadel). + pub fn oidc(issuer: impl Into) -> Self { + Self { + issuer: issuer.into(), + project: None, + redirect_uris: vec![], + post_logout_redirect_uris: vec![], + endpoint: None, + pat_namespace: "zitadel".to_string(), + } + } + /// Zitadel project to create the app in (defaults to the app name). + pub fn project(mut self, project: impl Into) -> Self { + self.project = Some(project.into()); + self + } + pub fn redirect(mut self, uri: impl Into) -> Self { + self.redirect_uris.push(uri.into()); + self + } + pub fn post_logout(mut self, uri: impl Into) -> Self { + self.post_logout_redirect_uris.push(uri.into()); + self + } + /// Override how the provisioner reaches Zitadel's API (e.g. a local + /// `http://localhost:8080`); defaults to the issuer. + pub fn endpoint(mut self, endpoint: impl Into) -> Self { + self.endpoint = Some(endpoint.into()); + self + } + /// Namespace holding the `iam-admin-pat` secret (default `zitadel`). + pub fn pat_namespace(mut self, ns: impl Into) -> Self { + self.pat_namespace = ns.into(); + self + } + + fn configmap(app: &AppRef) -> String { + format!("{}-oidc", app.name) + } +} + +/// Hostname (no scheme/port/path) for a URL — Zitadel's `Host:` header. +fn host_of(url: &str) -> String { + let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url); + after_scheme + .split('/') + .next() + .unwrap_or(after_scheme) + .split(':') + .next() + .unwrap_or(after_scheme) + .to_string() +} + +impl Capability for ZitadelAuth { + fn scores(&self, app: &AppRef) -> Vec>> { + let project = self.project.clone().unwrap_or_else(|| app.name.to_string()); + let setup = ZitadelSetupScore { + host: host_of(self.endpoint.as_deref().unwrap_or(&self.issuer)), + scheme: Default::default(), + port: None, + skip_tls: false, + endpoint: self.endpoint.clone(), + namespace: self.pat_namespace.clone(), + admin_org_id: None, + applications: vec![ZitadelApplication { + project_name: project, + app_name: app.name.to_string(), + app_type: ZitadelAppType::WebPkce { + redirect_uris: self.redirect_uris.clone(), + post_logout_redirect_uris: self.post_logout_redirect_uris.clone(), + }, + }], + api_apps: vec![], + roles: vec![], + machine_users: vec![], + groups_claim_action: false, + }; + let export = ZitadelClientIdExportScore { + app_name: app.name.to_string(), + configmap_name: Self::configmap(app), + namespace: app.namespace.to_string(), + key: "OIDC_CLIENT_ID".to_string(), + }; + vec![Box::new(setup), Box::new(export)] + } + + fn env(&self, app: &AppRef) -> Vec { + vec![ + EnvVar { + name: "OIDC_ISSUER".to_string(), + value: Some(self.issuer.clone()), + ..Default::default() + }, + EnvVar { + name: "OIDC_CLIENT_ID".to_string(), + value_from: Some(EnvVarSource { + config_map_key_ref: Some(ConfigMapKeySelector { + name: Self::configmap(app), + key: "OIDC_CLIENT_ID".to_string(), + optional: Some(true), + }), + ..Default::default() + }), + ..Default::default() + }, + ] + } +} + #[cfg(test)] mod tests { use super::*; @@ -195,4 +323,44 @@ mod tests { let scores = Monitoring::new().scores(&app()); assert_eq!(scores.len(), 1); } + + #[test] + fn zitadel_auth_wires_issuer_plain_and_client_id_by_reference() { + let env = ZitadelAuth::oidc("https://sso.example") + .redirect("https://ts.example/callback") + .env(&app()); + let issuer = env.iter().find(|e| e.name == "OIDC_ISSUER").unwrap(); + assert_eq!(issuer.value.as_deref(), Some("https://sso.example")); + + let cid = env.iter().find(|e| e.name == "OIDC_CLIENT_ID").unwrap(); + let sel = cid + .value_from + .as_ref() + .unwrap() + .config_map_key_ref + .as_ref() + .unwrap(); + assert_eq!(sel.name, "ts-oidc"); + assert_eq!(sel.key, "OIDC_CLIENT_ID"); + assert!(cid.value.is_none(), "client_id is referenced, not inlined"); + } + + #[test] + fn zitadel_auth_provisions_then_exports() { + // Two scores, in order: provision the OIDC app, then publish its + // client_id to the ConfigMap. + let scores = ZitadelAuth::oidc("https://sso.example").scores(&app()); + assert_eq!(scores.len(), 2); + assert!(scores[0].name().contains("ZitadelSetup")); + assert!(scores[1].name().contains("ZitadelClientIdExport")); + } + + #[test] + fn host_of_strips_scheme_port_path() { + assert_eq!( + host_of("https://sso.nationtech.io/oauth"), + "sso.nationtech.io" + ); + assert_eq!(host_of("http://localhost:8080"), "localhost"); + } } diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index 1b3ac4cb..8ecf14ec 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -23,6 +23,6 @@ pub use app::{ AppIdentity, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, WorkloadStatus, deploy, logs, ship, status, }; -pub use capabilities::{AppRef, Capability, Monitoring, Postgres}; +pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth}; pub use context::{AppContext, ClusterAccess}; pub use profile::Profile; -- 2.39.5 From 5235e46d1e525f394a4063b53551b34e6e0dc385 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 16:46:41 -0400 Subject: [PATCH 18/20] fix(dx): --config for the contexts file; rewrite stale example README - app_main gains -C/--config to point at the contexts file from any cwd (the 'no .harmony/contexts.toml found' trap); error now names the flag. - examples/compose_java_react/README rewritten to the declarative ComposeDeploy + verb/--context surface; drops removed flags (--host/--storage-class/ --published/--yes/compose-publish) and documents .with(...) capabilities. (Both flagged by the fresh-eyes DX reviews.) --- examples/compose_java_react/README.md | 101 ++++++++++++++------------ harmony_app/src/context.rs | 16 +++- harmony_cli/src/app.rs | 7 +- 3 files changed, 76 insertions(+), 48 deletions(-) diff --git a/examples/compose_java_react/README.md b/examples/compose_java_react/README.md index 120aa3c8..109271b1 100644 --- a/examples/compose_java_react/README.md +++ b/examples/compose_java_react/README.md @@ -1,9 +1,9 @@ # Deploy a Java + React app from its `docker-compose.yml` -Demonstrates deploying an existing containerized app to Kubernetes with -Harmony by **importing its `docker-compose.yml`** — no hand-written -manifests, no Argo. The compose file stays the source of truth for the -app's *base* shape; deploy-only concerns live in typed Rust. +Deploys an existing containerized app to Kubernetes with Harmony by +**importing its `docker-compose.yml`** — no hand-written manifests, no Argo. +The compose file stays the source of truth for the app's *base* shape; +deploy-only concerns are typed Rust. ``` app/ the "customer" project (their existing repo) @@ -11,59 +11,70 @@ app/ the "customer" project (their existing repo) backend/ (Java + SQLite, Dockerfile) frontend/ (React + nginx, Dockerfile) Harmony.toml identity only — the app name (ADR-026 §3) +.harmony/contexts.toml deploy targets (local / prod), checked in src/ compose.rs import docker-compose → typed model (loud on the unsupported) - chart.rs model + deploy knobs → a hydrated helm chart (typed k8s) - publish.rs docker build/push each service + helm package/push the chart + chart.rs model + profile knobs → a hydrated helm chart (typed k8s) + publish.rs build images + (push to a registry | import to k3d) score.rs ComposeAppScore: helm upgrade --install + Ingress - main.rs `compose-deploy` bin - bin/publish `compose-publish` bin + deploy.rs ComposeDeploy — the declarative builder (impls HarmonyApp) + main.rs the whole app, declared ``` +## The app, declared (`main.rs`) + +```rust +let app = ComposeDeploy::from_dir("timesheet", "./app")? + .expose("frontend", "timesheet.example.harmony.mcd") + .with(Postgres::managed()); // a managed CNPG database, wired in +harmony_cli::app::app_main(app).await +``` + +That declaration gets you `ship` / `deploy` / `status` / `logs` over any +`--context`, all from `harmony_app`. See +[Application CLI](../../docs/guides/application-cli.md) and +[Application Capabilities](../../docs/guides/application-capabilities.md). + ## Design -- **compose = base, Score = deploy knobs.** The importer reads only - images, ports, env, and volumes. Replicas, RWX storage class, ingress - host, and rolling strategy are *not* in compose (or `Harmony.toml`) — - they are typed fields on the deploy `Score` (ADR-026 §6). A behavioral - knob in compose or `Harmony.toml` is a defect. -- **Build / publish / deploy split — the fleet pattern.** `publish` - builds + pushes the per-service images and a hydrated helm chart; - `deploy` is a `Score` that `helm upgrade --install`s it and layers the - public Ingress. No `ApplicationScore`, no ArgoCD. -- **Same Scores local → prod.** Only the topology changes - (`K8sAnywhereTopology::from_env`): local k3d to test, a tenant in prod. -- **Loud, never lossy.** Bind mounts and unparseable ports are hard - errors; `depends_on`/`command`/`restart` are warned, never silently - dropped. +- **compose = base, profile = deploy knobs.** The importer reads only images, + ports, env, volumes. Replicas, storage class, TLS, rolling strategy come + from the `Profile` the context carries (ADR-026 §6/§7), not from compose or + `Harmony.toml`. A behavioral knob in either is a defect. +- **Capabilities compose upward.** `.with(Postgres::managed())` / + `.with(Monitoring::new()...)` / `.with(ZitadelAuth::oidc(...))` each deploy + their own Scores and wire into the app **by reference** (a DB URL via + `secretKeyRef`, a client_id via `configMapKeyRef`) — no `ApplicationScore`, + no ArgoCD. +- **Same definition, local → prod.** Only the context changes; the *same* + `ComposeAppScore` converges on local k3d or a tenant cluster. +- **Loud, never lossy.** Bind mounts and unparseable ports are hard errors; + `depends_on`/`command`/`restart` are warned, never silently dropped. -## Run it - -Local (build, don't push; render the chart from compose): +## Run it (local k3d) ```sh -cargo run -p example-compose-java-react --bin compose-publish -- --no-push -cargo run -p example-compose-java-react --bin compose-deploy -- \ - --host timesheet.local --storage-class --yes +k3d cluster create compose-local --servers 1 --wait +kubectl --context k3d-compose-local create namespace timesheet + +cd examples/compose_java_react # so the CLI finds .harmony/contexts.toml +cargo run --bin compose-deploy -- ship --context local # build → k3d import → deploy (+ Postgres) +cargo run --bin compose-deploy -- status --context local +cargo run --bin compose-deploy -- logs --context local --tail 50 ``` -CD / prod (push to the registry, then install the published chart): +Running from elsewhere? Point at the contexts file with +`--config /.harmony/contexts.toml` (a context is always required — there +is no default, so you never hit the wrong cluster). -```sh -compose-publish --version 0.1.0 -compose-deploy --published --version 0.1.0 \ - --namespace -timesheet --host timesheet..example \ - --storage-class cephfs --cluster-issuer letsencrypt --yes -``` +**Production** is the same verbs against a prod context (cluster + creds +brokered from OpenBao); see the `folk_ci_deploy` setup for the full tenant +wiring. -`compose-deploy --help` lists every deploy knob. +## Storage note (SQLite) -## ⚠️ SQLite + RollingUpdate + RWX - -This demo ships `--rolling` (RollingUpdate) with a `ReadWriteMany` PVC, as -requested. During a rollout two pods briefly share the same SQLite file, -which SQLite does not make safe for concurrent writers over a networked -filesystem. It is tolerable only for an effectively single-writer, -low-traffic app. For true safe zero-downtime, either deploy with -`--recreate` (one writer at a time, brief downtime) or migrate the store -to Postgres. +`Profile::Local` deploys single-replica with `Recreate` on a `ReadWriteOnce` +volume — safe for the demo's single-writer SQLite (one pod at a time). Prod +(`Profile::Prod`) replicates with `RollingUpdate` on `ReadWriteMany`; for a +real multi-writer store use `.with(Postgres::managed())` (the demo already +provisions it) and point the app at `DATABASE_URL`. diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index 1d8265ef..349f8666 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -62,8 +62,17 @@ impl AppContext { name: &str, version: impl Into, local_config_dir: Option, + contexts_file: Option, ) -> Result { - let path = find_contexts_file()?; + let path = match contexts_file { + Some(p) => { + if !p.is_file() { + bail!("contexts file not found: {}", p.display()); + } + p + } + None => find_contexts_file()?, + }; let raw = std::fs::read_to_string(&path) .with_context(|| format!("reading {}", path.display()))?; let file: ContextsFile = @@ -146,7 +155,10 @@ fn find_contexts_file() -> Result { return Ok(candidate); } if !dir.pop() { - bail!("no .harmony/contexts.toml found (searched up from the cwd)"); + bail!( + "no .harmony/contexts.toml found (searched up from the cwd) — \ + run from the project dir or pass --config " + ); } } } diff --git a/harmony_cli/src/app.rs b/harmony_cli/src/app.rs index e3d9bddf..9ca4fc50 100644 --- a/harmony_cli/src/app.rs +++ b/harmony_cli/src/app.rs @@ -29,6 +29,11 @@ struct AppCli { /// (`/DeploySecrets.toml`). #[arg(long, global = true)] local_config: Option, + + /// Path to the contexts file. Default: the nearest + /// `.harmony/contexts.toml` found by walking up from the current dir. + #[arg(long, short = 'C', global = true, value_name = "FILE")] + config: Option, } #[derive(Subcommand, Debug)] @@ -55,7 +60,7 @@ pub async fn app_main(app: A) -> Result<()> { let context = cli .context .context("--context is required (there is no default context)")?; - let ctx = AppContext::resolve(&context, cli.tag, cli.local_config).await?; + let ctx = AppContext::resolve(&context, cli.tag, cli.local_config, cli.config).await?; match cli.verb { Verb::Ship => render_deploy(harmony_app::ship(&app, &ctx).await?), -- 2.39.5 From b2c3c01081d95f0a9b1d5664300c5078fdf6172a Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 18:17:39 -0400 Subject: [PATCH 19/20] refactor(app): promote compose deploy stack into harmony_app; example is just main.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the compose importer, chart generator, ComposeAppScore, publish bricks, and the ComposeDeploy builder out of the example into harmony_app (they needed harmony_app::Profile/Capability, so harmony core would be circular — harmony_app is the home, per decision A). The example is now a 23-line main.rs + app/ + contexts; folk imports from harmony_app too. Tests + the helm lint/template integration test moved along. 29 lib + 1 integration test green. --- examples/compose_java_react/Cargo.toml | 27 +------ .../compose_java_react/src/bin/publish.rs | 73 ------------------- examples/compose_java_react/src/lib.rs | 44 ----------- examples/compose_java_react/src/main.rs | 2 +- harmony_app/Cargo.toml | 5 ++ .../src/chart.rs | 2 +- .../src/compose.rs | 2 +- .../src/deploy.rs | 4 +- harmony_app/src/lib.rs | 9 +++ .../src/publish.rs | 0 .../src/score.rs | 0 .../tests/helm_render.rs | 4 +- 12 files changed, 22 insertions(+), 150 deletions(-) delete mode 100644 examples/compose_java_react/src/bin/publish.rs delete mode 100644 examples/compose_java_react/src/lib.rs rename {examples/compose_java_react => harmony_app}/src/chart.rs (99%) rename {examples/compose_java_react => harmony_app}/src/compose.rs (99%) rename {examples/compose_java_react => harmony_app}/src/deploy.rs (98%) rename {examples/compose_java_react => harmony_app}/src/publish.rs (100%) rename {examples/compose_java_react => harmony_app}/src/score.rs (100%) rename {examples/compose_java_react => harmony_app}/tests/helm_render.rs (92%) diff --git a/examples/compose_java_react/Cargo.toml b/examples/compose_java_react/Cargo.toml index 1c3752d6..151af6e1 100644 --- a/examples/compose_java_react/Cargo.toml +++ b/examples/compose_java_react/Cargo.toml @@ -4,39 +4,14 @@ edition = "2024" version.workspace = true readme.workspace = true license.workspace = true -description = "Deploy a Java+React app to Kubernetes with Harmony by importing its existing docker-compose (publish + deploy, fleet pattern). See ADR-026." +description = "Deploy a Java+React app to Kubernetes by importing its docker-compose. The whole example is one main.rs; the machinery lives in harmony_app. See ADR-026." -[lib] -name = "example_compose_java_react" -path = "src/lib.rs" - -# `deploy` — converge the app onto a cluster (helm upgrade --install). [[bin]] name = "compose-deploy" path = "src/main.rs" -# `publish` — build + push the per-service images and the hydrated chart. -[[bin]] -name = "compose-publish" -path = "src/bin/publish.rs" - [dependencies] -harmony = { path = "../../harmony" } harmony_app = { path = "../../harmony_app" } harmony_cli = { path = "../../harmony_cli" } -harmony_types = { path = "../../harmony_types" } - -docker-compose-types = "0.24" anyhow = { workspace = true } -async-trait = { workspace = true } -clap = { workspace = true } -fqdn = "0.5.2" -k8s-openapi = { workspace = true } -log = { workspace = true } -env_logger = { workspace = true } -serde = { workspace = true } -serde_yaml = { workspace = true } -tempfile = "3" -thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } -toml = { workspace = true } diff --git a/examples/compose_java_react/src/bin/publish.rs b/examples/compose_java_react/src/bin/publish.rs deleted file mode 100644 index 259fb667..00000000 --- a/examples/compose_java_react/src/bin/publish.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! `compose-publish` — build the per-service images and (unless `--no-push`) -//! push them + the hydrated chart to the registry. `docker`/`helm` must be -//! on PATH. - -use std::path::PathBuf; - -use anyhow::{Context, Result, bail}; -use clap::Parser; - -use example_compose_java_react::{ - ComposeApp, DeployConfig, HarmonyManifest, - publish::{build_images, push_to_registry}, -}; - -#[derive(Parser, Debug)] -#[command( - name = "compose-publish", - about = "Build + push the app images and chart" -)] -struct Cli { - #[arg(long, default_value = env!("CARGO_MANIFEST_DIR"))] - project_dir: PathBuf, - #[arg(long)] - compose_dir: Option, - #[arg(long, default_value = "hub.nationtech.io")] - registry: String, - #[arg(long, default_value = "harmony")] - project: String, - #[arg(long, default_value = "0.1.0")] - version: String, - /// Build only; skip the push (local smoke-test). - #[arg(long)] - no_push: bool, - #[arg(long, env = "REGISTRY_USER")] - user: Option, - #[arg(long, env = "REGISTRY_TOKEN")] - token: Option, -} - -fn main() -> Result<()> { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - let cli = Cli::parse(); - - let manifest = HarmonyManifest::from_dir(&cli.project_dir)?; - let compose_dir = cli - .compose_dir - .unwrap_or_else(|| cli.project_dir.join("app")); - let app = ComposeApp::from_dir(&compose_dir).context("importing docker-compose")?; - - let deploy = DeployConfig { - app_name: manifest.app.name, - chart_version: cli.version.clone(), - registry: cli.registry, - project: cli.project, - version: cli.version, - storage_class: None, - volume_size: "1Gi".to_string(), - replicas: 1, - rolling: true, - extra_env: vec![], - }; - - build_images(&app, &deploy)?; - if !cli.no_push { - let (Some(user), Some(token)) = (cli.user, cli.token) else { - bail!( - "--user/--token (or REGISTRY_USER/REGISTRY_TOKEN) required to push; --no-push to build only" - ); - }; - push_to_registry(&app, &deploy, &user, &token)?; - } - Ok(()) -} diff --git a/examples/compose_java_react/src/lib.rs b/examples/compose_java_react/src/lib.rs deleted file mode 100644 index 2b4e7dcd..00000000 --- a/examples/compose_java_react/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Deploy a Java+React app to Kubernetes by importing its existing -//! `docker-compose.yml` (ADR-026). The compose file is the source of -//! truth for the app's base shape; deploy-only knobs live in -//! [`DeployConfig`]/[`ComposeAppScore`]. Build/publish/deploy follow the -//! fleet pattern — no `ApplicationScore`, no ArgoCD. - -pub mod chart; -pub mod compose; -pub mod deploy; -pub mod publish; -pub mod score; - -pub use chart::{DeployConfig, cluster_issuer_for, service_image}; -pub use compose::ComposeApp; -pub use deploy::ComposeDeploy; -pub use harmony_app::Profile; -pub use harmony_app::{Capability, Monitoring, Postgres}; -pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart}; - -use std::path::Path; - -use anyhow::{Context, Result}; -use serde::Deserialize; - -/// Identity-only project manifest (ADR-026 §3): the app *name*, nothing -/// behavioral. A behavioral knob here is a defect — it belongs in a Score. -#[derive(Debug, Deserialize)] -pub struct HarmonyManifest { - pub app: AppIdentity, -} - -#[derive(Debug, Deserialize)] -pub struct AppIdentity { - pub name: String, -} - -impl HarmonyManifest { - pub fn from_dir(dir: &Path) -> Result { - let path = dir.join("Harmony.toml"); - let raw = std::fs::read_to_string(&path) - .with_context(|| format!("reading {}", path.display()))?; - toml::from_str(&raw).with_context(|| format!("parsing {}", path.display())) - } -} diff --git a/examples/compose_java_react/src/main.rs b/examples/compose_java_react/src/main.rs index 514f77db..fa2a8215 100644 --- a/examples/compose_java_react/src/main.rs +++ b/examples/compose_java_react/src/main.rs @@ -10,7 +10,7 @@ //! and the context model all come from `harmony_app` — this file only //! *declares* the app. -use example_compose_java_react::{ComposeDeploy, Postgres}; +use harmony_app::{ComposeDeploy, Postgres}; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/harmony_app/Cargo.toml b/harmony_app/Cargo.toml index bd6e94e7..3b333ea1 100644 --- a/harmony_app/Cargo.toml +++ b/harmony_app/Cargo.toml @@ -9,12 +9,17 @@ license.workspace = true harmony = { path = "../harmony" } harmony-k8s = { path = "../harmony-k8s" } harmony_config = { path = "../harmony_config" } +harmony_types = { path = "../harmony_types" } async-trait.workspace = true anyhow.workspace = true tokio.workspace = true serde = { workspace = true } +serde_yaml = { workspace = true } schemars = "0.8" tempfile.workspace = true toml.workspace = true log.workspace = true k8s-openapi.workspace = true +thiserror.workspace = true +docker-compose-types = "0.24" +fqdn = "0.5.2" diff --git a/examples/compose_java_react/src/chart.rs b/harmony_app/src/chart.rs similarity index 99% rename from examples/compose_java_react/src/chart.rs rename to harmony_app/src/chart.rs index 8ed08347..48dc022f 100644 --- a/examples/compose_java_react/src/chart.rs +++ b/harmony_app/src/chart.rs @@ -25,8 +25,8 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; +use crate::Profile; use harmony::modules::application::helm::{HelmChart, HelmResourceKind}; -use harmony_app::Profile; use crate::compose::{ComposeApp, ComposeService}; diff --git a/examples/compose_java_react/src/compose.rs b/harmony_app/src/compose.rs similarity index 99% rename from examples/compose_java_react/src/compose.rs rename to harmony_app/src/compose.rs index 19f176b2..e7d34e74 100644 --- a/examples/compose_java_react/src/compose.rs +++ b/harmony_app/src/compose.rs @@ -380,7 +380,7 @@ volumes: /// source of truth and a contract for the chart tests. #[test] fn the_examples_compose_file_imports() { - let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("app"); + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/compose_java_react/app"); let app = ComposeApp::from_dir(&dir).expect("import example compose"); assert_eq!(app.services.len(), 2); assert_eq!(app.mounted_volumes(), vec!["timesheet-data".to_string()]); diff --git a/examples/compose_java_react/src/deploy.rs b/harmony_app/src/deploy.rs similarity index 98% rename from examples/compose_java_react/src/deploy.rs rename to harmony_app/src/deploy.rs index 0c182b92..7c415780 100644 --- a/examples/compose_java_react/src/deploy.rs +++ b/harmony_app/src/deploy.rs @@ -7,11 +7,11 @@ use std::collections::BTreeMap; use std::path::Path; +use crate::{AppContext, AppIdentity, AppRef, Capability, HarmonyApp, Profile}; use anyhow::{Result, anyhow}; use async_trait::async_trait; use harmony::score::Score; use harmony::topology::K8sAnywhereTopology; -use harmony_app::{AppContext, AppIdentity, AppRef, Capability, HarmonyApp, Profile}; use crate::chart::{DeployConfig, cluster_issuer_for}; use crate::compose::ComposeApp; @@ -181,7 +181,7 @@ impl HarmonyApp for ComposeDeploy { #[cfg(test)] mod tests { use super::*; - use harmony_app::Postgres; + use crate::Postgres; fn fixture() -> ComposeApp { ComposeApp::parse( diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index 8ecf14ec..db2117b9 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -16,13 +16,22 @@ pub mod app; pub mod capabilities; +pub mod chart; +pub mod compose; pub mod context; +pub mod deploy; pub mod profile; +pub mod publish; +pub mod score; pub use app::{ AppIdentity, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, WorkloadStatus, deploy, logs, ship, status, }; pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth}; +pub use chart::{DeployConfig, cluster_issuer_for, service_image}; +pub use compose::ComposeApp; pub use context::{AppContext, ClusterAccess}; +pub use deploy::ComposeDeploy; pub use profile::Profile; +pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart}; diff --git a/examples/compose_java_react/src/publish.rs b/harmony_app/src/publish.rs similarity index 100% rename from examples/compose_java_react/src/publish.rs rename to harmony_app/src/publish.rs diff --git a/examples/compose_java_react/src/score.rs b/harmony_app/src/score.rs similarity index 100% rename from examples/compose_java_react/src/score.rs rename to harmony_app/src/score.rs diff --git a/examples/compose_java_react/tests/helm_render.rs b/harmony_app/tests/helm_render.rs similarity index 92% rename from examples/compose_java_react/tests/helm_render.rs rename to harmony_app/tests/helm_render.rs index 39ea17ec..69c4246d 100644 --- a/examples/compose_java_react/tests/helm_render.rs +++ b/harmony_app/tests/helm_render.rs @@ -6,7 +6,7 @@ use std::path::Path; use std::process::Command; -use example_compose_java_react::{ComposeApp, DeployConfig, chart::build_chart}; +use harmony_app::{ComposeApp, DeployConfig, chart::build_chart}; fn helm_present() -> bool { Command::new("helm") @@ -23,7 +23,7 @@ fn generated_chart_passes_helm_lint_and_template() { return; } - let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("app"); + let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/compose_java_react/app"); let app = ComposeApp::from_dir(&app_dir).expect("import compose"); let cfg = DeployConfig { app_name: "timesheet".to_string(), -- 2.39.5 From 0bcfe7941ebba72d701a42121640969ddc3f16de Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 22 Jun 2026 18:32:34 -0400 Subject: [PATCH 20/20] refactor(app): make the application layer topology-generic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HarmonyApp and Capability are now generic over the target Topology instead of hardwired to K8sAnywhereTopology. Each concrete capability is implemented only for topologies that can host its Scores — Postgres for T: K8sclient+HelmCommand, Monitoring for T: HelmCommand+TenantManager, ZitadelAuth for T: K8sclient — so .with(X) is a compile error on a topology that can't run X (the framework's compile-time-capability thesis, now applied to the app layer). ComposeDeploy and the deploy/ship verbs carry T through; status/logs stay generic. The CLI front-end (app_main) pins K8sAnywhereTopology; a different front-end can drive another topology. Example main.rs is unchanged — T infers from app_main. 29+1 harmony_app, 11 harmony_cli tests green. --- Cargo.lock | 19 ++++-------- harmony_app/src/app.rs | 45 ++++++++++++++++++----------- harmony_app/src/capabilities.rs | 51 ++++++++++++++++++++++----------- harmony_app/src/deploy.rs | 33 ++++++++++++--------- harmony_cli/src/app.rs | 9 ++++-- 5 files changed, 93 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3373bcc..74ba26cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2831,23 +2831,9 @@ name = "example-compose-java-react" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", - "clap", - "docker-compose-types", - "env_logger", - "fqdn", - "harmony", "harmony_app", "harmony_cli", - "harmony_types", - "k8s-openapi", - "log", - "serde", - "serde_yaml", - "tempfile", - "thiserror 2.0.18", "tokio", - "toml", ] [[package]] @@ -4332,14 +4318,19 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "docker-compose-types", + "fqdn", "harmony", "harmony-k8s", "harmony_config", + "harmony_types", "k8s-openapi", "log", "schemars 0.8.22", "serde", + "serde_yaml", "tempfile", + "thiserror 2.0.18", "tokio", "toml", ] diff --git a/harmony_app/src/app.rs b/harmony_app/src/app.rs index dde4f644..f71978fe 100644 --- a/harmony_app/src/app.rs +++ b/harmony_app/src/app.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use harmony::inventory::Inventory; use harmony::maestro::Maestro; use harmony::score::Score; -use harmony::topology::K8sAnywhereTopology; +use harmony::topology::Topology; use k8s_openapi::api::apps::v1::Deployment; use k8s_openapi::api::core::v1::Pod; @@ -21,17 +21,18 @@ pub struct AppIdentity { pub namespace: String, } -/// A deployable application. An app crate implements this once; every UI -/// drives it through the verbs below. The topology is fixed to -/// `K8sAnywhereTopology` for now (the common app target); generalizing over -/// `Topology` is a later step. +/// A deployable application, **generic over its target `Topology`**. An app +/// crate implements this once; every UI drives it through the verbs below. +/// The Scores it returns carry their own capability bounds, so an app is +/// deployable to any topology that can host them — a missing capability is a +/// compile error, not a runtime surprise. #[async_trait] -pub trait HarmonyApp: Send + Sync { +pub trait HarmonyApp: Send + Sync { fn identity(&self) -> AppIdentity; /// The desired state for this context — the app composes its Scores, /// branching on `ctx.profile()`. Called by `deploy`/`ship`. - async fn scores(&self, ctx: &AppContext) -> Result>>>; + async fn scores(&self, ctx: &AppContext) -> Result>>>; /// Build + distribute images for `ctx` (push to a registry, or import to /// k3d). Default: nothing to build. Called by `ship`. @@ -74,13 +75,16 @@ pub struct PodLogs { // ---- the verbs ---- -/// Converge the app's Scores onto the context's cluster (no build). -pub async fn deploy(app: &dyn HarmonyApp, ctx: &AppContext) -> Result { +/// Converge the app's Scores onto `topology` (no build). +pub async fn deploy( + app: &dyn HarmonyApp, + topology: T, + ctx: &AppContext, +) -> Result { let scores = app.scores(ctx).await?; - let to_run: Vec>> = - scores.iter().map(|s| s.clone_box()).collect(); + let to_run: Vec>> = scores.iter().map(|s| s.clone_box()).collect(); - let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), ctx.topology()); + let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology); maestro.register_all(scores); maestro .prepare_topology() @@ -103,13 +107,20 @@ pub async fn deploy(app: &dyn HarmonyApp, ctx: &AppContext) -> Result Result { +pub async fn ship( + app: &dyn HarmonyApp, + topology: T, + ctx: &AppContext, +) -> Result { app.publish(ctx).await?; - deploy(app, ctx).await + deploy(app, topology, ctx).await } /// Workload readiness in the app's namespace (operational, read-only). -pub async fn status(app: &dyn HarmonyApp, ctx: &AppContext) -> Result { +pub async fn status( + app: &dyn HarmonyApp, + ctx: &AppContext, +) -> Result { let id = app.identity(); let client = ctx.k8s_client().await?; let deployments = client @@ -136,8 +147,8 @@ pub async fn status(app: &dyn HarmonyApp, ctx: &AppContext) -> Result( + app: &dyn HarmonyApp, ctx: &AppContext, tail: Option, ) -> Result> { diff --git a/harmony_app/src/capabilities.rs b/harmony_app/src/capabilities.rs index 38eb7313..c87b2f92 100644 --- a/harmony_app/src/capabilities.rs +++ b/harmony_app/src/capabilities.rs @@ -19,8 +19,9 @@ use harmony::modules::zitadel::{ ZitadelAppType, ZitadelApplication, ZitadelClientIdExportScore, ZitadelSetupScore, }; use harmony::score::Score; -use harmony::topology::K8sAnywhereTopology; use harmony::topology::oberservability::monitoring::AlertReceiver; +use harmony::topology::tenant::TenantManager; +use harmony::topology::{HelmCommand, K8sclient, Topology}; use k8s_openapi::api::core::v1::{ConfigMapKeySelector, EnvVar, EnvVarSource, SecretKeySelector}; use crate::Profile; @@ -33,9 +34,12 @@ pub struct AppRef<'a> { } /// An add-on a deployment declares with `.with(...)`: it deploys its own -/// Scores and injects env wired by k8s reference, never by value. -pub trait Capability: Send + Sync { - fn scores(&self, _app: &AppRef) -> Vec>> { +/// Scores and injects env wired by k8s reference, never by value. **Generic +/// over the target `Topology`** — a concrete capability is implemented only +/// for the topologies whose capabilities can host its Scores, so `.with(X)` +/// is a compile error on a topology that can't run `X`. +pub trait Capability: Send + Sync { + fn scores(&self, _app: &AppRef) -> Vec>> { vec![] } fn env(&self, _app: &AppRef) -> Vec { @@ -63,8 +67,8 @@ impl Postgres { } } -impl Capability for Postgres { - fn scores(&self, app: &AppRef) -> Vec>> { +impl Capability for Postgres { + fn scores(&self, app: &AppRef) -> Vec>> { let config = PostgreSQLConfig { cluster_name: Self::cluster(app), namespace: app.namespace.to_string(), @@ -133,8 +137,8 @@ fn downtime_alert(app: &AppRef) -> PrometheusAlertRule { ) } -impl Capability for Monitoring { - fn scores(&self, app: &AppRef) -> Vec>> { +impl Capability for Monitoring { + fn scores(&self, app: &AppRef) -> Vec>> { let group = AlertManagerRuleGroup::new( &format!("{}-availability", app.name), vec![downtime_alert(app)], @@ -216,8 +220,8 @@ fn host_of(url: &str) -> String { .to_string() } -impl Capability for ZitadelAuth { - fn scores(&self, app: &AppRef) -> Vec>> { +impl Capability for ZitadelAuth { + fn scores(&self, app: &AppRef) -> Vec>> { let project = self.project.clone().unwrap_or_else(|| app.name.to_string()); let setup = ZitadelSetupScore { host: host_of(self.endpoint.as_deref().unwrap_or(&self.issuer)), @@ -275,6 +279,18 @@ impl Capability for ZitadelAuth { #[cfg(test)] mod tests { use super::*; + use harmony::topology::K8sAnywhereTopology; + + // Pin a concrete topology for the (topology-independent) assertions. + fn scores_of( + c: &impl Capability, + a: &AppRef, + ) -> Vec>> { + c.scores(a) + } + fn env_of(c: &impl Capability, a: &AppRef) -> Vec { + c.env(a) + } fn app() -> AppRef<'static> { AppRef { @@ -286,7 +302,7 @@ mod tests { #[test] fn postgres_wires_database_url_by_reference() { - let env = Postgres::managed().env(&app()); + let env = env_of(&Postgres::managed(), &app()); let db = env.iter().find(|e| e.name == "DATABASE_URL").unwrap(); let sel = db .value_from @@ -302,7 +318,7 @@ mod tests { #[test] fn postgres_contributes_a_cluster_score() { - let scores = Postgres::managed().scores(&app()); + let scores = scores_of(&Postgres::managed(), &app()); assert_eq!(scores.len(), 1); assert!(scores[0].name().contains("PostgreSQL")); } @@ -320,15 +336,16 @@ mod tests { #[test] fn monitoring_contributes_one_alerting_score() { - let scores = Monitoring::new().scores(&app()); + let scores = scores_of(&Monitoring::new(), &app()); assert_eq!(scores.len(), 1); } #[test] fn zitadel_auth_wires_issuer_plain_and_client_id_by_reference() { - let env = ZitadelAuth::oidc("https://sso.example") - .redirect("https://ts.example/callback") - .env(&app()); + let env = env_of( + &ZitadelAuth::oidc("https://sso.example").redirect("https://ts.example/callback"), + &app(), + ); let issuer = env.iter().find(|e| e.name == "OIDC_ISSUER").unwrap(); assert_eq!(issuer.value.as_deref(), Some("https://sso.example")); @@ -349,7 +366,7 @@ mod tests { fn zitadel_auth_provisions_then_exports() { // Two scores, in order: provision the OIDC app, then publish its // client_id to the ConfigMap. - let scores = ZitadelAuth::oidc("https://sso.example").scores(&app()); + let scores = scores_of(&ZitadelAuth::oidc("https://sso.example"), &app()); assert_eq!(scores.len(), 2); assert!(scores[0].name().contains("ZitadelSetup")); assert!(scores[1].name().contains("ZitadelClientIdExport")); diff --git a/harmony_app/src/deploy.rs b/harmony_app/src/deploy.rs index 7c415780..607cac2e 100644 --- a/harmony_app/src/deploy.rs +++ b/harmony_app/src/deploy.rs @@ -11,7 +11,7 @@ use crate::{AppContext, AppIdentity, AppRef, Capability, HarmonyApp, Profile}; use anyhow::{Result, anyhow}; use async_trait::async_trait; use harmony::score::Score; -use harmony::topology::K8sAnywhereTopology; +use harmony::topology::{HelmCommand, K8sAnywhereTopology, K8sclient, Topology}; use crate::chart::{DeployConfig, cluster_issuer_for}; use crate::compose::ComposeApp; @@ -23,7 +23,10 @@ struct Endpoint { } /// A compose app, declared. Build it fluently, then hand it to `app_main`. -pub struct ComposeDeploy { +/// Generic over the target `Topology` (defaults to `K8sAnywhereTopology`, what +/// `app_main` drives); `.with(cap)` only compiles for capabilities the chosen +/// topology can host. +pub struct ComposeDeploy { name: String, namespace: String, project: String, @@ -31,10 +34,10 @@ pub struct ComposeDeploy { app: ComposeApp, expose: Option, app_secrets: BTreeMap, - capabilities: Vec>, + capabilities: Vec>>, } -impl ComposeDeploy { +impl ComposeDeploy { /// Import the app from a compose dir. `namespace`/`project` default to the /// name; override fluently. pub fn from_dir(name: impl Into, dir: impl AsRef) -> Result { @@ -83,7 +86,7 @@ impl ComposeDeploy { /// Add a capability (e.g. `Postgres::managed()`): it deploys its own /// Scores and wires itself into the app by reference. - pub fn with(mut self, capability: impl Capability + 'static) -> Self { + pub fn with(mut self, capability: impl Capability + 'static) -> Self { self.capabilities.push(Box::new(capability)); self } @@ -135,7 +138,7 @@ impl ComposeDeploy { } #[async_trait] -impl HarmonyApp for ComposeDeploy { +impl HarmonyApp for ComposeDeploy { fn identity(&self) -> AppIdentity { AppIdentity { name: self.name.clone(), @@ -143,11 +146,11 @@ impl HarmonyApp for ComposeDeploy { } } - async fn scores(&self, ctx: &AppContext) -> Result>>> { + async fn scores(&self, ctx: &AppContext) -> Result>>> { let app_score = self .score(ctx.profile(), ctx.version()) .map_err(|e| anyhow!(e))?; - let mut scores: Vec>> = vec![Box::new(app_score)]; + let mut scores: Vec>> = vec![Box::new(app_score)]; let app_ref = self.app_ref(ctx.profile()); for capability in &self.capabilities { scores.extend(capability.scores(&app_ref)); @@ -183,6 +186,10 @@ mod tests { use super::*; use crate::Postgres; + // The pure score-building logic is topology-independent; pin a concrete + // topology so the tests don't have to annotate every call. + type CD = ComposeDeploy; + fn fixture() -> ComposeApp { ComposeApp::parse( "name: t\nservices:\n frontend:\n build: .\n ports: [\"8081:80\"]\n", @@ -193,7 +200,7 @@ mod tests { #[test] fn namespace_and_project_default_to_name() { - let s = ComposeDeploy::from_compose("timesheet", fixture()) + let s = CD::from_compose("timesheet", fixture()) .score(Profile::Local, "0.1.0") .unwrap(); assert_eq!(s.namespace, "timesheet"); @@ -202,7 +209,7 @@ mod tests { #[test] fn local_profile_renders_locally_rwo_http() { - let s = ComposeDeploy::from_compose("ts", fixture()) + let s = CD::from_compose("ts", fixture()) .expose("frontend", "ts.local") .score(Profile::Local, "1.0.0") .unwrap(); @@ -218,7 +225,7 @@ mod tests { #[test] fn prod_profile_publishes_replicated_tls() { - let s = ComposeDeploy::from_compose("ts", fixture()) + let s = CD::from_compose("ts", fixture()) .registry("hub.x") .project("p") .expose("frontend", "ts.x") @@ -238,7 +245,7 @@ mod tests { #[test] fn secrets_are_carried_to_the_score() { - let s = ComposeDeploy::from_compose("ts", fixture()) + let s = CD::from_compose("ts", fixture()) .secret("DB_PASSWORD", "dev") .score(Profile::Local, "0.1.0") .unwrap(); @@ -247,7 +254,7 @@ mod tests { #[test] fn postgres_capability_wires_database_url_by_reference() { - let s = ComposeDeploy::from_compose("ts", fixture()) + let s = CD::from_compose("ts", fixture()) .with(Postgres::managed()) .score(Profile::Local, "0.1.0") .unwrap(); diff --git a/harmony_cli/src/app.rs b/harmony_cli/src/app.rs index 9ca4fc50..aafb2faf 100644 --- a/harmony_cli/src/app.rs +++ b/harmony_cli/src/app.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +use harmony::topology::K8sAnywhereTopology; use harmony_app::{AppContext, HarmonyApp}; #[derive(Parser, Debug)] @@ -52,7 +53,9 @@ enum Verb { } /// Entry point for a per-app deploy binary: `fn main() { app_main(MyApp) }`. -pub async fn app_main(app: A) -> Result<()> { +/// The CLI front-end drives `K8sAnywhereTopology`; the app layer itself is +/// topology-generic (a different front-end can drive another topology). +pub async fn app_main + 'static>(app: A) -> Result<()> { crate::cli_logger::init(); crate::cli_reporter::init(); let cli = AppCli::parse(); @@ -63,8 +66,8 @@ pub async fn app_main(app: A) -> Result<()> { let ctx = AppContext::resolve(&context, cli.tag, cli.local_config, cli.config).await?; match cli.verb { - Verb::Ship => render_deploy(harmony_app::ship(&app, &ctx).await?), - Verb::Deploy => render_deploy(harmony_app::deploy(&app, &ctx).await?), + Verb::Ship => render_deploy(harmony_app::ship(&app, ctx.topology(), &ctx).await?), + Verb::Deploy => render_deploy(harmony_app::deploy(&app, ctx.topology(), &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?), } -- 2.39.5