From 47e786c3a8efecfe9540bd0dc4085d7c9c45c21d Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Wed, 29 Jul 2026 19:20:00 -0400 Subject: [PATCH 01/34] feat: add declarative application deployment --- harmony-k8s/src/apply.rs | 35 +- harmony/src/modules/zitadel/contract.rs | 932 +++++++++++ harmony/src/modules/zitadel/mod.rs | 18 +- harmony/src/modules/zitadel/setup.rs | 865 ++++++++++- harmony_app/src/application/k8s_anywhere.rs | 1543 +++++++++++++++++++ harmony_app/src/application/mod.rs | 19 + harmony_app/src/application/model.rs | 550 +++++++ harmony_app/src/application/resources.rs | 167 ++ harmony_app/src/application/validation.rs | 561 +++++++ harmony_app/src/lib.rs | 11 + 10 files changed, 4649 insertions(+), 52 deletions(-) create mode 100644 harmony/src/modules/zitadel/contract.rs create mode 100644 harmony_app/src/application/k8s_anywhere.rs create mode 100644 harmony_app/src/application/mod.rs create mode 100644 harmony_app/src/application/model.rs create mode 100644 harmony_app/src/application/resources.rs create mode 100644 harmony_app/src/application/validation.rs diff --git a/harmony-k8s/src/apply.rs b/harmony-k8s/src/apply.rs index b8d70f32..37d16bfd 100644 --- a/harmony-k8s/src/apply.rs +++ b/harmony-k8s/src/apply.rs @@ -152,6 +152,18 @@ impl K8sClient { .await } + /// Server-side apply without serializing the resource into logs. Use for + /// generated credential objects whose `Debug`/`Serialize` output contains + /// secret material. + pub async fn apply_redacted(&self, resource: &K, namespace: Option<&str>) -> Result + where + K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize, + ::DynamicType: Default, + { + self.apply_with_strategy_inner(resource, namespace, WriteMode::CreateOrUpdate, true) + .await + } + /// POST only — returns an error if the resource already exists. pub async fn create(&self, resource: &K, namespace: Option<&str>) -> Result where @@ -178,6 +190,21 @@ impl K8sClient { namespace: Option<&str>, write_mode: WriteMode, ) -> Result + where + K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize, + ::DynamicType: Default, + { + self.apply_with_strategy_inner(resource, namespace, write_mode, false) + .await + } + + async fn apply_with_strategy_inner( + &self, + resource: &K, + namespace: Option<&str>, + write_mode: WriteMode, + redact: bool, + ) -> Result where K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize, ::DynamicType: Default, @@ -187,7 +214,9 @@ impl K8sClient { resource.meta().name, namespace ); - trace!("{:#}", serde_json::to_value(resource).unwrap_or_default()); + if !redact { + trace!("{:#}", serde_json::to_value(resource).unwrap_or_default()); + } let dyntype = K::DynamicType::default(); let gvk = GroupVersionKind { @@ -219,6 +248,10 @@ impl K8sClient { .expect("Kubernetes resource must have a name"); if self.dry_run { + if redact { + debug!("Dry-run payload for secret resource '{name}' is redacted"); + return Ok(resource.clone()); + } show_dry_run(&api, name, resource).await?; return Ok(resource.clone()); } diff --git a/harmony/src/modules/zitadel/contract.rs b/harmony/src/modules/zitadel/contract.rs new file mode 100644 index 00000000..1e87f1f6 --- /dev/null +++ b/harmony/src/modules/zitadel/contract.rs @@ -0,0 +1,932 @@ +//! Typed, additive Zitadel provisioning for [`super::ZitadelSetupScore`]. +//! +//! Declared resources are created and selected mutable settings are updated. +//! Resources removed from a contract are not deleted, and role metadata drift +//! is not yet converged. + +use std::{ + collections::{HashMap, HashSet}, + fmt, +}; + +use harmony_config::Config; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +macro_rules! named_ref { + ($name:ident) => { + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + pub fn name(&self) -> &str { + &self.0 + } + } + + impl From<&str> for $name { + fn from(name: &str) -> Self { + Self::new(name) + } + } + + impl From for $name { + fn from(name: String) -> Self { + Self::new(name) + } + } + }; +} + +named_ref!(ZitadelProjectRef); +named_ref!(ZitadelHumanRef); +named_ref!(ZitadelMachineRef); +named_ref!(ZitadelBootstrapSecretRef); + +/// An application identity is project-scoped because Zitadel permits the same +/// application name in different projects. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ZitadelApplicationRef { + project: ZitadelProjectRef, + name: String, +} + +impl ZitadelApplicationRef { + pub fn new(project: ZitadelProjectRef, name: impl Into) -> Self { + Self { + project, + name: name.into(), + } + } + + pub fn project(&self) -> &ZitadelProjectRef { + &self.project + } + + pub fn name(&self) -> &str { + &self.name + } + + pub(crate) fn cache_key(&self) -> String { + format!("{}::{}", self.project.name(), self.name) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ZitadelRoleRef { + pub project: ZitadelProjectRef, + pub key: String, +} + +impl ZitadelRoleRef { + pub fn new(project: ZitadelProjectRef, key: impl Into) -> Self { + Self { + project, + key: key.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "subject")] +pub enum ZitadelPrincipalRef { + Human(ZitadelHumanRef), + Machine(ZitadelMachineRef), +} + +impl From for ZitadelPrincipalRef { + fn from(value: ZitadelHumanRef) -> Self { + Self::Human(value) + } +} + +impl From for ZitadelPrincipalRef { + fn from(value: ZitadelMachineRef) -> Self { + Self::Machine(value) + } +} + +impl ZitadelPrincipalRef { + pub fn username(&self) -> &str { + match self { + Self::Human(value) => value.name(), + Self::Machine(value) => value.name(), + } + } +} + +/// Named bootstrap values resolved through Harmony's configured secret source +/// (OpenBao in remote deployments). Contract declarations serialize only a +/// [`ZitadelBootstrapSecretRef`], never the password itself. +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Config)] +#[config(secret)] +pub struct ZitadelBootstrapSecrets { + #[config(secret)] + values: HashMap, +} + +impl ZitadelBootstrapSecrets { + pub fn new() -> Self { + Self::default() + } + + pub fn insert( + mut self, + reference: ZitadelBootstrapSecretRef, + value: impl Into, + ) -> Self { + self.values + .insert(reference.name().to_string(), value.into()); + self + } + + pub(crate) fn resolve(&self, reference: &ZitadelBootstrapSecretRef) -> Option<&str> { + self.values.get(reference.name()).map(String::as_str) + } +} + +impl fmt::Debug for ZitadelBootstrapSecrets { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ZitadelBootstrapSecrets") + .field("values", &format_args!("[REDACTED; {}]", self.values.len())) + .finish() + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ZitadelAccessTokenType { + #[default] + Bearer, + Jwt, +} + +impl ZitadelAccessTokenType { + pub(crate) fn api_value(self) -> &'static str { + match self { + Self::Bearer => "OIDC_TOKEN_TYPE_BEARER", + Self::Jwt => "OIDC_TOKEN_TYPE_JWT", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ZitadelOidcResponseType { + Code, + IdToken, + IdTokenToken, +} + +impl ZitadelOidcResponseType { + pub(crate) fn api_value(self) -> &'static str { + match self { + Self::Code => "OIDC_RESPONSE_TYPE_CODE", + Self::IdToken => "OIDC_RESPONSE_TYPE_ID_TOKEN", + Self::IdTokenToken => "OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ZitadelOidcGrantType { + AuthorizationCode, + Implicit, + RefreshToken, + DeviceCode, + TokenExchange, +} + +impl ZitadelOidcGrantType { + pub(crate) fn api_value(self) -> &'static str { + match self { + Self::AuthorizationCode => "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", + Self::Implicit => "OIDC_GRANT_TYPE_IMPLICIT", + Self::RefreshToken => "OIDC_GRANT_TYPE_REFRESH_TOKEN", + Self::DeviceCode => "OIDC_GRANT_TYPE_DEVICE_CODE", + Self::TokenExchange => "OIDC_GRANT_TYPE_TOKEN_EXCHANGE", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ZitadelOidcAppType { + Web, + UserAgent, + Native, +} + +impl ZitadelOidcAppType { + pub(crate) fn api_value(self) -> &'static str { + match self { + Self::Web => "OIDC_APP_TYPE_WEB", + Self::UserAgent => "OIDC_APP_TYPE_USER_AGENT", + Self::Native => "OIDC_APP_TYPE_NATIVE", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ZitadelOidcAuthMethod { + None, + Basic, + Post, + PrivateKeyJwt, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ZitadelLoginVersion { + V1, + V2 { + #[serde(default)] + base_uri: Option, + }, +} + +impl ZitadelLoginVersion { + pub fn v2() -> Self { + Self::V2 { base_uri: None } + } + + pub fn v2_at(base_uri: impl Into) -> Self { + Self::V2 { + base_uri: Some(base_uri.into()), + } + } + + pub(crate) fn api_value(&self) -> serde_json::Value { + match self { + Self::V1 => serde_json::json!({ "loginV1": {} }), + Self::V2 { base_uri: None } => serde_json::json!({ "loginV2": {} }), + Self::V2 { + base_uri: Some(base_uri), + } => serde_json::json!({ "loginV2": { "baseUri": base_uri } }), + } + } +} + +impl ZitadelOidcAuthMethod { + pub(crate) fn api_value(self) -> &'static str { + match self { + Self::None => "OIDC_AUTH_METHOD_TYPE_NONE", + Self::Basic => "OIDC_AUTH_METHOD_TYPE_BASIC", + Self::Post => "OIDC_AUTH_METHOD_TYPE_POST", + Self::PrivateKeyJwt => "OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelOidcTokenSettings { + #[serde(default)] + pub access_token_type: ZitadelAccessTokenType, + #[serde(default)] + pub id_token_role_assertion: bool, + #[serde(default)] + pub id_token_userinfo_assertion: bool, + #[serde(default)] + pub access_token_role_assertion: bool, + #[serde(default)] + pub clock_skew: Option, + #[serde(default)] + pub dev_mode: bool, +} + +impl Default for ZitadelOidcTokenSettings { + fn default() -> Self { + Self { + access_token_type: ZitadelAccessTokenType::Bearer, + id_token_role_assertion: false, + id_token_userinfo_assertion: true, + access_token_role_assertion: false, + clock_skew: None, + dev_mode: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelProjectDeclaration { + pub project: ZitadelProjectRef, + #[serde(default = "default_true")] + pub project_role_assertion: bool, + #[serde(default)] + pub project_role_check: bool, + #[serde(default)] + pub has_project_check: bool, +} + +fn default_true() -> bool { + true +} + +impl ZitadelProjectDeclaration { + pub fn new(project: ZitadelProjectRef) -> Self { + Self { + project, + project_role_assertion: true, + project_role_check: false, + has_project_check: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelRoleDeclaration { + pub role: ZitadelRoleRef, + pub display_name: String, + #[serde(default)] + pub group: Option, +} + +impl ZitadelRoleDeclaration { + pub fn new(role: ZitadelRoleRef, display_name: impl Into) -> Self { + Self { + role, + display_name: display_name.into(), + group: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelOidcApplicationDeclaration { + pub application: ZitadelApplicationRef, + #[serde(default)] + pub redirect_uris: Vec, + #[serde(default)] + pub post_logout_redirect_uris: Vec, + pub response_types: Vec, + pub grant_types: Vec, + pub app_type: ZitadelOidcAppType, + pub auth_method: ZitadelOidcAuthMethod, + #[serde(default)] + pub login_version: Option, + #[serde(default)] + pub token_settings: ZitadelOidcTokenSettings, +} + +impl ZitadelOidcApplicationDeclaration { + pub fn web_pkce(application: ZitadelApplicationRef, redirect_uris: Vec) -> Self { + Self { + application, + redirect_uris, + post_logout_redirect_uris: Vec::new(), + response_types: vec![ZitadelOidcResponseType::Code], + grant_types: vec![ + ZitadelOidcGrantType::AuthorizationCode, + ZitadelOidcGrantType::RefreshToken, + ], + app_type: ZitadelOidcAppType::UserAgent, + auth_method: ZitadelOidcAuthMethod::None, + login_version: None, + token_settings: ZitadelOidcTokenSettings::default(), + } + } +} + +/// A Zitadel API application (resource server), scoped to its owning project +/// through [`ZitadelApplicationRef`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelApiApplicationDeclaration { + pub application: ZitadelApplicationRef, +} + +impl ZitadelApiApplicationDeclaration { + pub fn new(application: ZitadelApplicationRef) -> Self { + Self { application } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelHumanDeclaration { + pub human: ZitadelHumanRef, + pub first_name: String, + pub last_name: String, + pub bootstrap_password: ZitadelBootstrapSecretRef, + #[serde(default)] + pub password_change_required: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ZitadelMachineKeyDeclaration { + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelMachineDeclaration { + pub machine: ZitadelMachineRef, + pub name: String, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub client_secret: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelProjectRoleAssignment { + pub principal: ZitadelPrincipalRef, + pub project: ZitadelProjectRef, + pub roles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelOrgRoleAssignment { + pub principal: ZitadelPrincipalRef, + pub roles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelInstanceRoleAssignment { + pub principal: ZitadelPrincipalRef, + pub roles: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZitadelContract { + #[serde(default)] + pub projects: Vec, + #[serde(default)] + pub roles: Vec, + #[serde(default)] + pub applications: Vec, + #[serde(default)] + pub api_applications: Vec, + #[serde(default)] + pub humans: Vec, + #[serde(default)] + pub machines: Vec, + #[serde(default)] + pub project_role_assignments: Vec, + #[serde(default)] + pub org_role_assignments: Vec, + #[serde(default)] + pub instance_role_assignments: Vec, +} + +impl ZitadelContract { + pub fn project(mut self, project: ZitadelProjectDeclaration) -> Self { + self.projects.push(project); + self + } + + pub fn role(mut self, role: ZitadelRoleDeclaration) -> Self { + self.roles.push(role); + self + } + + pub fn application(mut self, application: ZitadelOidcApplicationDeclaration) -> Self { + self.applications.push(application); + self + } + + pub fn api_application(mut self, application: ZitadelApiApplicationDeclaration) -> Self { + self.api_applications.push(application); + self + } + + pub fn human(mut self, human: ZitadelHumanDeclaration) -> Self { + self.humans.push(human); + self + } + + pub fn machine(mut self, machine: ZitadelMachineDeclaration) -> Self { + self.machines.push(machine); + self + } + + pub fn project_roles(mut self, assignment: ZitadelProjectRoleAssignment) -> Self { + self.project_role_assignments.push(assignment); + self + } + + pub fn org_roles(mut self, assignment: ZitadelOrgRoleAssignment) -> Self { + self.org_role_assignments.push(assignment); + self + } + + pub fn instance_roles(mut self, assignment: ZitadelInstanceRoleAssignment) -> Self { + self.instance_role_assignments.push(assignment); + self + } + + pub fn validate(&self) -> Result<(), String> { + fn reject_duplicates<'a>( + kind: &str, + values: impl IntoIterator, + ) -> Result<(), String> { + let mut seen = HashSet::new(); + for value in values { + if !seen.insert(value) { + return Err(format!("duplicate {kind} declaration '{value}'")); + } + } + Ok(()) + } + + reject_duplicates( + "project", + self.projects.iter().map(|item| item.project.name()), + )?; + let mut roles = HashSet::new(); + for role in &self.roles { + if !roles.insert(role.role.clone()) { + return Err(format!( + "duplicate role declaration '{}::{}'", + role.role.project.name(), + role.role.key + )); + } + } + let mut applications = HashSet::new(); + for application in self + .applications + .iter() + .map(|item| &item.application) + .chain(self.api_applications.iter().map(|item| &item.application)) + { + if !applications.insert(application.clone()) { + return Err(format!( + "duplicate application declaration '{}::{}'", + application.project().name(), + application.name() + )); + } + } + reject_duplicates("human", self.humans.iter().map(|item| item.human.name()))?; + reject_duplicates( + "machine", + self.machines.iter().map(|item| item.machine.name()), + )?; + let humans: HashSet<&str> = self.humans.iter().map(|item| item.human.name()).collect(); + if let Some(machine) = self + .machines + .iter() + .find(|item| humans.contains(item.machine.name())) + { + return Err(format!( + "principal '{}' is declared as both human and machine", + machine.machine.name() + )); + } + + let project_exists = + |project: &ZitadelProjectRef| self.projects.iter().any(|item| &item.project == project); + let principal_exists = |principal: &ZitadelPrincipalRef| match principal { + ZitadelPrincipalRef::Human(human) => { + self.humans.iter().any(|item| &item.human == human) + } + ZitadelPrincipalRef::Machine(machine) => { + self.machines.iter().any(|item| &item.machine == machine) + } + }; + + for role in &self.roles { + if !project_exists(&role.role.project) { + return Err(format!( + "role '{}' references undeclared project '{}'", + role.role.key, + role.role.project.name() + )); + } + } + for app in &self.applications { + if !project_exists(app.application.project()) { + return Err(format!( + "application '{}' references undeclared project '{}'", + app.application.name(), + app.application.project().name() + )); + } + if app.response_types.is_empty() || app.grant_types.is_empty() { + return Err(format!( + "application '{}' requires response_types and grant_types", + app.application.name() + )); + } + } + for app in &self.api_applications { + if !project_exists(app.application.project()) { + return Err(format!( + "API application '{}' references undeclared project '{}'", + app.application.name(), + app.application.project().name() + )); + } + } + for assignment in &self.project_role_assignments { + if !principal_exists(&assignment.principal) { + return Err(format!( + "project assignment references undeclared principal '{}'", + assignment.principal.username() + )); + } + if !project_exists(&assignment.project) { + return Err(format!( + "project assignment references undeclared project '{}'", + assignment.project.name() + )); + } + for role in &assignment.roles { + if role.project != assignment.project { + return Err(format!( + "role '{}' belongs to project '{}', not assignment project '{}'", + role.key, + role.project.name(), + assignment.project.name() + )); + } + if !self.roles.iter().any(|item| &item.role == role) { + return Err(format!( + "assignment references undeclared role '{}'", + role.key + )); + } + } + } + for assignment in self + .org_role_assignments + .iter() + .map(|item| &item.principal) + .chain( + self.instance_role_assignments + .iter() + .map(|item| &item.principal), + ) + { + if !principal_exists(assignment) { + return Err(format!( + "membership references undeclared principal '{}'", + assignment.username() + )); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ZitadelProjectOutputRef { + project: ZitadelProjectRef, +} + +impl ZitadelProjectOutputRef { + pub(crate) fn new(project: &ZitadelProjectRef) -> Self { + Self { + project: project.clone(), + } + } + + pub fn project(&self) -> &ZitadelProjectRef { + &self.project + } + #[doc(hidden)] + pub fn config_map_name(&self) -> String { + format!("zitadel-{}-project", self.project.name()) + } + #[doc(hidden)] + pub fn project_id_key(&self) -> &'static str { + "project_id" + } + #[doc(hidden)] + pub fn roles_claim_key(&self) -> &'static str { + "roles_claim" + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ZitadelApplicationOutputRef { + application: ZitadelApplicationRef, +} + +impl ZitadelApplicationOutputRef { + pub(crate) fn new(application: &ZitadelApplicationRef) -> Self { + Self { + application: application.clone(), + } + } + + pub fn application(&self) -> &ZitadelApplicationRef { + &self.application + } + #[doc(hidden)] + pub fn config_map_name(&self) -> String { + format!( + "zitadel-{}-{}-oidc", + self.application.project().name(), + self.application.name() + ) + } + #[doc(hidden)] + pub fn client_id_key(&self) -> &'static str { + "client_id" + } + #[doc(hidden)] + pub fn project_id_key(&self) -> &'static str { + "project_id" + } + #[doc(hidden)] + pub fn roles_claim_key(&self) -> &'static str { + "roles_claim" + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ZitadelMachineOutputRef { + machine: ZitadelMachineRef, +} + +impl ZitadelMachineOutputRef { + pub(crate) fn new(machine: &ZitadelMachineRef) -> Self { + Self { + machine: machine.clone(), + } + } + + pub fn machine(&self) -> &ZitadelMachineRef { + &self.machine + } + #[doc(hidden)] + pub fn secret_name(&self) -> String { + format!("zitadel-{}-machine", self.machine.name()) + } + #[doc(hidden)] + pub fn user_id_key(&self) -> &'static str { + "user_id" + } + #[doc(hidden)] + pub fn key_json_key(&self) -> &'static str { + "key.json" + } + #[doc(hidden)] + pub fn client_id_key(&self) -> &'static str { + "client_id" + } + #[doc(hidden)] + pub fn client_secret_key(&self) -> &'static str { + "client_secret" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn declaration_serializes_only_bootstrap_secret_reference() { + let human = ZitadelHumanDeclaration { + human: "admin@example.test".into(), + first_name: "Admin".into(), + last_name: "User".into(), + bootstrap_password: ZitadelBootstrapSecretRef::new("initial-admin"), + password_change_required: true, + }; + let json = serde_json::to_string(&human).unwrap(); + assert!(json.contains("initial-admin")); + assert!(!json.contains("super-secret")); + + let secrets = ZitadelBootstrapSecrets::new().insert( + ZitadelBootstrapSecretRef::new("initial-admin"), + "super-secret", + ); + assert_eq!( + ::CLASS, + harmony_config::ConfigClass::Secret + ); + let debug = format!("{secrets:?}"); + assert!(debug.contains("[REDACTED; 1]")); + assert!(!debug.contains("super-secret")); + } + + #[test] + fn same_application_name_is_scoped_by_project() { + let first = ZitadelApplicationRef::new("first".into(), "console"); + let second = ZitadelApplicationRef::new("second".into(), "console"); + assert_ne!(first, second); + assert_ne!(first.cache_key(), second.cache_key()); + + let first_output = ZitadelApplicationOutputRef::new(&first); + let second_output = ZitadelApplicationOutputRef::new(&second); + assert_ne!( + first_output.config_map_name(), + second_output.config_map_name() + ); + } + + #[test] + fn duplicate_scoped_application_is_rejected() { + let project = ZitadelProjectRef::new("app"); + let application = ZitadelApplicationRef::new(project.clone(), "console"); + let declaration = ZitadelOidcApplicationDeclaration::web_pkce( + application, + vec!["https://app.example.test/callback".into()], + ); + let contract = ZitadelContract::default() + .project(ZitadelProjectDeclaration::new(project)) + .application(declaration.clone()) + .application(declaration); + + assert!( + contract + .validate() + .unwrap_err() + .contains("duplicate application declaration 'app::console'") + ); + } + + #[test] + fn oidc_and_api_applications_share_scoped_uniqueness() { + let project = ZitadelProjectRef::new("app"); + let application = ZitadelApplicationRef::new(project.clone(), "backend"); + let contract = ZitadelContract::default() + .project(ZitadelProjectDeclaration::new(project)) + .application(ZitadelOidcApplicationDeclaration::web_pkce( + application.clone(), + vec!["https://app.example.test/callback".into()], + )) + .api_application(ZitadelApiApplicationDeclaration::new(application)); + + assert!( + contract + .validate() + .unwrap_err() + .contains("duplicate application declaration 'app::backend'") + ); + } + + #[test] + fn same_api_application_name_in_different_projects_is_valid() { + let first = ZitadelProjectRef::new("first"); + let second = ZitadelProjectRef::new("second"); + let contract = ZitadelContract::default() + .project(ZitadelProjectDeclaration::new(first.clone())) + .project(ZitadelProjectDeclaration::new(second.clone())) + .api_application(ZitadelApiApplicationDeclaration::new( + ZitadelApplicationRef::new(first, "backend"), + )) + .api_application(ZitadelApiApplicationDeclaration::new( + ZitadelApplicationRef::new(second, "backend"), + )); + + contract.validate().unwrap(); + } + + #[test] + fn assignment_rejects_a_role_from_another_project() { + let project = ZitadelProjectRef::new("app"); + let other = ZitadelProjectRef::new("other"); + let human = ZitadelHumanRef::new("admin@example.test"); + let role = ZitadelRoleRef::new(other.clone(), "admin"); + let contract = ZitadelContract::default() + .project(ZitadelProjectDeclaration::new(project.clone())) + .project(ZitadelProjectDeclaration::new(other)) + .role(ZitadelRoleDeclaration::new(role.clone(), "Admin")) + .human(ZitadelHumanDeclaration { + human: human.clone(), + first_name: "Admin".into(), + last_name: "User".into(), + bootstrap_password: ZitadelBootstrapSecretRef::new("admin"), + password_change_required: false, + }) + .project_roles(ZitadelProjectRoleAssignment { + principal: human.into(), + project, + roles: vec![role], + }); + + assert!( + contract + .validate() + .unwrap_err() + .contains("not assignment project") + ); + } + + #[test] + fn semantic_outputs_serialize_without_kubernetes_coordinates() { + let project_ref = ZitadelProjectRef::new("fleet"); + let app_ref = ZitadelApplicationRef::new(project_ref.clone(), "console"); + let machine_ref = ZitadelMachineRef::new("callout"); + let project = ZitadelProjectOutputRef::new(&project_ref); + let app = ZitadelApplicationOutputRef::new(&app_ref); + let machine = ZitadelMachineOutputRef::new(&machine_ref); + assert_eq!(project.project(), &project_ref); + assert_eq!(app.application(), &app_ref); + assert_eq!(machine.machine(), &machine_ref); + assert!(!serde_json::to_string(&app).unwrap().contains("config_map")); + assert_eq!(app.config_map_name(), "zitadel-fleet-console-oidc"); + assert_eq!(machine.secret_name(), "zitadel-callout-machine"); + assert_eq!(machine.client_secret_key(), "client_secret"); + } +} diff --git a/harmony/src/modules/zitadel/mod.rs b/harmony/src/modules/zitadel/mod.rs index 7b00bac5..c373266c 100644 --- a/harmony/src/modules/zitadel/mod.rs +++ b/harmony/src/modules/zitadel/mod.rs @@ -1,12 +1,24 @@ pub mod admin_auth; +pub mod contract; pub mod setup; pub use admin_auth::{ADMIN_API_SCOPES, DeviceCodeError, DeviceCodeFlowConfig, device_code_login}; +pub use contract::{ + ZitadelAccessTokenType, ZitadelApiApplicationDeclaration, ZitadelApplicationOutputRef, + ZitadelApplicationRef, ZitadelBootstrapSecretRef, ZitadelBootstrapSecrets, ZitadelContract, + ZitadelHumanDeclaration, ZitadelHumanRef, ZitadelInstanceRoleAssignment, ZitadelLoginVersion, + ZitadelMachineDeclaration, ZitadelMachineKeyDeclaration, ZitadelMachineOutputRef, + ZitadelMachineRef, ZitadelOidcAppType, ZitadelOidcApplicationDeclaration, + ZitadelOidcAuthMethod, ZitadelOidcGrantType, ZitadelOidcResponseType, ZitadelOidcTokenSettings, + ZitadelOrgRoleAssignment, ZitadelPrincipalRef, ZitadelProjectDeclaration, + ZitadelProjectOutputRef, ZitadelProjectRef, ZitadelProjectRoleAssignment, + ZitadelRoleDeclaration, ZitadelRoleRef, +}; pub use setup::{ MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication, - ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelCredentialsExportScore, - ZitadelHumanUser, ZitadelMachineUser, ZitadelMember, ZitadelRole, ZitadelScheme, - ZitadelSetupScore, mint_device_credentials, + ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelContractSetupScore, + ZitadelCredentialsExportScore, ZitadelHumanUser, ZitadelMachineUser, ZitadelMember, + 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 d27b0c97..8405994e 100644 --- a/harmony/src/modules/zitadel/setup.rs +++ b/harmony/src/modules/zitadel/setup.rs @@ -16,7 +16,15 @@ use crate::{ }; use harmony_types::id::Id; -use super::{OidcApplicationRef, OidcMachineIdentityRef, OidcProviderRef}; +use super::{ + OidcApplicationRef, OidcMachineIdentityRef, OidcProviderRef, + contract::{ + ZitadelApplicationOutputRef, ZitadelBootstrapSecrets, ZitadelContract, + ZitadelHumanDeclaration, ZitadelMachineKeyDeclaration, ZitadelMachineOutputRef, + ZitadelOidcApplicationDeclaration, ZitadelProjectDeclaration, ZitadelProjectOutputRef, + ZitadelProjectRef, + }, +}; const ADMIN_PAT_SECRET: &str = "iam-admin-pat"; const ZITADEL_NAMESPACE: &str = "zitadel"; @@ -419,6 +427,15 @@ impl ZitadelSetupScore { self } + /// Attach the typed declarative contract while retaining this score's + /// connection, namespace, and legacy provisioning declarations. + pub fn contract(self, contract: ZitadelContract) -> ZitadelContractSetupScore { + ZitadelContractSetupScore { + setup: self, + contract, + } + } + fn resolved_outputs_namespace(&self) -> &str { self.outputs_namespace.as_deref().unwrap_or(&self.namespace) } @@ -448,6 +465,62 @@ impl ZitadelSetupScore { } } +/// A [`ZitadelSetupScore`] carrying typed additive provisioning declarations. +/// +/// Construct this through [`ZitadelSetupScore::contract`]. Keeping the contract +/// in a wrapper preserves source compatibility for existing public struct +/// literals of `ZitadelSetupScore`. It does not delete removed declarations or +/// yet reconcile role display-name/group metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZitadelContractSetupScore { + pub setup: ZitadelSetupScore, + pub contract: ZitadelContract, +} + +impl ZitadelContractSetupScore { + pub fn project_output(&self, project: &ZitadelProjectRef) -> ZitadelProjectOutputRef { + assert!( + self.contract + .projects + .iter() + .any(|item| &item.project == project), + "project '{}' is not declared by this Zitadel contract", + project.name() + ); + ZitadelProjectOutputRef::new(project) + } + + pub fn application_output( + &self, + application: &super::contract::ZitadelApplicationRef, + ) -> ZitadelApplicationOutputRef { + assert!( + self.contract + .applications + .iter() + .any(|item| &item.application == application), + "application '{}' is not declared by this Zitadel contract", + application.name() + ); + ZitadelApplicationOutputRef::new(application) + } + + pub fn machine_output( + &self, + machine: &super::contract::ZitadelMachineRef, + ) -> ZitadelMachineOutputRef { + assert!( + self.contract + .machines + .iter() + .any(|item| &item.machine == machine), + "machine '{}' is not declared by this Zitadel contract", + machine.name() + ); + ZitadelMachineOutputRef::new(machine) + } +} + /// Function name doubles as the Action name — Zitadel requires the /// script's entry function to match. pub const GROUPS_CLAIM_ACTION_NAME: &str = "harmonyGroupsClaim"; @@ -581,6 +654,14 @@ impl ZitadelClientConfig { self.apps.get(app_name) } + /// Get a contract application's client ID using its project-scoped identity. + pub fn application_client_id( + &self, + application: &super::contract::ZitadelApplicationRef, + ) -> Option<&String> { + self.apps.get(&application.cache_key()) + } + /// Get the JSON machine key (raw keyfile content) for a username. pub fn machine_key(&self, username: &str) -> Option<&String> { self.machine_keys.get(username) @@ -614,6 +695,20 @@ impl Score for ZitadelSetupScore { fn create_interpret(&self) -> Box> { Box::new(ZitadelSetupInterpret { score: self.clone(), + contract: None, + }) + } +} + +impl Score for ZitadelContractSetupScore { + fn name(&self) -> String { + "ZitadelContractSetupScore".to_string() + } + + fn create_interpret(&self) -> Box> { + Box::new(ZitadelSetupInterpret { + score: self.setup.clone(), + contract: Some(self.contract.clone()), }) } } @@ -625,6 +720,7 @@ impl Score for ZitadelSetupScore { #[derive(Debug, Clone)] struct ZitadelSetupInterpret { score: ZitadelSetupScore, + contract: Option, } #[derive(Deserialize)] @@ -698,6 +794,21 @@ struct UserSearchEntry { user_name: Option, #[serde(rename = "preferredLoginName", default)] preferred_login_name: Option, + #[serde(default)] + human: Option, + #[serde(default)] + machine: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum UserKind { + Human, + Machine, +} + +struct FoundUser { + id: String, + kind: UserKind, } #[derive(Deserialize)] @@ -1077,16 +1188,34 @@ impl ZitadelSetupInterpret { client: &reqwest::Client, pat: &str, name: &str, + ) -> Result { + self.create_project_with_settings( + client, + pat, + &ZitadelProjectDeclaration::new(ZitadelProjectRef::new(name)), + ) + .await + } + + fn project_body(project: &ZitadelProjectDeclaration) -> serde_json::Value { + serde_json::json!({ + "name": project.project.name(), + "projectRoleAssertion": project.project_role_assertion, + "projectRoleCheck": project.project_role_check, + "hasProjectCheck": project.has_project_check, + }) + } + + async fn create_project_with_settings( + &self, + client: &reqwest::Client, + pat: &str, + project: &ZitadelProjectDeclaration, ) -> Result { let resp = self .post(client, "/management/v1/projects") .bearer_auth(pat) - .json(&serde_json::json!({ - "name": name, - "projectRoleAssertion": true, - "projectRoleCheck": false, - "hasProjectCheck": false - })) + .json(&Self::project_body(project)) .send() .await .map_err(|e| format!("Failed to create project: {e}"))?; @@ -1103,6 +1232,47 @@ impl ZitadelSetupInterpret { Ok(result.id) } + async fn ensure_contract_project( + &self, + client: &reqwest::Client, + pat: &str, + project: &ZitadelProjectDeclaration, + config: &mut ZitadelClientConfig, + ) -> Result { + let name = project.project.name(); + let id = match self.find_project(client, pat, name).await { + Ok(Some(id)) => { + let response = self + .put(client, &format!("/management/v1/projects/{id}")) + .bearer_auth(pat) + .json(&Self::project_body(project)) + .send() + .await + .map_err(|error| { + InterpretError::new(format!("Update project '{name}': {error}")) + })?; + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + if !is_zitadel_no_changes(&body) { + return Err(InterpretError::new(format!( + "Update project '{name}' failed: {body}" + ))); + } + } + id + } + Ok(None) => self + .create_project_with_settings(client, pat, project) + .await + .map_err(InterpretError::new)?, + Err(error) => return Err(InterpretError::new(error)), + }; + config.projects.insert(name.to_string(), id.clone()); + config.project_id = Some(id.clone()); + info!("[ZitadelSetup] Contract project '{name}' resolved: {id}"); + Ok(id) + } + /// Find or create the project, refreshing the cache with the live /// id every call. /// @@ -1279,6 +1449,73 @@ impl ZitadelSetupInterpret { }) } + fn contract_oidc_config_body( + application: &ZitadelOidcApplicationDeclaration, + ) -> serde_json::Value { + let settings = &application.token_settings; + let mut body = serde_json::json!({ + "redirectUris": application.redirect_uris, + "postLogoutRedirectUris": application.post_logout_redirect_uris, + "responseTypes": application.response_types.iter().map(|value| value.api_value()).collect::>(), + "grantTypes": application.grant_types.iter().map(|value| value.api_value()).collect::>(), + "appType": application.app_type.api_value(), + "authMethodType": application.auth_method.api_value(), + "accessTokenType": settings.access_token_type.api_value(), + "idTokenRoleAssertion": settings.id_token_role_assertion, + "idTokenUserinfoAssertion": settings.id_token_userinfo_assertion, + "accessTokenRoleAssertion": settings.access_token_role_assertion, + "devMode": settings.dev_mode, + }); + if let Some(clock_skew) = &settings.clock_skew { + body["clockSkew"] = serde_json::Value::String(clock_skew.clone()); + } + if let Some(login_version) = &application.login_version { + body["loginVersion"] = login_version.api_value(); + } + body + } + + async fn ensure_contract_app( + &self, + client: &reqwest::Client, + pat: &str, + application: &ZitadelOidcApplicationDeclaration, + config: &mut ZitadelClientConfig, + ) -> Result { + let project_id = config + .project_id_by_name(application.application.project().name()) + .cloned() + .ok_or_else(|| { + InterpretError::new(format!( + "contract project '{}' was not provisioned", + application.application.project().name() + )) + })?; + let name = application.application.name(); + let body = Self::contract_oidc_config_body(application); + let client_id = if let Some(found) = self + .find_app(client, pat, &project_id, name) + .await + .map_err(InterpretError::new)? + { + self.update_oidc_config(client, pat, &project_id, &found.id, body) + .await + .map_err(InterpretError::new)?; + found.client_id + } else { + let mut create_body = body; + create_body["name"] = serde_json::Value::String(name.to_string()); + self.create_oidc_app(client, pat, &project_id, create_body) + .await + .map_err(InterpretError::new)? + }; + config + .apps + .insert(application.application.cache_key(), client_id.clone()); + info!("[ZitadelSetup] Contract OIDC app '{name}' resolved: {client_id}"); + Ok(client_id) + } + async fn create_web_pkce_app( &self, client: &reqwest::Client, @@ -1640,12 +1877,12 @@ impl ZitadelSetupInterpret { // Machine users + machine keys + grants // ------------------------------------------------------------------ - async fn find_machine_user( + async fn find_user( &self, client: &reqwest::Client, pat: &str, username: &str, - ) -> Result, String> { + ) -> Result, String> { // Filter by userName for an O(1)-ish lookup. The Zitadel API // returns paginated results; for our test scale, no pagination // is needed. @@ -1669,7 +1906,7 @@ impl ZitadelSetupInterpret { .await .map_err(|e| format!("Failed to parse user search: {e}"))?; - Ok(result + result .result .unwrap_or_default() .into_iter() @@ -1677,7 +1914,56 @@ impl ZitadelSetupInterpret { u.user_name.as_deref() == Some(username) || u.preferred_login_name.as_deref() == Some(username) }) - .map(|u| u.id)) + .map(|user| { + let kind = if user.machine.is_some() { + UserKind::Machine + } else if user.human.is_some() { + UserKind::Human + } else { + // Zitadel user search results always contain one of these + // variants; refusing an unknown shape avoids adopting the + // wrong principal type. + return Err("user search result has no human or machine type".to_string()); + }; + Ok(FoundUser { id: user.id, kind }) + }) + .transpose() + } + + async fn find_user_of_kind( + &self, + client: &reqwest::Client, + pat: &str, + username: &str, + expected: UserKind, + ) -> Result, String> { + match self.find_user(client, pat, username).await? { + Some(found) if found.kind == expected => Ok(Some(found.id)), + Some(_) => Err(format!( + "user '{username}' already exists with a different principal type" + )), + None => Ok(None), + } + } + + async fn find_machine_user( + &self, + client: &reqwest::Client, + pat: &str, + username: &str, + ) -> Result, String> { + self.find_user_of_kind(client, pat, username, UserKind::Machine) + .await + } + + async fn find_human_user( + &self, + client: &reqwest::Client, + pat: &str, + username: &str, + ) -> Result, String> { + self.find_user_of_kind(client, pat, username, UserKind::Human) + .await } async fn create_machine_user( @@ -1986,6 +2272,63 @@ impl ZitadelSetupInterpret { Ok(parsed.user_grant_id) } + async fn ensure_contract_project_assignment( + &self, + client: &reqwest::Client, + pat: &str, + assignment: &super::contract::ZitadelProjectRoleAssignment, + config: &mut ZitadelClientConfig, + ) -> Result<(), InterpretError> { + let username = assignment.principal.username(); + let user_id = self.resolve_user_id(client, pat, username, config).await?; + let project_name = assignment.project.name(); + let project_id = config + .project_id_by_name(project_name) + .cloned() + .ok_or_else(|| { + InterpretError::new(format!("project '{project_name}' was not provisioned")) + })?; + let role_keys: Vec = assignment + .roles + .iter() + .map(|role| role.key.clone()) + .collect(); + let grant_id = if let Some(grant_id) = self + .find_user_grant(client, pat, &user_id, &project_id) + .await + .map_err(InterpretError::new)? + { + let response = self + .put( + client, + &format!("/management/v1/users/{user_id}/grants/{grant_id}"), + ) + .bearer_auth(pat) + .json(&serde_json::json!({ "roleKeys": role_keys })) + .send() + .await + .map_err(|error| InterpretError::new(format!("Update user grant: {error}")))?; + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + if !is_zitadel_no_changes(&body) { + return Err(InterpretError::new(format!( + "Update grant for '{username}' failed: {body}" + ))); + } + } + grant_id + } else { + self.create_user_grant(client, pat, &user_id, &project_id, &role_keys) + .await + .map_err(InterpretError::new)? + }; + config.user_grants.insert( + ZitadelClientConfig::user_grant_key(username, project_name), + grant_id, + ); + Ok(()) + } + async fn ensure_machine_user( &self, client: &reqwest::Client, @@ -2010,6 +2353,8 @@ impl ZitadelSetupInterpret { }; if config.machine_user_ids.get(&user.username) != Some(&user_id) { config.machine_keys.remove(&user.username); + config.machine_client_ids.remove(&user.username); + config.machine_secrets.remove(&user.username); } config .machine_user_ids @@ -2032,6 +2377,10 @@ impl ZitadelSetupInterpret { .map_err(InterpretError::new)?; info!("[ZitadelSetup] Machine key created for '{}'", user.username); config.machine_keys.insert(user.username.clone(), key_json); + config + .save_for_host(&self.score.host) + .await + .map_err(InterpretError::new)?; } // 3. Ensure user grants for the requested project + roles. @@ -2087,18 +2436,18 @@ impl ZitadelSetupInterpret { username: &str, config: &ZitadelClientConfig, ) -> Result { - if let Some(id) = self - .find_machine_user(client, pat, username) - .await - .map_err(InterpretError::new)? - { - return Ok(id); - } - config + if let Some(id) = config .machine_user_ids .get(username) .or_else(|| config.human_user_ids.get(username)) .cloned() + { + return Ok(id); + } + self.find_user(client, pat, username) + .await + .map_err(InterpretError::new)? + .map(|found| found.id) .ok_or_else(|| { InterpretError::new(format!( "member references unknown user '{username}' — provision it first" @@ -2106,22 +2455,22 @@ impl ZitadelSetupInterpret { }) } - /// Treat a membership response as success when the user is already a member - /// (409 / "already") — the only non-idempotent shape of these endpoints. - async fn ok_or_already_member( + /// Return whether a membership was created. Existing memberships must be + /// updated so declared security roles cannot silently drift. + async fn membership_created( &self, resp: reqwest::Response, who: &str, scope: &str, - ) -> Result<(), InterpretError> { + ) -> Result { if resp.status().is_success() { - return Ok(()); + return Ok(true); } let status = resp.status(); let body = resp.text().await.unwrap_or_default(); if status == reqwest::StatusCode::CONFLICT || body.to_lowercase().contains("already") { debug!("[ZitadelSetup] '{who}' is already a {scope} member"); - return Ok(()); + return Ok(false); } Err(InterpretError::new(format!( "Add {scope} member '{who}' failed: {body}" @@ -2163,6 +2512,86 @@ impl ZitadelSetupInterpret { Ok(parsed.user_id) } + async fn create_contract_human( + &self, + client: &reqwest::Client, + pat: &str, + human: &ZitadelHumanDeclaration, + bootstrap_password: &str, + ) -> Result { + let email = human.human.name(); + let display_name = format!("{} {}", human.first_name, human.last_name); + let response = self + .post(client, "/management/v1/users/human/_import") + .bearer_auth(pat) + .json(&serde_json::json!({ + "userName": email, + "profile": { + "firstName": human.first_name, + "lastName": human.last_name, + "displayName": display_name, + }, + "email": { "email": email, "isEmailVerified": true }, + "password": bootstrap_password, + "passwordChangeRequired": human.password_change_required, + })) + .send() + .await + .map_err(|error| format!("Failed to create contract human: {error}"))?; + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(format!("Create human '{email}' failed: {body}")); + } + let parsed: UserCreateResponse = response + .json() + .await + .map_err(|error| format!("Parse human response: {error}"))?; + Ok(parsed.user_id) + } + + /// Contract bootstrap passwords are create-only. Existing humans are + /// deliberately left untouched so a later apply cannot rotate a password + /// that a person has already changed. + async fn ensure_contract_human( + &self, + client: &reqwest::Client, + pat: &str, + human: &ZitadelHumanDeclaration, + config: &mut ZitadelClientConfig, + ) -> Result<(), InterpretError> { + let email = human.human.name(); + let user_id = match self + .find_human_user(client, pat, email) + .await + .map_err(InterpretError::new)? + { + Some(id) => id, + None => { + let secrets = harmony_config::get::() + .await + .map_err(|error| { + InterpretError::new(format!( + "resolve Zitadel bootstrap secrets for '{email}': {error}" + )) + })?; + let bootstrap_password = secrets + .resolve(&human.bootstrap_password) + .ok_or_else(|| { + InterpretError::new(format!( + "bootstrap secret '{}' referenced by human '{email}' was not found in ZitadelBootstrapSecrets", + human.bootstrap_password.name() + )) + })?; + self.create_contract_human(client, pat, human, bootstrap_password) + .await + .map_err(InterpretError::new)? + } + }; + config.human_user_ids.insert(email.to_string(), user_id); + info!("[ZitadelSetup] Contract human '{email}' resolved (password create-only)"); + Ok(()) + } + /// Ensure a human user exists (create or reset its password) and holds its /// `grant_roles` on `project_name`. async fn ensure_human_user( @@ -2173,7 +2602,7 @@ impl ZitadelSetupInterpret { config: &mut ZitadelClientConfig, ) -> Result<(), InterpretError> { let user_id = match self - .find_machine_user(client, pat, &user.email) + .find_human_user(client, pat, &user.email) .await .map_err(InterpretError::new)? { @@ -2290,6 +2719,10 @@ impl ZitadelSetupInterpret { config .machine_secrets .insert(username.to_string(), parsed.client_secret); + config + .save_for_host(&self.score.host) + .await + .map_err(InterpretError::new)?; info!("[ZitadelSetup] Client secret minted for '{username}'"); Ok(()) } @@ -2312,8 +2745,28 @@ impl ZitadelSetupInterpret { .send() .await .map_err(|e| InterpretError::new(format!("Add org member: {e}")))?; - self.ok_or_already_member(resp, &member.username, "org") + if self + .membership_created(resp, &member.username, "org") + .await? + { + return Ok(()); + } + let response = self + .put(client, &format!("/management/v1/orgs/me/members/{user_id}")) + .bearer_auth(pat) + .json(&serde_json::json!({ "roles": member.roles })) + .send() .await + .map_err(|error| InterpretError::new(format!("Update org member: {error}")))?; + if response.status().is_success() { + Ok(()) + } else { + let body = response.text().await.unwrap_or_default(); + Err(InterpretError::new(format!( + "Update org member '{}' failed: {body}", + member.username + ))) + } } /// Grant a user instance-level `roles` (e.g. `IAM_LOGIN_CLIENT`). @@ -2334,8 +2787,28 @@ impl ZitadelSetupInterpret { .send() .await .map_err(|e| InterpretError::new(format!("Add instance member: {e}")))?; - self.ok_or_already_member(resp, &member.username, "instance") + if self + .membership_created(resp, &member.username, "instance") + .await? + { + return Ok(()); + } + let response = self + .put(client, &format!("/admin/v1/members/{user_id}")) + .bearer_auth(pat) + .json(&serde_json::json!({ "roles": member.roles })) + .send() .await + .map_err(|error| InterpretError::new(format!("Update instance member: {error}")))?; + if response.status().is_success() { + Ok(()) + } else { + let body = response.text().await.unwrap_or_default(); + Err(InterpretError::new(format!( + "Update instance member '{}' failed: {body}", + member.username + ))) + } } } @@ -2370,6 +2843,7 @@ pub async fn mint_device_credentials( ) -> Result { let interp = ZitadelSetupInterpret { score: connection.clone(), + contract: None, }; let client = interp.http_client().map_err(InterpretError::new)?; @@ -2449,6 +2923,9 @@ impl Interpret for ZitadelSetupInterpret { inventory: &Inventory, topology: &T, ) -> Result { + if let Some(contract) = &self.contract { + contract.validate().map_err(InterpretError::new)?; + } let k8s = topology .k8s_client() .await @@ -2516,7 +2993,10 @@ impl Interpret for ZitadelSetupInterpret { _pf = Some(handle); let mut score = self.score.clone(); score.endpoint = Some(endpoint); - std::borrow::Cow::Owned(ZitadelSetupInterpret { score }) + std::borrow::Cow::Owned(ZitadelSetupInterpret { + score, + contract: self.contract.clone(), + }) } else { _pf = None; std::borrow::Cow::Borrowed(self) @@ -2541,6 +3021,51 @@ impl Interpret for ZitadelSetupInterpret { let mut details = Vec::new(); + if let Some(contract) = &me.contract { + for project in &contract.projects { + me.ensure_contract_project(&client, &pat, project, &mut config) + .await?; + details.push(format!("project:{}", project.project.name())); + } + for application in &contract.applications { + let client_id = me + .ensure_contract_app(&client, &pat, application, &mut config) + .await?; + details.push(format!( + "oidc_app:{}={client_id}", + application.application.name() + )); + } + for application in &contract.api_applications { + let legacy_application = ZitadelApiApp { + project_name: application.application.project().name().to_string(), + app_name: application.application.name().to_string(), + }; + me.ensure_api_app(&client, &pat, &legacy_application, &mut config) + .await?; + details.push(format!( + "api_app:{}@{}", + application.application.name(), + application.application.project().name() + )); + } + for role in &contract.roles { + let legacy_role = ZitadelRole { + project_name: role.role.project.name().to_string(), + key: role.role.key.clone(), + display_name: role.display_name.clone(), + group: role.group.clone(), + }; + me.ensure_role(&client, &pat, &legacy_role, &mut config) + .await?; + details.push(format!( + "role:{}@{}", + role.role.key, + role.role.project.name() + )); + } + } + for app in &me.score.applications { let client_id = me.ensure_app(&client, &pat, app, &mut config).await?; details.push(format!("oidc_app:{}={}", app.app_name, client_id)); @@ -2575,6 +3100,60 @@ impl Interpret for ZitadelSetupInterpret { details.push(format!("human_user:{}", user.email)); } + if let Some(contract) = &me.contract { + for machine in &contract.machines { + let legacy_machine = ZitadelMachineUser { + username: machine.machine.name().to_string(), + name: machine.name.clone(), + create_pat: false, + machine_key: machine.key.map(|key| match key { + ZitadelMachineKeyDeclaration::Json => MachineKeyType::Json, + }), + project_name: None, + grant_roles: Vec::new(), + }; + me.ensure_machine_user(&client, &pat, &legacy_machine, &mut config) + .await?; + if machine.client_secret { + me.ensure_machine_secret(&client, &pat, machine.machine.name(), &mut config) + .await?; + } + details.push(format!("machine_user:{}", machine.machine.name())); + } + for human in &contract.humans { + me.ensure_contract_human(&client, &pat, human, &mut config) + .await?; + details.push(format!("human_user:{}", human.human.name())); + } + for assignment in &contract.project_role_assignments { + me.ensure_contract_project_assignment(&client, &pat, assignment, &mut config) + .await?; + details.push(format!( + "project_assignment:{}@{}", + assignment.principal.username(), + assignment.project.name() + )); + } + for assignment in &contract.org_role_assignments { + let member = ZitadelMember { + username: assignment.principal.username().to_string(), + roles: assignment.roles.clone(), + }; + me.ensure_org_member(&client, &pat, &member, &config) + .await?; + details.push(format!("org_member:{}", member.username)); + } + for assignment in &contract.instance_role_assignments { + let member = ZitadelMember { + username: assignment.principal.username().to_string(), + roles: assignment.roles.clone(), + }; + me.ensure_instance_member(&client, &pat, &member, &config) + .await?; + details.push(format!("instance_member:{}", member.username)); + } + } + // Secrets first (the user must exist), then org/instance memberships. for username in &me.score.machine_secrets { me.ensure_machine_secret(&client, &pat, username, &mut config) @@ -2665,23 +3244,164 @@ impl Interpret for ZitadelSetupInterpret { user.username )) })?; - K8sResourceScore::single( - Secret { - metadata: ObjectMeta { - name: Some(identity_ref.secret_name().to_string()), - namespace: Some(identity_ref.namespace().to_string()), - ..Default::default() - }, - string_data: Some(BTreeMap::from([( - identity_ref.key_json_key().to_string(), - key_json.clone(), - )])), + let secret = Secret { + metadata: ObjectMeta { + name: Some(identity_ref.secret_name().to_string()), + namespace: Some(identity_ref.namespace().to_string()), ..Default::default() }, - Some(identity_ref.namespace().to_string()), - ) - .interpret(inventory, topology) - .await?; + string_data: Some(BTreeMap::from([( + identity_ref.key_json_key().to_string(), + key_json.clone(), + )])), + ..Default::default() + }; + k8s.apply_redacted(&secret, Some(identity_ref.namespace())) + .await + .map_err(|_| { + InterpretError::new(format!( + "failed to apply generated Zitadel machine Secret '{}/{}'", + identity_ref.namespace(), + identity_ref.secret_name() + )) + })?; + } + + if let Some(contract) = &me.contract { + let output_namespace = me.score.resolved_outputs_namespace(); + for project in &contract.projects { + let output = ZitadelProjectOutputRef::new(&project.project); + let project_id = config + .project_id_by_name(project.project.name()) + .ok_or_else(|| { + InterpretError::new(format!( + "project ID for '{}' missing after provisioning", + project.project.name() + )) + })?; + let data = BTreeMap::from([ + (output.project_id_key().to_string(), project_id.clone()), + ( + output.roles_claim_key().to_string(), + format!("urn:zitadel:iam:org:project:{project_id}:roles"), + ), + ]); + K8sResourceScore::single( + ConfigMap { + metadata: ObjectMeta { + name: Some(output.config_map_name()), + namespace: Some(output_namespace.to_string()), + ..Default::default() + }, + data: Some(data), + ..Default::default() + }, + Some(output_namespace.to_string()), + ) + .interpret(inventory, topology) + .await?; + } + + for application in &contract.applications { + let output = ZitadelApplicationOutputRef::new(&application.application); + let project_id = config + .project_id_by_name(application.application.project().name()) + .ok_or_else(|| { + InterpretError::new("contract project ID missing".to_string()) + })?; + let client_id = config + .application_client_id(&application.application) + .ok_or_else(|| { + InterpretError::new("contract OIDC client ID missing".to_string()) + })?; + let data = BTreeMap::from([ + (output.project_id_key().to_string(), project_id.clone()), + (output.client_id_key().to_string(), client_id.clone()), + ( + output.roles_claim_key().to_string(), + format!("urn:zitadel:iam:org:project:{project_id}:roles"), + ), + ]); + K8sResourceScore::single( + ConfigMap { + metadata: ObjectMeta { + name: Some(output.config_map_name()), + namespace: Some(output_namespace.to_string()), + ..Default::default() + }, + data: Some(data), + ..Default::default() + }, + Some(output_namespace.to_string()), + ) + .interpret(inventory, topology) + .await?; + } + + for machine in &contract.machines { + let output = ZitadelMachineOutputRef::new(&machine.machine); + let username = machine.machine.name(); + let mut data = BTreeMap::from([( + output.user_id_key().to_string(), + config + .machine_user_ids + .get(username) + .ok_or_else(|| { + InterpretError::new("contract machine ID missing".to_string()) + })? + .clone(), + )]); + if machine.key.is_some() { + data.insert( + output.key_json_key().to_string(), + config + .machine_key(username) + .ok_or_else(|| { + InterpretError::new("contract machine key missing".to_string()) + })? + .clone(), + ); + } + if machine.client_secret { + data.insert( + output.client_id_key().to_string(), + config + .machine_client_id(username) + .ok_or_else(|| { + InterpretError::new( + "contract machine client ID missing".to_string(), + ) + })? + .clone(), + ); + data.insert( + output.client_secret_key().to_string(), + config + .machine_secret(username) + .ok_or_else(|| { + InterpretError::new("contract machine secret missing".to_string()) + })? + .clone(), + ); + } + let secret_name = output.secret_name(); + let secret = Secret { + metadata: ObjectMeta { + name: Some(secret_name.clone()), + namespace: Some(output_namespace.to_string()), + ..Default::default() + }, + string_data: Some(data), + ..Default::default() + }; + k8s.apply_redacted(&secret, Some(output_namespace)) + .await + .map_err(|_| { + InterpretError::new(format!( + "failed to apply generated Zitadel machine Secret '{output_namespace}/{secret_name}'" + )) + })?; + } } Ok(Outcome { @@ -2932,9 +3652,22 @@ impl Interpret for ZitadelCredentialsExportInterpret string_data: Some(secret_data), ..Default::default() }; - K8sResourceScore::single(secret, Some(s.namespace.clone())) - .interpret(inventory, topology) - .await?; + topology + .k8s_client() + .await + .map_err(|_| { + InterpretError::new( + "failed to get K8s client for Zitadel Secret export".to_string(), + ) + })? + .apply_redacted(&secret, Some(&s.namespace)) + .await + .map_err(|_| { + InterpretError::new(format!( + "failed to apply generated Zitadel credentials Secret '{}/{}'", + s.namespace, s.secret_name + )) + })?; } Ok(Outcome::success(format!( @@ -3125,7 +3858,10 @@ mod tests { } fn interp(score: ZitadelSetupScore) -> ZitadelSetupInterpret { - ZitadelSetupInterpret { score } + ZitadelSetupInterpret { + score, + contract: None, + } } #[test] @@ -3134,6 +3870,39 @@ mod tests { assert_eq!(body["idTokenRoleAssertion"], serde_json::json!(true)); } + #[test] + fn contract_oidc_body_requests_jwt_access_tokens_and_access_token_roles() { + use super::super::contract::{ + ZitadelAccessTokenType, ZitadelApplicationRef, ZitadelLoginVersion, ZitadelOidcAppType, + ZitadelOidcAuthMethod, ZitadelOidcGrantType, ZitadelOidcResponseType, + ZitadelOidcTokenSettings, + }; + + let application = ZitadelOidcApplicationDeclaration { + application: ZitadelApplicationRef::new(ZitadelProjectRef::new("project"), "api"), + redirect_uris: vec!["https://app.example.test/callback".into()], + post_logout_redirect_uris: Vec::new(), + response_types: vec![ZitadelOidcResponseType::Code], + grant_types: vec![ZitadelOidcGrantType::AuthorizationCode], + app_type: ZitadelOidcAppType::Web, + auth_method: ZitadelOidcAuthMethod::Basic, + login_version: Some(ZitadelLoginVersion::v2()), + token_settings: ZitadelOidcTokenSettings { + access_token_type: ZitadelAccessTokenType::Jwt, + access_token_role_assertion: true, + clock_skew: Some("5s".into()), + ..Default::default() + }, + }; + + let body = ZitadelSetupInterpret::contract_oidc_config_body(&application); + assert_eq!(body["accessTokenType"], "OIDC_TOKEN_TYPE_JWT"); + assert_eq!(body["accessTokenRoleAssertion"], true); + assert_eq!(body["clockSkew"], "5s"); + assert_eq!(body["authMethodType"], "OIDC_AUTH_METHOD_TYPE_BASIC"); + assert_eq!(body["loginVersion"], serde_json::json!({ "loginV2": {} })); + } + #[test] fn api_url_https_default_port_omits_port() { let i = interp(score("zitadel.example.com")); diff --git a/harmony_app/src/application/k8s_anywhere.rs b/harmony_app/src/application/k8s_anywhere.rs new file mode 100644 index 00000000..6f9d88fd --- /dev/null +++ b/harmony_app/src/application/k8s_anywhere.rs @@ -0,0 +1,1543 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::time::Duration; + +use async_trait::async_trait; +use harmony::data::Version; +use harmony::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; +use harmony::inventory::Inventory; +use harmony::modules::k8s::resource::K8sResourceScore; +use harmony::modules::postgresql::K8sPostgreSQLScore; +use harmony::modules::zitadel::{ZitadelContract, ZitadelScore, ZitadelSetupScore}; +use harmony::score::Score; +use harmony::topology::{K8sAnywhereTopology, K8sclient}; +use harmony_types::id::Id; +use k8s_openapi::api::apps::v1::{ + Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment, +}; +use k8s_openapi::api::core::v1::{ + Capabilities, ConfigMapKeySelector, Container, ContainerPort, EnvVar, EnvVarSource, + HTTPGetAction, LocalObjectReference, PodSecurityContext, PodSpec, PodTemplateSpec, Probe, + ResourceRequirements, SeccompProfile, SecretKeySelector, SecretVolumeSource, SecurityContext, + Service as K8sService, ServicePort, ServiceSpec, TCPSocketAction, Volume, VolumeMount, +}; +use k8s_openapi::api::networking::v1::{ + HTTPIngressPath, HTTPIngressRuleValue, Ingress, IngressBackend, IngressRule, + IngressServiceBackend, IngressSpec, IngressTLS, ServiceBackendPort, +}; +use k8s_openapi::apimachinery::pkg::api::resource::Quantity; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta}; +use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; +use serde::Serialize; + +use crate::{ + AppContext, AppError, AppIdentity, HarmonyApp, ImageRefs, ImageSpec, + application::{ + Application, Cpu, FileRef, HealthCheck, ImageSource, ManagedResource, ManagedTls, Memory, + Protocol, PublicEndpointRef, RolloutStrategy, Route, Service, ValueRef, + }, +}; + +// K8sAnywhere owns these choices. They deliberately do not appear in the declaration model. +const MANAGED_TLS_ISSUER: &str = "letsencrypt-prod"; + +/// Internal-provider Score for a portable [`Application`] declaration. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct K8sAnywhereApplicationScore { + application: Application, + namespace: String, + image_overrides: BTreeMap, + #[serde(skip)] + bindings: ProviderBindings, + #[serde(skip)] + endpoints: BTreeMap, + image_pull_secret: Option, +} + +impl K8sAnywhereApplicationScore { + fn new( + application: Application, + namespace: impl Into, + bindings: ProviderBindings, + endpoints: BTreeMap, + image_pull_secret: Option, + ) -> Result { + application + .validate() + .map_err(|error| AppError::InvalidComposition(error.to_string()))?; + Ok(Self { + application, + namespace: namespace.into(), + image_overrides: BTreeMap::new(), + bindings, + endpoints, + image_pull_secret, + }) + } + + pub(crate) fn with_image_overrides(mut self, images: &ImageRefs) -> Self { + self.image_overrides = images + .iter() + .map(|(name, image)| (name.to_string(), image.to_string())) + .collect(); + self + } + + fn lower(&self) -> Result { + self.application + .validate() + .map_err(|error| AppError::InvalidComposition(error.to_string()))?; + lower( + &self.application, + &self.image_overrides, + &self.bindings, + &self.endpoints, + self.image_pull_secret.as_deref(), + ) + } +} + +impl Score for K8sAnywhereApplicationScore { + fn create_interpret(&self) -> Box> { + Box::new(K8sAnywhereApplicationInterpret { + score: self.clone(), + }) + } + + fn name(&self) -> String { + format!("K8sAnywhereApplicationScore({})", self.application.name) + } +} + +#[async_trait] +impl HarmonyApp for Application { + fn identity(&self, ctx: &AppContext) -> AppIdentity { + AppIdentity { + name: self.name.clone(), + namespace: ctx.namespace().to_string(), + } + } + + async fn scores( + &self, + ctx: &AppContext, + images: &ImageRefs, + ) -> Result>>, AppError> { + self.validate() + .map_err(|error| AppError::InvalidComposition(error.to_string()))?; + if ctx.profile() == crate::Profile::Local && !self.endpoints.is_empty() { + return Err(AppError::InvalidComposition( + "K8sAnywhere local public endpoints are not supported yet".to_string(), + )); + } + for image in &self.images { + if matches!(&image.source, ImageSource::Build(_)) { + images.require(&image.name)?; + } + } + let mut scores: Vec>> = Vec::new(); + let mut bindings = ProviderBindings::default(); + let endpoints = resolve_endpoints(self, ctx); + + for resource in &self.resources { + match resource { + ManagedResource::Postgres(database) => { + let mut score = + K8sPostgreSQLScore::new(ctx.namespace()).cluster_name(&database.name); + score.config.instances = database.instances; + bindings.databases.insert( + database.name.clone(), + application_database_binding(&database.name), + ); + scores.push(Box::new(score)); + } + ManagedResource::Zitadel(zitadel) => { + let database = K8sPostgreSQLScore::new(ctx.namespace()) + .cluster_name(format!("{}-db", zitadel.name)); + let root = database.root_account_ref(); + scores.push(Box::new(database)); + + let endpoint = endpoints.get(zitadel.endpoint.name()).ok_or_else(|| { + AppError::InvalidComposition(format!( + "unknown public endpoint '{}'", + zitadel.endpoint.name() + )) + })?; + let mut deployment = + ZitadelScore::new(&endpoint.host, ctx.namespace()).database(root); + deployment.zitadel_version = zitadel.version.clone(); + if endpoint.tls == ManagedTls::Disabled { + deployment = deployment.http(None); + } + let provider = deployment.provider_ref(); + bindings + .zitadels + .insert(zitadel.name.clone(), provider.issuer()); + scores.push(Box::new(deployment)); + + let contract = lower_contract(zitadel, &endpoints)?; + let mut setup = ZitadelSetupScore::for_provider( + &provider, + ctx.namespace(), + ctx.namespace(), + ); + if ctx.profile() == crate::Profile::Local { + setup = setup.port_forward("zitadel"); + } + let setup = setup.contract(contract); + bind_contract_outputs(&mut bindings, &zitadel.name, &setup); + scores.push(Box::new(setup)); + } + } + } + scores.push(Box::new( + K8sAnywhereApplicationScore::new( + self.clone(), + ctx.namespace(), + bindings, + endpoints, + ctx.image_pull_secret().map(|secret| secret.to_string()), + )? + .with_image_overrides(images), + )); + Ok(scores) + } + + fn images(&self, ctx: &AppContext) -> Result, AppError> { + Ok(self + .images + .iter() + .filter_map(|image| match &image.source { + ImageSource::Reference(_) => None, + ImageSource::Build(build) => Some(ImageSpec { + name: image.name.clone(), + image: ctx.image(&image.name), + context: build.context.clone(), + dockerfile: build.context.join(&build.dockerfile), + platform: build.platform.clone(), + build_args: build.build_args.clone(), + }), + }) + .collect()) + } +} + +#[derive(Debug)] +struct K8sAnywhereApplicationInterpret { + score: K8sAnywhereApplicationScore, +} + +#[async_trait] +impl Interpret for K8sAnywhereApplicationInterpret { + async fn execute( + &self, + inventory: &Inventory, + topology: &K8sAnywhereTopology, + ) -> Result { + let lowered = self + .score + .lower() + .map_err(|error| InterpretError::new(error.to_string()))?; + let namespace = self.score.namespace.clone(); + let client = topology + .k8s_client() + .await + .map_err(|error| InterpretError::new(format!("get Kubernetes client: {error}")))?; + client.ensure_namespace(&namespace).await.map_err(|error| { + InterpretError::new(format!( + "ensure application namespace '{namespace}': {error}" + )) + })?; + + if !lowered.services.is_empty() { + K8sResourceScore { + resource: lowered.services, + namespace: Some(namespace.clone()), + } + .interpret(inventory, topology) + .await?; + } + K8sResourceScore { + resource: lowered.deployments, + namespace: Some(namespace.clone()), + } + .interpret(inventory, topology) + .await?; + if let Some(ingress) = lowered.ingress { + K8sResourceScore::single(ingress, Some(namespace.clone())) + .interpret(inventory, topology) + .await?; + } + + if self.score.application.rollout.readiness.wait { + for service in &self.score.application.services { + client + .wait_until_deployment_ready( + &service.name, + Some(&namespace), + Some(self.score.application.rollout.readiness.timeout), + ) + .await + .map_err(|error| { + InterpretError::new(format!( + "application deployment {namespace}/{} not ready: {error}", + service.name + )) + })?; + } + } + + smoke_check_routes(&self.score.application.routes, &self.score.endpoints).await?; + Ok(Outcome::success_with_details( + format!("deployed application '{}'", self.score.application.name), + vec![format!( + "services: {}", + self.score.application.services.len() + )], + )) + } + + fn get_name(&self) -> InterpretName { + InterpretName::Custom("K8sAnywhereApplicationInterpret") + } + + 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::new() + } +} + +struct LoweredApplication { + deployments: Vec, + services: Vec, + ingress: Option, +} + +#[derive(Debug, Clone)] +struct ResolvedEndpoint { + host: String, + tls: ManagedTls, +} + +#[derive(Debug, Clone)] +struct DatabaseBinding { + secret: String, +} + +fn application_database_binding(cluster: &str) -> DatabaseBinding { + DatabaseBinding { + // CNPG's application-owner Secret exports username, password, URI, + // and JDBC URI without granting the workload superuser access. + secret: format!("{cluster}-app"), + } +} + +#[derive(Debug, Clone)] +struct KeyBinding { + source: String, + key: String, +} + +#[derive(Debug, Clone, Default)] +struct ProviderBindings { + databases: BTreeMap, + zitadels: BTreeMap, + projects: BTreeMap<(String, String), KeyBinding>, + applications: BTreeMap<(String, String, String), KeyBinding>, + machine_client_ids: BTreeMap<(String, String), KeyBinding>, + machine_client_secrets: BTreeMap<(String, String), KeyBinding>, + machine_json_keys: BTreeMap<(String, String), KeyBinding>, +} + +fn lower( + application: &Application, + image_overrides: &BTreeMap, + bindings: &ProviderBindings, + endpoints: &BTreeMap, + image_pull_secret: Option<&str>, +) -> Result { + let images: BTreeMap<_, _> = application + .images + .iter() + .filter_map(|image| match &image.source { + ImageSource::Reference(reference) => Some((image.name.as_str(), reference.as_str())), + ImageSource::Build(_) => None, + }) + .collect(); + let ports: BTreeMap<_, _> = application + .services + .iter() + .flat_map(|service| { + service + .ports + .iter() + .map(move |port| ((service.name.as_str(), port.name.as_str()), port.number)) + }) + .collect(); + + let deployments = application + .services + .iter() + .map(|service| { + let image = image_overrides + .get(service.image.name()) + .map(String::as_str) + .or_else(|| images.get(service.image.name()).copied()) + .ok_or_else(|| { + AppError::InvalidComposition(format!( + "unknown image '{}'", + service.image.name() + )) + })?; + deployment( + application, + service, + image, + &ports, + bindings, + endpoints, + image_pull_secret, + ) + }) + .collect::, AppError>>()?; + let services = application + .services + .iter() + .filter(|service| !service.ports.is_empty()) + .map(k8s_service) + .collect(); + + Ok(LoweredApplication { + deployments, + services, + ingress: ingress(application, endpoints), + }) +} + +fn deployment( + application: &Application, + service: &Service, + image: &str, + ports: &BTreeMap<(&str, &str), u16>, + bindings: &ProviderBindings, + endpoints: &BTreeMap, + image_pull_secret: Option<&str>, +) -> Result { + let labels = labels(&application.name, &service.name); + let mut env = Vec::new(); + let mut volumes = Vec::new(); + let mut volume_mounts = Vec::new(); + for (index, (name, value)) in service.values.iter().enumerate() { + let mut variable = EnvVar { + name: name.clone(), + ..Default::default() + }; + match value { + ValueRef::Literal(value) => variable.value = Some(value.clone()), + ValueRef::ServiceHost(reference) => variable.value = Some(reference.name().to_string()), + ValueRef::ServicePort(reference) => { + variable.value = Some(port_number(ports, reference)?.to_string()) + } + ValueRef::ServiceUrl { scheme, port } => { + variable.value = Some(format!( + "{scheme}://{}:{}", + port.service().name(), + port_number(ports, port)? + )); + } + ValueRef::PublicEndpointOrigin(endpoint) => { + variable.value = Some(endpoint_url(endpoint, "", endpoints)?); + } + ValueRef::PublicEndpointUrl { endpoint, path } => { + variable.value = Some(endpoint_url(endpoint, path, endpoints)?); + } + ValueRef::DatabaseJdbcUrl(reference) + | ValueRef::DatabaseUsername(reference) + | ValueRef::DatabasePassword(reference) => { + let binding = bindings.databases.get(reference.name()).ok_or_else(|| { + AppError::InvalidComposition(format!("unknown database '{}'", reference.name())) + })?; + let key = match value { + ValueRef::DatabaseJdbcUrl(_) => "jdbc-uri", + ValueRef::DatabaseUsername(_) => "username", + ValueRef::DatabasePassword(_) => "password", + _ => unreachable!(), + }; + variable.value_from = Some(secret_value(&binding.secret, key)); + } + ValueRef::ZitadelIssuer(reference) | ValueRef::ZitadelManagementUrl(reference) => { + variable.value = Some( + bindings + .zitadels + .get(reference.name()) + .cloned() + .ok_or_else(|| { + AppError::InvalidComposition(format!( + "unknown Zitadel '{}'", + reference.name() + )) + })?, + ); + } + ValueRef::OidcProjectId { zitadel, project } => { + variable.value_from = Some(config_value(binding( + &bindings.projects, + (&zitadel.0, project.name()), + "OIDC project", + )?)); + } + ValueRef::OidcClientId { + zitadel, + application, + } => { + let binding = bindings + .applications + .get(&( + zitadel.0.clone(), + application.project().name().to_string(), + application.name().to_string(), + )) + .ok_or_else(|| { + AppError::InvalidComposition(format!( + "unknown OIDC application '{}/{}/{}'", + zitadel.name(), + application.project().name(), + application.name() + )) + })?; + variable.value_from = Some(config_value(binding)); + } + ValueRef::MachineClientId { zitadel, machine } + | ValueRef::MachineClientSecret { zitadel, machine } => { + let source = if matches!(value, ValueRef::MachineClientId { .. }) { + &bindings.machine_client_ids + } else { + &bindings.machine_client_secrets + }; + let binding = binding(source, (&zitadel.0, machine.name()), "machine identity")?; + variable.value_from = Some(secret_value(&binding.source, &binding.key)); + } + ValueRef::File(reference) => { + variable.value = Some(reference.path().to_string()); + let volume_name = format!("value-file-{index}"); + let FileRef::MachineJsonKey { + zitadel, machine, .. + } = reference; + let binding = binding( + &bindings.machine_json_keys, + (&zitadel.0, machine.name()), + "machine identity", + )?; + let (secret, key) = (&binding.source, &binding.key); + volumes.push(Volume { + name: volume_name.clone(), + secret: Some(SecretVolumeSource { + secret_name: Some(secret.clone()), + optional: Some(false), + ..Default::default() + }), + ..Default::default() + }); + volume_mounts.push(VolumeMount { + name: volume_name, + mount_path: reference.path().to_string(), + sub_path: Some(key.clone()), + read_only: Some(true), + ..Default::default() + }); + } + } + env.push(variable); + } + + let (command, args) = service + .command + .as_ref() + .map(|command| { + ( + Some(vec![command.program.clone()]), + Some(command.args.clone()), + ) + }) + .unwrap_or_default(); + let probe = service + .health + .as_ref() + .map(|health| health_probe(health, ports)) + .transpose()?; + let strategy = match application.rollout.strategy { + RolloutStrategy::Rolling => DeploymentStrategy { + type_: Some("RollingUpdate".to_string()), + rolling_update: Some(RollingUpdateDeployment { + max_surge: Some(IntOrString::Int(1)), + max_unavailable: Some(IntOrString::Int(0)), + }), + }, + RolloutStrategy::Replace => DeploymentStrategy { + type_: Some("Recreate".to_string()), + ..Default::default() + }, + }; + + Ok(Deployment { + metadata: ObjectMeta { + name: Some(service.name.clone()), + labels: Some(labels.clone()), + ..Default::default() + }, + spec: Some(DeploymentSpec { + replicas: Some(application.rollout.replicas.try_into().map_err(|_| { + AppError::InvalidComposition("rollout replicas exceed provider limit".to_string()) + })?), + strategy: Some(strategy), + selector: LabelSelector { + match_labels: Some(labels.clone()), + ..Default::default() + }, + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some(labels), + ..Default::default() + }), + spec: Some(PodSpec { + automount_service_account_token: Some(false), + image_pull_secrets: image_pull_secret.map(|name| { + vec![LocalObjectReference { + name: name.to_string(), + }] + }), + security_context: Some(PodSecurityContext { + run_as_non_root: Some(true), + seccomp_profile: Some(SeccompProfile { + type_: "RuntimeDefault".to_string(), + ..Default::default() + }), + ..Default::default() + }), + containers: vec![Container { + name: service.name.clone(), + image: Some(image.to_string()), + image_pull_policy: Some("IfNotPresent".to_string()), + command, + args, + ports: (!service.ports.is_empty()).then(|| { + service + .ports + .iter() + .map(|port| ContainerPort { + name: Some(port.name.clone()), + container_port: i32::from(port.number), + protocol: Some(protocol(port.protocol).to_string()), + ..Default::default() + }) + .collect() + }), + env: (!env.is_empty()).then_some(env), + volume_mounts: (!volume_mounts.is_empty()).then_some(volume_mounts), + readiness_probe: probe.clone(), + liveness_probe: probe, + resources: resources(service), + security_context: Some(SecurityContext { + allow_privilege_escalation: Some(false), + capabilities: Some(Capabilities { + drop: Some(vec!["ALL".to_string()]), + ..Default::default() + }), + run_as_non_root: Some(true), + ..Default::default() + }), + ..Default::default() + }], + volumes: (!volumes.is_empty()).then_some(volumes), + ..Default::default() + }), + }, + ..Default::default() + }), + ..Default::default() + }) +} + +fn k8s_service(service: &Service) -> K8sService { + K8sService { + metadata: ObjectMeta { + name: Some(service.name.clone()), + ..Default::default() + }, + spec: Some(ServiceSpec { + type_: Some("ClusterIP".to_string()), + selector: Some(BTreeMap::from([( + "app.kubernetes.io/component".to_string(), + service.name.clone(), + )])), + ports: Some( + service + .ports + .iter() + .map(|port| ServicePort { + name: Some(port.name.clone()), + port: i32::from(port.number), + target_port: Some(IntOrString::String(port.name.clone())), + protocol: Some(protocol(port.protocol).to_string()), + ..Default::default() + }) + .collect(), + ), + ..Default::default() + }), + ..Default::default() + } +} + +fn ingress( + application: &Application, + endpoints: &BTreeMap, +) -> Option { + if application.routes.is_empty() { + return None; + } + let mut hosts = Vec::::new(); + let mut paths = BTreeMap::>::new(); + for route in &application.routes { + let host = endpoints.get(route.endpoint.name())?.host.clone(); + if !paths.contains_key(&host) { + hosts.push(host.clone()); + } + paths.entry(host).or_default().push(HTTPIngressPath { + path: Some(route.path.clone()), + path_type: "Prefix".to_string(), + backend: IngressBackend { + service: Some(IngressServiceBackend { + name: route.target.service().name().to_string(), + port: Some(ServiceBackendPort { + name: Some(route.target.name().to_string()), + number: None, + }), + }), + ..Default::default() + }, + }); + } + let tls_hosts: Vec<_> = application + .routes + .iter() + .filter_map(|route| { + endpoints + .get(route.endpoint.name()) + .filter(|endpoint| endpoint.tls == ManagedTls::Managed) + .map(|endpoint| endpoint.host.clone()) + }) + .collect::>() + .into_iter() + .collect(); + let managed_tls = !tls_hosts.is_empty(); + Some(Ingress { + metadata: ObjectMeta { + name: Some(application.name.clone()), + annotations: managed_tls.then(|| { + BTreeMap::from([( + "cert-manager.io/cluster-issuer".to_string(), + MANAGED_TLS_ISSUER.to_string(), + )]) + }), + ..Default::default() + }, + spec: Some(IngressSpec { + rules: Some( + hosts + .into_iter() + .map(|host| IngressRule { + host: (!host.is_empty()).then_some(host.clone()), + http: Some(HTTPIngressRuleValue { + paths: paths.remove(&host).unwrap_or_default(), + }), + }) + .collect(), + ), + tls: managed_tls.then(|| { + vec![IngressTLS { + hosts: Some(tls_hosts), + secret_name: Some(format!("{}-tls", application.name)), + }] + }), + ..Default::default() + }), + ..Default::default() + }) +} + +fn health_probe( + health: &HealthCheck, + ports: &BTreeMap<(&str, &str), u16>, +) -> Result { + let (reference, interval, timeout, initial_delay) = match health { + HealthCheck::Http { + port, + interval, + timeout, + initial_delay, + .. + } + | HealthCheck::Tcp { + port, + interval, + timeout, + initial_delay, + } => (port, interval, timeout, initial_delay), + }; + let port = IntOrString::Int(i32::from(port_number(ports, reference)?)); + let mut probe = Probe { + initial_delay_seconds: Some(seconds(*initial_delay)), + period_seconds: Some(seconds(*interval)), + timeout_seconds: Some(seconds(*timeout)), + failure_threshold: Some(3), + ..Default::default() + }; + match health { + HealthCheck::Http { path, .. } => { + probe.http_get = Some(HTTPGetAction { + path: Some(path.clone()), + port, + scheme: Some("HTTP".to_string()), + ..Default::default() + }); + } + HealthCheck::Tcp { .. } => { + probe.tcp_socket = Some(TCPSocketAction { + port, + ..Default::default() + }); + } + } + Ok(probe) +} + +fn resources(service: &Service) -> Option { + let requests = [ + service + .resources + .cpu_request + .map(|value| ("cpu", cpu(value))), + service + .resources + .memory_request + .map(|value| ("memory", memory(value))), + ] + .into_iter() + .flatten() + .map(|(name, value)| (name.to_string(), Quantity(value))) + .collect::>(); + let limits = [ + service.resources.cpu_limit.map(|value| ("cpu", cpu(value))), + service + .resources + .memory_limit + .map(|value| ("memory", memory(value))), + ] + .into_iter() + .flatten() + .map(|(name, value)| (name.to_string(), Quantity(value))) + .collect::>(); + (!requests.is_empty() || !limits.is_empty()).then_some(ResourceRequirements { + requests: (!requests.is_empty()).then_some(requests), + limits: (!limits.is_empty()).then_some(limits), + ..Default::default() + }) +} + +fn cpu(value: Cpu) -> String { + match value { + Cpu::Millicores(value) => format!("{value}m"), + Cpu::Cores(value) => value.to_string(), + } +} + +fn memory(value: Memory) -> String { + match value { + Memory::Mebibytes(value) => format!("{value}Mi"), + Memory::Gibibytes(value) => format!("{value}Gi"), + } +} + +fn labels(application: &str, service: &str) -> BTreeMap { + let mut labels = BTreeMap::from([ + ( + "app.kubernetes.io/component".to_string(), + service.to_string(), + ), + ( + "app.kubernetes.io/managed-by".to_string(), + "harmony".to_string(), + ), + ]); + if !application.is_empty() { + labels.insert( + "app.kubernetes.io/part-of".to_string(), + application.to_string(), + ); + } + labels +} + +fn port_number( + ports: &BTreeMap<(&str, &str), u16>, + reference: &crate::application::PortRef, +) -> Result { + ports + .get(&(reference.service().name(), reference.name())) + .copied() + .ok_or_else(|| { + AppError::InvalidComposition(format!( + "unknown port '{}.{}'", + reference.service().name(), + reference.name() + )) + }) +} + +fn protocol(protocol: Protocol) -> &'static str { + match protocol { + Protocol::Tcp => "TCP", + Protocol::Udp => "UDP", + } +} + +fn secret_value(secret: &str, key: &str) -> EnvVarSource { + EnvVarSource { + secret_key_ref: Some(SecretKeySelector { + name: secret.to_string(), + key: key.to_string(), + optional: Some(false), + }), + ..Default::default() + } +} + +fn config_value(binding: &KeyBinding) -> EnvVarSource { + EnvVarSource { + config_map_key_ref: Some(ConfigMapKeySelector { + name: binding.source.clone(), + key: binding.key.clone(), + optional: Some(false), + }), + ..Default::default() + } +} + +fn binding<'a>( + bindings: &'a BTreeMap<(String, String), KeyBinding>, + key: (&str, &str), + kind: &str, +) -> Result<&'a KeyBinding, AppError> { + bindings + .get(&(key.0.to_string(), key.1.to_string())) + .ok_or_else(|| { + AppError::InvalidComposition(format!("unknown {kind} '{}.{}'", key.0, key.1)) + }) +} + +fn resolve_endpoints( + application: &Application, + ctx: &AppContext, +) -> BTreeMap { + application + .endpoints + .iter() + .map(|endpoint| { + ( + endpoint.name.clone(), + ResolvedEndpoint { + host: ctx.service_host(&endpoint.name), + tls: endpoint.tls, + }, + ) + }) + .collect() +} + +fn endpoint_url( + endpoint: &PublicEndpointRef, + path: &str, + endpoints: &BTreeMap, +) -> Result { + let endpoint = endpoints.get(endpoint.name()).ok_or_else(|| { + AppError::InvalidComposition(format!("unknown public endpoint '{}'", endpoint.name())) + })?; + let scheme = if endpoint.tls == ManagedTls::Managed { + "https" + } else { + "http" + }; + Ok(format!("{scheme}://{}{}", endpoint.host, path)) +} + +fn lower_contract( + managed: &crate::application::ManagedZitadel, + endpoints: &BTreeMap, +) -> Result { + let mut lowered = managed.contract.clone(); + for redirect in &managed.redirects { + let application = lowered + .applications + .iter_mut() + .find(|application| application.application == redirect.application) + .ok_or_else(|| { + AppError::InvalidComposition(format!( + "OIDC application '{}' is not declared", + redirect.application.name() + )) + })?; + let url = endpoint_url(&redirect.endpoint, &redirect.path, endpoints)?; + if redirect.post_logout { + application.post_logout_redirect_uris.push(url); + } else { + application.redirect_uris.push(url); + } + } + Ok(lowered) +} + +fn bind_contract_outputs( + bindings: &mut ProviderBindings, + zitadel: &str, + setup: &harmony::modules::zitadel::ZitadelContractSetupScore, +) { + for project in &setup.contract.projects { + let output = setup.project_output(&project.project); + bindings.projects.insert( + (zitadel.to_string(), project.project.name().to_string()), + KeyBinding { + source: output.config_map_name().to_string(), + key: output.project_id_key().to_string(), + }, + ); + } + for application in &setup.contract.applications { + let output = setup.application_output(&application.application); + bindings.applications.insert( + ( + zitadel.to_string(), + application.application.project().name().to_string(), + application.application.name().to_string(), + ), + KeyBinding { + source: output.config_map_name().to_string(), + key: output.client_id_key().to_string(), + }, + ); + } + for machine in &setup.contract.machines { + let output = setup.machine_output(&machine.machine); + let coordinate = (zitadel.to_string(), machine.machine.name().to_string()); + bindings.machine_client_ids.insert( + coordinate.clone(), + KeyBinding { + source: output.secret_name().to_string(), + key: output.client_id_key().to_string(), + }, + ); + bindings.machine_client_secrets.insert( + coordinate.clone(), + KeyBinding { + source: output.secret_name().to_string(), + key: output.client_secret_key().to_string(), + }, + ); + bindings.machine_json_keys.insert( + coordinate, + KeyBinding { + source: output.secret_name().to_string(), + key: output.key_json_key().to_string(), + }, + ); + } +} + +fn seconds(duration: Duration) -> i32 { + duration.as_secs().clamp(1, i32::MAX as u64) as i32 +} + +async fn smoke_check_routes( + routes: &[Route], + endpoints: &BTreeMap, +) -> Result<(), InterpretError> { + let routes: Vec<_> = routes.iter().filter(|route| route.smoke_check).collect(); + if routes.is_empty() { + return Ok(()); + } + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|error| InterpretError::new(format!("build route smoke-check client: {error}")))?; + for route in routes { + let endpoint = endpoints.get(route.endpoint.name()).ok_or_else(|| { + InterpretError::new(format!( + "unknown public endpoint '{}'", + route.endpoint.name() + )) + })?; + let scheme = if endpoint.tls == ManagedTls::Managed { + "https" + } else { + "http" + }; + let url = format!("{scheme}://{}{}", endpoint.host, route.path); + let mut last_error = "route did not respond".to_string(); + tokio::time::timeout(Duration::from_secs(180), async { + loop { + match client.get(&url).send().await { + Ok(response) if response.status().is_success() => return, + Ok(response) => last_error = format!("HTTP {}", response.status()), + Err(error) => last_error = error.to_string(), + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }) + .await + .map_err(|_| { + InterpretError::new(format!( + "application route {url} failed its smoke check after 180s: {last_error}" + )) + })?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::{ + Image, LogicalEndpoint, ManagedZitadel, Port, ResourceIntent, ServiceRef, + }; + use crate::{Context, ContextSpec, LocalContext, OpenBaoClusterAccess, RemoteContext}; + use harmony::modules::zitadel::{ + ZitadelApplicationRef, ZitadelContract, ZitadelOidcApplicationDeclaration, + ZitadelProjectDeclaration, ZitadelProjectRef, + }; + + fn fixture() -> Application { + let image = Image::new("api-image", "example/api:1"); + let api = Service::new("api", image.reference()) + .port(Port::tcp("http", 8080)) + .value( + "SELF", + ValueRef::service_url("http", ServiceRef::new("api").port("http")), + ) + .resources(ResourceIntent { + cpu_request: Some(Cpu::Millicores(100)), + memory_limit: Some(Memory::Mebibytes(128)), + ..Default::default() + }); + let api_ref = api.reference(); + let endpoint = LogicalEndpoint::new("sample").managed_tls(); + let endpoint_ref = endpoint.reference(); + Application::new("sample") + .image(image) + .endpoint(endpoint) + .service(api) + .route(Route::new(endpoint_ref, "/api", api_ref.port("http"))) + } + + fn endpoints() -> BTreeMap { + BTreeMap::from([( + "sample".to_string(), + ResolvedEndpoint { + host: "sample.test".to_string(), + tls: ManagedTls::Managed, + }, + )]) + } + + fn lower_fixture( + app: &Application, + images: &BTreeMap, + ) -> Result { + lower( + app, + images, + &ProviderBindings::default(), + &endpoints(), + None, + ) + } + + #[test] + fn lowers_values_security_resources_and_files() { + let lowered = lower_fixture(&fixture(), &BTreeMap::new()).unwrap(); + let pod = lowered.deployments[0] + .spec + .as_ref() + .unwrap() + .template + .spec + .as_ref() + .unwrap(); + assert_eq!(pod.automount_service_account_token, Some(false)); + let container = &pod.containers[0]; + assert_eq!( + container + .env + .as_ref() + .unwrap() + .iter() + .find(|value| value.name == "SELF") + .unwrap() + .value + .as_deref(), + Some("http://api:8080") + ); + assert_eq!( + container + .resources + .as_ref() + .unwrap() + .limits + .as_ref() + .unwrap()["memory"] + .0, + "128Mi" + ); + assert_eq!( + container + .security_context + .as_ref() + .unwrap() + .allow_privilege_escalation, + Some(false) + ); + } + + #[test] + fn preserves_route_order_and_managed_tls_policy() { + let mut app = fixture(); + let target = app.services[0].reference().port("http"); + app.routes + .push(Route::new(PublicEndpointRef::new("sample"), "/", target)); + let ingress = lower_fixture(&app, &BTreeMap::new()) + .unwrap() + .ingress + .unwrap(); + let paths = &ingress.spec.as_ref().unwrap().rules.as_ref().unwrap()[0] + .http + .as_ref() + .unwrap() + .paths; + assert_eq!(paths[0].path.as_deref(), Some("/api")); + assert_eq!(paths[1].path.as_deref(), Some("/")); + assert_eq!( + ingress.metadata.annotations.as_ref().unwrap()["cert-manager.io/cluster-issuer"], + MANAGED_TLS_ISSUER + ); + assert_eq!( + ingress.spec.unwrap().tls.unwrap()[0] + .hosts + .as_ref() + .unwrap(), + &["sample.test"] + ); + } + + #[test] + fn digest_override_changes_deployment_pod_template_image() { + let overrides = BTreeMap::from([( + "api-image".to_string(), + "registry.example/api@sha256:abc".to_string(), + )]); + let lowered = lower_fixture(&fixture(), &overrides).unwrap(); + let image = lowered.deployments[0] + .spec + .as_ref() + .unwrap() + .template + .spec + .as_ref() + .unwrap() + .containers[0] + .image + .as_deref(); + assert_eq!(image, Some("registry.example/api@sha256:abc")); + } + + #[test] + fn context_pull_secret_is_lowered_only_to_the_pod() { + let lowered = lower( + &fixture(), + &BTreeMap::new(), + &ProviderBindings::default(), + &endpoints(), + Some("registry-credentials"), + ) + .unwrap(); + let pod = lowered.deployments[0] + .spec + .as_ref() + .unwrap() + .template + .spec + .as_ref() + .unwrap(); + assert_eq!( + pod.image_pull_secrets.as_ref().unwrap()[0].name, + "registry-credentials" + ); + } + + #[test] + fn semantic_values_lower_to_typed_provider_references() { + let mut app = fixture(); + let database = crate::application::DatabaseRef::new("app-db"); + let zitadel = crate::application::ZitadelRef::new("identity"); + let project = ZitadelProjectRef::new("recipe"); + let application = ZitadelApplicationRef::new(project.clone(), "web"); + let machine = harmony::modules::zitadel::ZitadelMachineRef::new("backend"); + app.services[0].values.extend([ + ("JDBC_URL".into(), ValueRef::DatabaseJdbcUrl(database)), + ( + "PUBLIC_ORIGIN".into(), + PublicEndpointRef::new("sample").origin(), + ), + ( + "CALLBACK_URL".into(), + PublicEndpointRef::new("sample").url("/callback"), + ), + ( + "DB_USERNAME".into(), + ValueRef::DatabaseUsername(crate::application::DatabaseRef::new("app-db")), + ), + ( + "DB_PASSWORD".into(), + ValueRef::DatabasePassword(crate::application::DatabaseRef::new("app-db")), + ), + ("OIDC_CLIENT_ID".into(), zitadel.oidc_client_id(application)), + ( + "MACHINE_SECRET".into(), + zitadel.machine_client_secret(machine.clone()), + ), + ( + "MACHINE_KEY".into(), + ValueRef::File(zitadel.machine_json_key(machine, "/run/identity/key.json")), + ), + ]); + let key = KeyBinding { + source: "generated-output".into(), + key: "value".into(), + }; + let bindings = ProviderBindings { + databases: BTreeMap::from([("app-db".into(), application_database_binding("app-db"))]), + applications: BTreeMap::from([( + ("identity".into(), "recipe".into(), "web".into()), + key.clone(), + )]), + machine_client_secrets: BTreeMap::from([( + ("identity".into(), "backend".into()), + key.clone(), + )]), + machine_json_keys: BTreeMap::from([(("identity".into(), "backend".into()), key)]), + ..Default::default() + }; + let lowered = lower(&app, &BTreeMap::new(), &bindings, &endpoints(), None).unwrap(); + let container = &lowered.deployments[0] + .spec + .as_ref() + .unwrap() + .template + .spec + .as_ref() + .unwrap() + .containers[0]; + let env = container.env.as_ref().unwrap(); + let jdbc = env.iter().find(|value| value.name == "JDBC_URL").unwrap(); + assert_eq!( + jdbc.value_from + .as_ref() + .unwrap() + .secret_key_ref + .as_ref() + .unwrap() + .name, + "app-db-app" + ); + assert_eq!( + jdbc.value_from + .as_ref() + .unwrap() + .secret_key_ref + .as_ref() + .unwrap() + .key, + "jdbc-uri" + ); + for name in ["DB_USERNAME", "DB_PASSWORD"] { + let selector = env + .iter() + .find(|value| value.name == name) + .unwrap() + .value_from + .as_ref() + .unwrap() + .secret_key_ref + .as_ref() + .unwrap(); + assert_eq!(selector.name, "app-db-app"); + } + assert_eq!( + env.iter() + .find(|value| value.name == "PUBLIC_ORIGIN") + .unwrap() + .value + .as_deref(), + Some("https://sample.test") + ); + assert_eq!( + env.iter() + .find(|value| value.name == "CALLBACK_URL") + .unwrap() + .value + .as_deref(), + Some("https://sample.test/callback") + ); + let oidc = env + .iter() + .find(|value| value.name == "OIDC_CLIENT_ID") + .unwrap(); + assert_eq!( + oidc.value_from + .as_ref() + .unwrap() + .config_map_key_ref + .as_ref() + .unwrap() + .name, + "generated-output" + ); + assert_eq!( + container.volume_mounts.as_ref().unwrap()[0].mount_path, + "/run/identity/key.json" + ); + } + + #[test] + fn contract_urls_resolve_from_logical_endpoints() { + let project = ZitadelProjectRef::new("recipe"); + let application = ZitadelApplicationRef::new(project.clone(), "web"); + let contract = ZitadelContract::default() + .project(ZitadelProjectDeclaration::new(project)) + .application(ZitadelOidcApplicationDeclaration::web_pkce( + application.clone(), + Vec::new(), + )); + let managed = ManagedZitadel::new("identity", PublicEndpointRef::new("sample")) + .contract(contract) + .redirect(application, PublicEndpointRef::new("sample"), "/callback"); + let lowered = lower_contract(&managed, &endpoints()).unwrap(); + assert_eq!( + lowered.applications[0].redirect_uris, + ["https://sample.test/callback"] + ); + } + + #[test] + fn image_build_declaration_becomes_image_spec() { + let app = Application::new("sample") + .image( + Image::build("api", "services/api") + .dockerfile("Containerfile") + .platform("linux/amd64") + .build_arg("PROFILE", Some("prod")), + ) + .service(Service::new( + "api", + crate::application::ImageRef::new("api"), + )); + let context = Context { + name: "dev".parse().unwrap(), + namespace: "sample".parse().unwrap(), + spec: ContextSpec::Local(LocalContext::ManagedK3d), + }; + let ctx = AppContext::load_metadata(&context, "1.2.3", None); + let specs = >::images(&app, &ctx).unwrap(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].image, "localhost/api:1.2.3"); + assert_eq!( + specs[0].dockerfile.to_str(), + Some("services/api/Containerfile") + ); + assert_eq!(specs[0].build_args[0].0, "PROFILE"); + } + + #[tokio::test] + async fn local_public_endpoints_are_rejected_before_lowering() { + let context = Context { + name: "dev".parse().unwrap(), + namespace: "sample".parse().unwrap(), + spec: ContextSpec::Local(LocalContext::ManagedK3d), + }; + let ctx = AppContext::load_metadata(&context, "1.0.0", None); + let error = fixture() + .scores(&ctx, &ImageRefs::default()) + .await + .err() + .unwrap(); + assert!( + error + .to_string() + .contains("local public endpoints are not supported") + ); + } + + #[tokio::test] + async fn managed_zitadel_scores_are_dependency_ordered() { + let web_endpoint = LogicalEndpoint::new("web"); + let auth_endpoint = LogicalEndpoint::new("auth"); + let project = ZitadelProjectRef::new("recipe"); + let oidc = ZitadelApplicationRef::new(project.clone(), "web"); + let contract = ZitadelContract::default() + .project(ZitadelProjectDeclaration::new(project)) + .application(ZitadelOidcApplicationDeclaration::web_pkce( + oidc.clone(), + Vec::new(), + )); + let zitadel = ManagedZitadel::new("identity", auth_endpoint.reference()) + .contract(contract) + .redirect(oidc, web_endpoint.reference(), "/auth/callback"); + let app = Application::new("sample") + .image(Image::new("web", "example/web:1")) + .endpoint(web_endpoint) + .endpoint(auth_endpoint) + .resource(zitadel) + .service(Service::new( + "web", + crate::application::ImageRef::new("web"), + )); + let context = Context { + name: "prod".parse().unwrap(), + namespace: "sample".parse().unwrap(), + spec: ContextSpec::Remote(RemoteContext { + registry: "registry.example.com".parse().unwrap(), + repository: "apps".parse().unwrap(), + domain: "example.com".parse().unwrap(), + image_pull_secret: None, + access: OpenBaoClusterAccess { + namespace: "sample".parse().unwrap(), + url: "https://bao.example.com".parse().unwrap(), + role: "deployer".parse().unwrap(), + zitadel_url: "https://auth.example.com".parse().unwrap(), + zitadel_audience: "harmony".parse().unwrap(), + }, + }), + }; + let ctx = AppContext::load_metadata(&context, "1.0.0", None); + let scores = app.scores(&ctx, &ImageRefs::default()).await.unwrap(); + let names: Vec<_> = scores.iter().map(|score| score.name()).collect(); + assert!(names[0].starts_with("PostgreSQLScore")); + assert_eq!(names[1], "ZitadelScore"); + assert_eq!(names[2], "ZitadelContractSetupScore"); + assert_eq!(names[3], "K8sAnywhereApplicationScore(sample)"); + } +} diff --git a/harmony_app/src/application/mod.rs b/harmony_app/src/application/mod.rs new file mode 100644 index 00000000..a21ce5f0 --- /dev/null +++ b/harmony_app/src/application/mod.rs @@ -0,0 +1,19 @@ +//! Topology-neutral application declarations. K8sAnywhere is the first adapter; +//! declarations are not deployable until an adapter exists for the target topology. + +mod k8s_anywhere; +mod model; +mod resources; +mod validation; + +pub use harmony::modules::zitadel::contract as zitadel; +pub use model::{ + Application, Command, Cpu, FileRef, HealthCheck, Image, ImageBuild, ImageRef, ImageSource, + LogicalEndpoint, ManagedTls, Memory, Port, PortRef, Protocol, PublicEndpointRef, + ReadinessIntent, ResourceIntent, RolloutIntent, RolloutStrategy, Route, Service, ServiceRef, + ValueRef, +}; +pub use resources::{ + DatabaseRef, ManagedPostgres, ManagedResource, ManagedZitadel, OidcRedirect, ZitadelRef, +}; +pub use validation::ApplicationValidationError; diff --git a/harmony_app/src/application/model.rs b/harmony_app/src/application/model.rs new file mode 100644 index 00000000..72c8e21a --- /dev/null +++ b/harmony_app/src/application/model.rs @@ -0,0 +1,550 @@ +use std::path::PathBuf; +use std::time::Duration; + +use serde::Serialize; + +use super::{ApplicationValidationError, DatabaseRef, ManagedResource, ZitadelRef}; +use harmony::modules::zitadel::{ZitadelApplicationRef, ZitadelMachineRef, ZitadelProjectRef}; + +/// A topology-neutral application declaration. K8sAnywhere is currently its first adapter. +#[derive(Debug, Clone, Serialize)] +pub struct Application { + pub name: String, + pub images: Vec, + pub endpoints: Vec, + pub resources: Vec, + pub services: Vec, + /// Routes are evaluated in declaration order. + pub routes: Vec, + pub rollout: RolloutIntent, +} + +impl Application { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + images: Vec::new(), + endpoints: Vec::new(), + resources: Vec::new(), + services: Vec::new(), + routes: Vec::new(), + rollout: RolloutIntent::default(), + } + } + + pub fn image(mut self, image: Image) -> Self { + self.images.push(image); + self + } + + pub fn service(mut self, service: Service) -> Self { + self.services.push(service); + self + } + + pub fn endpoint(mut self, endpoint: LogicalEndpoint) -> Self { + self.endpoints.push(endpoint); + self + } + + pub fn resource(mut self, resource: impl Into) -> Self { + self.resources.push(resource.into()); + self + } + + pub fn route(mut self, route: Route) -> Self { + self.routes.push(route); + self + } + + pub fn rollout(mut self, rollout: RolloutIntent) -> Self { + self.rollout = rollout; + self + } + + pub fn validate(&self) -> Result<(), ApplicationValidationError> { + super::validation::validate(self) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Image { + pub name: String, + pub source: ImageSource, +} + +impl Image { + pub fn new(name: impl Into, reference: impl Into) -> Self { + Self { + name: name.into(), + source: ImageSource::Reference(reference.into()), + } + } + + pub fn reference(&self) -> ImageRef { + ImageRef(self.name.clone()) + } + + pub fn build(name: impl Into, context: impl Into) -> Self { + Self { + name: name.into(), + source: ImageSource::Build(ImageBuild { + context: context.into(), + dockerfile: PathBuf::from("Dockerfile"), + platform: None, + build_args: Vec::new(), + }), + } + } + + pub fn dockerfile(mut self, dockerfile: impl Into) -> Self { + if let ImageSource::Build(build) = &mut self.source { + build.dockerfile = dockerfile.into(); + } + self + } + + pub fn platform(mut self, platform: impl Into) -> Self { + if let ImageSource::Build(build) = &mut self.source { + build.platform = Some(platform.into()); + } + self + } + + pub fn build_arg(mut self, name: impl Into, value: Option>) -> Self { + if let ImageSource::Build(build) = &mut self.source { + build.build_args.push((name.into(), value.map(Into::into))); + } + self + } +} + +#[derive(Debug, Clone, Serialize)] +pub enum ImageSource { + Reference(String), + Build(ImageBuild), +} + +#[derive(Debug, Clone, Serialize)] +pub struct ImageBuild { + pub context: PathBuf, + pub dockerfile: PathBuf, + pub platform: Option, + pub build_args: Vec<(String, Option)>, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct ImageRef(pub(crate) String); + +impl ImageRef { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + pub fn name(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct ServiceRef(pub(crate) String); + +impl ServiceRef { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + pub fn name(&self) -> &str { + &self.0 + } + + pub fn port(&self, name: impl Into) -> PortRef { + PortRef { + service: self.clone(), + port: name.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct PortRef { + pub(crate) service: ServiceRef, + pub(crate) port: String, +} + +impl PortRef { + pub fn new(service: ServiceRef, port: impl Into) -> Self { + Self { + service, + port: port.into(), + } + } + + pub fn service(&self) -> &ServiceRef { + &self.service + } + + pub fn name(&self) -> &str { + &self.port + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Service { + pub name: String, + pub image: ImageRef, + pub command: Option, + pub ports: Vec, + pub values: Vec<(String, ValueRef)>, + pub health: Option, + pub resources: ResourceIntent, +} + +impl Service { + pub fn new(name: impl Into, image: ImageRef) -> Self { + Self { + name: name.into(), + image, + command: None, + ports: Vec::new(), + values: Vec::new(), + health: None, + resources: ResourceIntent::default(), + } + } + + pub fn reference(&self) -> ServiceRef { + ServiceRef(self.name.clone()) + } + + pub fn command(mut self, command: Command) -> Self { + self.command = Some(command); + self + } + + pub fn port(mut self, port: Port) -> Self { + self.ports.push(port); + self + } + + pub fn value(mut self, name: impl Into, value: ValueRef) -> Self { + self.values.push((name.into(), value)); + self + } + + pub fn health(mut self, health: HealthCheck) -> Self { + self.health = Some(health); + self + } + + pub fn resources(mut self, resources: ResourceIntent) -> Self { + self.resources = resources; + self + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Command { + pub program: String, + pub args: Vec, +} + +impl Command { + pub fn new( + program: impl Into, + args: impl IntoIterator>, + ) -> Self { + Self { + program: program.into(), + args: args.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Port { + pub name: String, + pub number: u16, + pub protocol: Protocol, +} + +impl Port { + pub fn tcp(name: impl Into, number: u16) -> Self { + Self { + name: name.into(), + number, + protocol: Protocol::Tcp, + } + } + + pub fn udp(name: impl Into, number: u16) -> Self { + Self { + name: name.into(), + number, + protocol: Protocol::Udp, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Protocol { + Tcp, + Udp, +} + +/// A value is either literal or resolved from desired-state semantics. +#[derive(Debug, Clone, Serialize)] +pub enum ValueRef { + Literal(String), + ServiceHost(ServiceRef), + ServicePort(PortRef), + ServiceUrl { + scheme: String, + port: PortRef, + }, + PublicEndpointOrigin(PublicEndpointRef), + PublicEndpointUrl { + endpoint: PublicEndpointRef, + path: String, + }, + DatabaseJdbcUrl(DatabaseRef), + DatabaseUsername(DatabaseRef), + DatabasePassword(DatabaseRef), + ZitadelIssuer(ZitadelRef), + ZitadelManagementUrl(ZitadelRef), + OidcProjectId { + zitadel: ZitadelRef, + project: ZitadelProjectRef, + }, + OidcClientId { + zitadel: ZitadelRef, + application: ZitadelApplicationRef, + }, + MachineClientId { + zitadel: ZitadelRef, + machine: ZitadelMachineRef, + }, + MachineClientSecret { + zitadel: ZitadelRef, + machine: ZitadelMachineRef, + }, + /// Mount the secret key at `path` and set the value to that path. + File(FileRef), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum FileRef { + MachineJsonKey { + zitadel: ZitadelRef, + machine: ZitadelMachineRef, + path: String, + }, +} + +impl FileRef { + pub fn path(&self) -> &str { + match self { + Self::MachineJsonKey { path, .. } => path, + } + } +} + +impl ValueRef { + pub fn literal(value: impl Into) -> Self { + Self::Literal(value.into()) + } + + pub fn service_url(scheme: impl Into, port: PortRef) -> Self { + Self::ServiceUrl { + scheme: scheme.into(), + port, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub enum HealthCheck { + Http { + port: PortRef, + path: String, + interval: Duration, + timeout: Duration, + initial_delay: Duration, + }, + Tcp { + port: PortRef, + interval: Duration, + timeout: Duration, + initial_delay: Duration, + }, +} + +impl HealthCheck { + pub fn http(port: PortRef, path: impl Into) -> Self { + Self::Http { + port, + path: path.into(), + interval: Duration::from_secs(10), + timeout: Duration::from_secs(2), + initial_delay: Duration::from_secs(0), + } + } + + pub fn tcp(port: PortRef) -> Self { + Self::Tcp { + port, + interval: Duration::from_secs(10), + timeout: Duration::from_secs(2), + initial_delay: Duration::from_secs(0), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Cpu { + Millicores(u32), + Cores(u16), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Memory { + Mebibytes(u32), + Gibibytes(u32), +} + +/// Portable scheduler intent with typed CPU and memory units. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ResourceIntent { + pub cpu_request: Option, + pub cpu_limit: Option, + pub memory_request: Option, + pub memory_limit: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Route { + pub endpoint: PublicEndpointRef, + pub path: String, + pub target: PortRef, + pub smoke_check: bool, +} + +impl Route { + pub fn new(endpoint: PublicEndpointRef, path: impl Into, target: PortRef) -> Self { + Self { + endpoint, + path: path.into(), + target, + smoke_check: true, + } + } + + pub fn smoke_check(mut self, enabled: bool) -> Self { + self.smoke_check = enabled; + self + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct LogicalEndpoint { + pub name: String, + pub tls: ManagedTls, +} + +impl LogicalEndpoint { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + tls: ManagedTls::Disabled, + } + } + + pub fn managed_tls(mut self) -> Self { + self.tls = ManagedTls::Managed; + self + } + + pub fn reference(&self) -> PublicEndpointRef { + PublicEndpointRef(self.name.clone()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct PublicEndpointRef(pub(crate) String); + +impl PublicEndpointRef { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + pub fn name(&self) -> &str { + &self.0 + } + + pub fn origin(&self) -> ValueRef { + ValueRef::PublicEndpointOrigin(self.clone()) + } + + pub fn url(&self, path: impl Into) -> ValueRef { + ValueRef::PublicEndpointUrl { + endpoint: self.clone(), + path: path.into(), + } + } +} + +impl From for ManagedResource { + fn from(value: super::ManagedPostgres) -> Self { + Self::Postgres(value) + } +} + +impl From for ManagedResource { + fn from(value: super::ManagedZitadel) -> Self { + Self::Zitadel(value) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum ManagedTls { + Disabled, + Managed, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RolloutIntent { + pub replicas: u32, + pub strategy: RolloutStrategy, + pub readiness: ReadinessIntent, +} + +impl Default for RolloutIntent { + fn default() -> Self { + Self { + replicas: 1, + strategy: RolloutStrategy::Rolling, + readiness: ReadinessIntent::default(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum RolloutStrategy { + Rolling, + Replace, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReadinessIntent { + pub wait: bool, + pub timeout: Duration, +} + +impl Default for ReadinessIntent { + fn default() -> Self { + Self { + wait: true, + timeout: Duration::from_secs(180), + } + } +} diff --git a/harmony_app/src/application/resources.rs b/harmony_app/src/application/resources.rs new file mode 100644 index 00000000..dedfee97 --- /dev/null +++ b/harmony_app/src/application/resources.rs @@ -0,0 +1,167 @@ +use serde::Serialize; + +use harmony::modules::zitadel::{ + ZitadelApplicationRef, ZitadelContract, ZitadelMachineRef, ZitadelProjectRef, +}; + +use super::{FileRef, PublicEndpointRef, ValueRef}; + +#[derive(Debug, Clone, Serialize)] +pub enum ManagedResource { + Postgres(ManagedPostgres), + Zitadel(ManagedZitadel), +} + +#[derive(Debug, Clone, Serialize)] +pub struct ManagedPostgres { + pub name: String, + pub instances: u32, +} + +impl ManagedPostgres { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + instances: 1, + } + } + pub fn instances(mut self, instances: u32) -> Self { + self.instances = instances; + self + } + pub fn reference(&self) -> DatabaseRef { + DatabaseRef(self.name.clone()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct DatabaseRef(pub(crate) String); + +impl DatabaseRef { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + pub fn name(&self) -> &str { + &self.0 + } + pub fn jdbc_url(&self) -> ValueRef { + ValueRef::DatabaseJdbcUrl(self.clone()) + } + pub fn username(&self) -> ValueRef { + ValueRef::DatabaseUsername(self.clone()) + } + pub fn password(&self) -> ValueRef { + ValueRef::DatabasePassword(self.clone()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ManagedZitadel { + pub name: String, + pub endpoint: PublicEndpointRef, + pub version: String, + pub contract: ZitadelContract, + pub redirects: Vec, +} + +impl ManagedZitadel { + pub fn new(name: impl Into, endpoint: PublicEndpointRef) -> Self { + Self { + name: name.into(), + endpoint, + version: "v4.12.1".to_string(), + contract: ZitadelContract::default(), + redirects: Vec::new(), + } + } + pub fn version(mut self, version: impl Into) -> Self { + self.version = version.into(); + self + } + pub fn contract(mut self, contract: ZitadelContract) -> Self { + self.contract = contract; + self + } + pub fn redirect( + mut self, + application: ZitadelApplicationRef, + endpoint: PublicEndpointRef, + path: impl Into, + ) -> Self { + self.redirects.push(OidcRedirect { + application, + endpoint, + path: path.into(), + post_logout: false, + }); + self + } + pub fn post_logout( + mut self, + application: ZitadelApplicationRef, + endpoint: PublicEndpointRef, + path: impl Into, + ) -> Self { + self.redirects.push(OidcRedirect { + application, + endpoint, + path: path.into(), + post_logout: true, + }); + self + } + pub fn reference(&self) -> ZitadelRef { + ZitadelRef(self.name.clone()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct ZitadelRef(pub(crate) String); + +impl ZitadelRef { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + pub fn name(&self) -> &str { + &self.0 + } + pub fn project_id(&self, project: ZitadelProjectRef) -> ValueRef { + ValueRef::OidcProjectId { + zitadel: self.clone(), + project, + } + } + pub fn oidc_client_id(&self, application: ZitadelApplicationRef) -> ValueRef { + ValueRef::OidcClientId { + zitadel: self.clone(), + application, + } + } + pub fn machine_client_id(&self, machine: ZitadelMachineRef) -> ValueRef { + ValueRef::MachineClientId { + zitadel: self.clone(), + machine, + } + } + pub fn machine_client_secret(&self, machine: ZitadelMachineRef) -> ValueRef { + ValueRef::MachineClientSecret { + zitadel: self.clone(), + machine, + } + } + pub fn machine_json_key(&self, machine: ZitadelMachineRef, path: impl Into) -> FileRef { + FileRef::MachineJsonKey { + zitadel: self.clone(), + machine, + path: path.into(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct OidcRedirect { + pub application: ZitadelApplicationRef, + pub endpoint: PublicEndpointRef, + pub path: String, + pub post_logout: bool, +} diff --git a/harmony_app/src/application/validation.rs b/harmony_app/src/application/validation.rs new file mode 100644 index 00000000..59f90a05 --- /dev/null +++ b/harmony_app/src/application/validation.rs @@ -0,0 +1,561 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use thiserror::Error; + +use super::{ + Application, FileRef, HealthCheck, ImageSource, ManagedResource, PortRef, Protocol, ValueRef, +}; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ApplicationValidationError { + #[error("{field} must be non-empty")] + Empty { field: String }, + #[error("duplicate {kind} '{name}'")] + Duplicate { kind: &'static str, name: String }, + #[error("unknown image '{0}'")] + UnknownImage(String), + #[error("unknown {kind} '{name}'")] + UnknownResource { kind: &'static str, name: String }, + #[error("unknown service '{0}'")] + UnknownService(String), + #[error("service '{0}' has no addressable ports")] + ServiceHasNoPorts(String), + #[error("unknown port '{service}.{port}'")] + UnknownPort { service: String, port: String }, + #[error("route target '{service}.{port}' must use TCP")] + NonTcpRoute { service: String, port: String }, + #[error("route '{host}{path}' must start with '/'")] + InvalidRoutePath { host: String, path: String }, + #[error("rollout replicas must be greater than zero")] + ZeroReplicas, + #[error("file value path '{0}' must be absolute")] + RelativeFilePath(String), + #[error("health check for '{service}' references another service '{target}'")] + CrossServiceHealthCheck { service: String, target: String }, + #[error("invalid Zitadel contract '{name}': {reason}")] + InvalidZitadelContract { name: String, reason: String }, + #[error("machine identity '{machine}' does not produce {credential}")] + MissingMachineCredential { + machine: String, + credential: &'static str, + }, +} + +pub(crate) fn validate(app: &Application) -> Result<(), ApplicationValidationError> { + non_empty(&app.name, "application name")?; + if app.rollout.replicas == 0 { + return Err(ApplicationValidationError::ZeroReplicas); + } + if app.services.is_empty() { + return Err(ApplicationValidationError::Empty { + field: "application services".to_string(), + }); + } + + let mut images = BTreeSet::new(); + for image in &app.images { + non_empty(&image.name, "image name")?; + match &image.source { + ImageSource::Reference(reference) => { + non_empty(reference, &format!("image '{}' reference", image.name))? + } + ImageSource::Build(build) => { + if build.context.as_os_str().is_empty() { + return Err(ApplicationValidationError::Empty { + field: format!("image '{}' build context", image.name), + }); + } + } + } + if !images.insert(image.name.as_str()) { + return Err(ApplicationValidationError::Duplicate { + kind: "image", + name: image.name.clone(), + }); + } + } + + let endpoints: BTreeSet<_> = app + .endpoints + .iter() + .map(|endpoint| endpoint.name.as_str()) + .collect(); + if endpoints.len() != app.endpoints.len() { + return Err(ApplicationValidationError::Duplicate { + kind: "public endpoint", + name: "declaration".to_string(), + }); + } + let mut databases = BTreeSet::new(); + let mut zitadels = BTreeMap::new(); + for resource in &app.resources { + match resource { + ManagedResource::Postgres(database) => { + non_empty(&database.name, "database name")?; + if !databases.insert(database.name.as_str()) { + return Err(ApplicationValidationError::Duplicate { + kind: "database", + name: database.name.clone(), + }); + } + } + ManagedResource::Zitadel(zitadel) => { + non_empty(&zitadel.name, "Zitadel name")?; + if zitadels.contains_key(zitadel.name.as_str()) { + return Err(ApplicationValidationError::Duplicate { + kind: "Zitadel", + name: zitadel.name.clone(), + }); + } + zitadel.contract.validate().map_err(|reason| { + ApplicationValidationError::InvalidZitadelContract { + name: zitadel.name.clone(), + reason, + } + })?; + if !endpoints.contains(zitadel.endpoint.name()) { + return Err(ApplicationValidationError::UnknownResource { + kind: "public endpoint", + name: zitadel.endpoint.name().to_string(), + }); + } + zitadels.insert(zitadel.name.as_str(), zitadel); + for redirect in &zitadel.redirects { + if !endpoints.contains(redirect.endpoint.name()) { + return Err(ApplicationValidationError::UnknownResource { + kind: "public endpoint", + name: redirect.endpoint.name().to_string(), + }); + } + if !redirect.path.starts_with('/') { + return Err(ApplicationValidationError::InvalidRoutePath { + host: redirect.endpoint.name().to_string(), + path: redirect.path.clone(), + }); + } + if !zitadel + .contract + .applications + .iter() + .any(|application| application.application == redirect.application) + { + return Err(ApplicationValidationError::UnknownResource { + kind: "OIDC application", + name: format!( + "{}/{}", + redirect.application.project().name(), + redirect.application.name() + ), + }); + } + } + } + } + } + + let mut services = BTreeMap::new(); + for service in &app.services { + non_empty(&service.name, "service name")?; + if !images.contains(service.image.name()) { + return Err(ApplicationValidationError::UnknownImage( + service.image.name().to_string(), + )); + } + if services.insert(service.name.as_str(), service).is_some() { + return Err(ApplicationValidationError::Duplicate { + kind: "service", + name: service.name.clone(), + }); + } + let mut ports = BTreeSet::new(); + for port in &service.ports { + non_empty(&port.name, &format!("service '{}' port name", service.name))?; + if port.number == 0 { + return Err(ApplicationValidationError::Empty { + field: format!("service '{}' port number", service.name), + }); + } + if !ports.insert(port.name.as_str()) { + return Err(ApplicationValidationError::Duplicate { + kind: "port", + name: format!("{}.{}", service.name, port.name), + }); + } + } + } + + let resolve_port = |reference: &PortRef| { + let service = services.get(reference.service.name()).ok_or_else(|| { + ApplicationValidationError::UnknownService(reference.service.name().to_string()) + })?; + service + .ports + .iter() + .find(|port| port.name == reference.name()) + .ok_or_else(|| ApplicationValidationError::UnknownPort { + service: reference.service.name().to_string(), + port: reference.name().to_string(), + }) + }; + + for service in &app.services { + let mut values = BTreeSet::new(); + for (name, value) in &service.values { + non_empty(name, &format!("service '{}' value name", service.name))?; + if !values.insert(name.as_str()) { + return Err(ApplicationValidationError::Duplicate { + kind: "value", + name: format!("{}.{}", service.name, name), + }); + } + match value { + ValueRef::ServiceHost(reference) => match services.get(reference.name()) { + None => { + return Err(ApplicationValidationError::UnknownService( + reference.name().to_string(), + )); + } + Some(service) if service.ports.is_empty() => { + return Err(ApplicationValidationError::ServiceHasNoPorts( + reference.name().to_string(), + )); + } + Some(_) => {} + }, + ValueRef::ServicePort(reference) + | ValueRef::ServiceUrl { + port: reference, .. + } => { + resolve_port(reference)?; + } + ValueRef::PublicEndpointOrigin(reference) => { + if !endpoints.contains(reference.name()) { + return Err(ApplicationValidationError::UnknownResource { + kind: "public endpoint", + name: reference.name().to_string(), + }); + } + } + ValueRef::PublicEndpointUrl { endpoint, path } => { + if !endpoints.contains(endpoint.name()) { + return Err(ApplicationValidationError::UnknownResource { + kind: "public endpoint", + name: endpoint.name().to_string(), + }); + } + if !path.starts_with('/') { + return Err(ApplicationValidationError::InvalidRoutePath { + host: endpoint.name().to_string(), + path: path.clone(), + }); + } + } + ValueRef::DatabaseJdbcUrl(reference) + | ValueRef::DatabaseUsername(reference) + | ValueRef::DatabasePassword(reference) => { + if !databases.contains(reference.name()) { + return Err(ApplicationValidationError::UnknownResource { + kind: "database", + name: reference.name().to_string(), + }); + } + } + ValueRef::ZitadelIssuer(reference) | ValueRef::ZitadelManagementUrl(reference) => { + validate_zitadel(&zitadels, reference.name())?; + } + ValueRef::OidcProjectId { + zitadel: producer, + project, + } => { + let zitadel = validate_zitadel(&zitadels, producer.name())?; + if !zitadel + .contract + .projects + .iter() + .any(|item| item.project == *project) + { + return Err(ApplicationValidationError::UnknownResource { + kind: "OIDC project", + name: project.name().to_string(), + }); + } + } + ValueRef::OidcClientId { + zitadel: producer, + application, + } => { + let zitadel = validate_zitadel(&zitadels, producer.name())?; + if !zitadel + .contract + .applications + .iter() + .any(|item| item.application == *application) + { + return Err(ApplicationValidationError::UnknownResource { + kind: "OIDC application", + name: format!( + "{}/{}", + application.project().name(), + application.name() + ), + }); + } + } + ValueRef::MachineClientId { zitadel, machine } => { + let declaration = validate_machine(&zitadels, zitadel, machine)?; + if !declaration.client_secret { + return Err(ApplicationValidationError::MissingMachineCredential { + machine: machine.name().to_string(), + credential: "a client ID", + }); + } + } + ValueRef::MachineClientSecret { zitadel, machine } => { + let declaration = validate_machine(&zitadels, zitadel, machine)?; + if !declaration.client_secret { + return Err(ApplicationValidationError::MissingMachineCredential { + machine: machine.name().to_string(), + credential: "a client secret", + }); + } + } + ValueRef::File(reference) => { + let FileRef::MachineJsonKey { + zitadel, machine, .. + } = reference; + let declaration = validate_machine(&zitadels, zitadel, machine)?; + if declaration.key + != Some(harmony::modules::zitadel::ZitadelMachineKeyDeclaration::Json) + { + return Err(ApplicationValidationError::MissingMachineCredential { + machine: machine.name().to_string(), + credential: "a JSON key", + }); + } + if !reference.path().starts_with('/') { + return Err(ApplicationValidationError::RelativeFilePath( + reference.path().to_string(), + )); + } + } + ValueRef::Literal(_) => {} + } + } + if let Some(health) = &service.health { + let reference = match health { + HealthCheck::Http { port, path, .. } => { + if !path.starts_with('/') { + return Err(ApplicationValidationError::InvalidRoutePath { + host: service.name.clone(), + path: path.clone(), + }); + } + port + } + HealthCheck::Tcp { port, .. } => port, + }; + resolve_port(reference)?; + if reference.service.name() != service.name { + return Err(ApplicationValidationError::CrossServiceHealthCheck { + service: service.name.clone(), + target: reference.service.name().to_string(), + }); + } + } + } + + for route in &app.routes { + if !endpoints.contains(route.endpoint.name()) { + return Err(ApplicationValidationError::UnknownResource { + kind: "public endpoint", + name: route.endpoint.name().to_string(), + }); + } + if !route.path.starts_with('/') { + return Err(ApplicationValidationError::InvalidRoutePath { + host: route.endpoint.name().to_string(), + path: route.path.clone(), + }); + } + let port = resolve_port(&route.target)?; + if port.protocol != Protocol::Tcp { + return Err(ApplicationValidationError::NonTcpRoute { + service: route.target.service.name().to_string(), + port: route.target.name().to_string(), + }); + } + } + Ok(()) +} + +fn validate_zitadel<'a>( + zitadels: &'a BTreeMap<&str, &super::ManagedZitadel>, + name: &str, +) -> Result<&'a super::ManagedZitadel, ApplicationValidationError> { + zitadels + .get(name) + .copied() + .ok_or_else(|| ApplicationValidationError::UnknownResource { + kind: "Zitadel", + name: name.to_string(), + }) +} + +fn validate_machine<'a>( + zitadels: &'a BTreeMap<&str, &'a super::ManagedZitadel>, + producer: &super::ZitadelRef, + machine: &harmony::modules::zitadel::ZitadelMachineRef, +) -> Result<&'a harmony::modules::zitadel::ZitadelMachineDeclaration, ApplicationValidationError> { + let zitadel = validate_zitadel(zitadels, producer.name())?; + zitadel + .contract + .machines + .iter() + .find(|item| item.machine == *machine) + .ok_or_else(|| ApplicationValidationError::UnknownResource { + kind: "machine identity", + name: machine.name().to_string(), + }) +} + +fn non_empty(value: &str, field: &str) -> Result<(), ApplicationValidationError> { + if value.trim().is_empty() { + Err(ApplicationValidationError::Empty { + field: field.to_string(), + }) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::{Image, LogicalEndpoint, ManagedZitadel, Port, Route, Service}; + use harmony::modules::zitadel::{ + ZitadelContract, ZitadelMachineDeclaration, ZitadelMachineRef, + }; + + fn valid_app() -> Application { + let image = Image::new("web", "example/web:1"); + let web = Service::new("web", image.reference()).port(Port::tcp("http", 8080)); + let web_ref = web.reference(); + let endpoint = LogicalEndpoint::new("web"); + let endpoint_ref = endpoint.reference(); + Application::new("example") + .image(image) + .endpoint(endpoint) + .service(web) + .route(Route::new(endpoint_ref, "/", web_ref.port("http"))) + } + + #[test] + fn accepts_typed_references() { + valid_app().validate().unwrap(); + } + + #[test] + fn rejects_unknown_route_port() { + let mut app = valid_app(); + app.routes[0].target = app.services[0].reference().port("admin"); + assert_eq!( + app.validate().unwrap_err(), + ApplicationValidationError::UnknownPort { + service: "web".to_string(), + port: "admin".to_string(), + } + ); + } + + #[test] + fn rejects_unknown_semantic_endpoint() { + let mut app = valid_app(); + app.services[0].values.push(( + "ORIGIN".to_string(), + super::super::PublicEndpointRef::new("missing").origin(), + )); + assert_eq!( + app.validate().unwrap_err(), + ApplicationValidationError::UnknownResource { + kind: "public endpoint", + name: "missing".to_string(), + } + ); + } + + #[test] + fn rejects_unproduced_machine_client_secret() { + let mut app = valid_app(); + let endpoint = LogicalEndpoint::new("identity"); + let machine = ZitadelMachineRef::new("backend"); + let identity = ManagedZitadel::new("identity", endpoint.reference()).contract( + ZitadelContract::default().machine(ZitadelMachineDeclaration { + machine: machine.clone(), + name: "Backend".to_string(), + key: None, + client_secret: false, + }), + ); + app.services[0].values.push(( + "CLIENT_SECRET".to_string(), + identity.reference().machine_client_secret(machine), + )); + app.endpoints.push(endpoint); + app.resources.push(identity.into()); + + assert_eq!( + app.validate().unwrap_err(), + ApplicationValidationError::MissingMachineCredential { + machine: "backend".to_string(), + credential: "a client secret", + } + ); + } + + #[test] + fn rejects_unproduced_machine_client_id() { + let mut app = valid_app(); + let endpoint = LogicalEndpoint::new("identity"); + let machine = ZitadelMachineRef::new("backend"); + let identity = ManagedZitadel::new("identity", endpoint.reference()).contract( + ZitadelContract::default().machine(ZitadelMachineDeclaration { + machine: machine.clone(), + name: "Backend".to_string(), + key: None, + client_secret: false, + }), + ); + app.services[0].values.push(( + "CLIENT_ID".to_string(), + identity.reference().machine_client_id(machine), + )); + app.endpoints.push(endpoint); + app.resources.push(identity.into()); + + assert_eq!( + app.validate().unwrap_err(), + ApplicationValidationError::MissingMachineCredential { + machine: "backend".to_string(), + credential: "a client ID", + } + ); + } + + #[test] + fn rejects_duplicate_managed_zitadel() { + let mut app = valid_app(); + let endpoint = LogicalEndpoint::new("identity"); + let identity = ManagedZitadel::new("identity", endpoint.reference()); + app.endpoints.push(endpoint); + app.resources.push(identity.clone().into()); + app.resources.push(identity.into()); + + assert_eq!( + app.validate().unwrap_err(), + ApplicationValidationError::Duplicate { + kind: "Zitadel", + name: "identity".to_string(), + } + ); + } +} diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index 89ea1951..6592fa32 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -13,8 +13,12 @@ //! A [`Context`] defines a compiled deployment target. [`AppContext`] resolves //! its credentials and runtime state. The verbs converge the same Scores for //! local and production targets (ADR-026 §1/§10). +//! +//! [`Application`] is a topology-neutral declaration model. K8sAnywhere is its +//! first adapter; topology-neutral does not imply every runtime is supported. pub mod app; +pub mod application; pub mod capabilities; pub mod chart; pub mod compose; @@ -29,6 +33,13 @@ pub use app::{ AppIdentity, DeployOptions, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, WorkloadStatus, deploy, deploy_with_options, logs, ship, ship_with_options, status, }; +pub use application::{ + Application, ApplicationValidationError, Command, Cpu, DatabaseRef, FileRef, HealthCheck, + Image, ImageBuild, ImageRef, ImageSource, LogicalEndpoint, ManagedPostgres, ManagedResource, + ManagedTls, ManagedZitadel, Memory, OidcRedirect, Port, PortRef, Protocol, PublicEndpointRef, + ReadinessIntent, ResourceIntent, RolloutIntent, RolloutStrategy, Route, Service, ServiceRef, + ValueRef, ZitadelRef, zitadel, +}; pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth}; pub use chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image}; pub use compose::ComposeApp; -- 2.39.5 From 66eba5f0f37bc2d65d9fffef8abf12d0d2ef5126 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Wed, 29 Jul 2026 21:34:09 -0400 Subject: [PATCH 02/34] feat: provision application tenant credentials --- Cargo.lock | 1 + harmony_app/Cargo.toml | 1 + harmony_app/src/lib.rs | 3 + harmony_app/src/tenant.rs | 165 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 harmony_app/src/tenant.rs diff --git a/Cargo.lock b/Cargo.lock index 6866af70..8d8bb796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4317,6 +4317,7 @@ dependencies = [ name = "harmony_app" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", "docker-compose-types", "fqdn", diff --git a/harmony_app/Cargo.toml b/harmony_app/Cargo.toml index 06aaddd1..ab7ec984 100644 --- a/harmony_app/Cargo.toml +++ b/harmony_app/Cargo.toml @@ -6,6 +6,7 @@ readme.workspace = true license.workspace = true [dependencies] +anyhow.workspace = true harmony = { path = "../harmony" } harmony-k8s = { path = "../harmony-k8s" } harmony_config = { path = "../harmony_config" } diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index 6592fa32..fb25f11b 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -28,6 +28,7 @@ pub mod error; pub mod profile; pub mod publish; pub mod score; +pub mod tenant; pub use app::{ AppIdentity, DeployOptions, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, @@ -50,9 +51,11 @@ pub use context::{ pub use deploy::ComposeDeploy; pub use error::{AppError, ContextError, ImageError}; pub use harmony::modules::tenant::ClusterAccess; +pub use harmony::topology::tenant::{ResourceLimits, TenantConfig, TenantNetworkPolicy}; pub use profile::Profile; pub use publish::{ ImagePublisher, ImageRefs, ImageSpec, PublicationTopology, RegistryCredentials, is_digest_pinned, }; pub use score::{ComposeAppScore, PublicEndpoint}; +pub use tenant::provision_application_tenant_with_kubeconfig; diff --git a/harmony_app/src/tenant.rs b/harmony_app/src/tenant.rs new file mode 100644 index 00000000..17bd92ae --- /dev/null +++ b/harmony_app/src/tenant.rs @@ -0,0 +1,165 @@ +use std::{path::PathBuf, sync::Arc}; + +use async_trait::async_trait; +use harmony::{ + modules::tenant::{TenantCredentialScore, TenantScore}, + score::Score, + topology::{K8sAnywhereTopology, tenant::TenantConfig}, +}; +use harmony_config::ConfigClient; +use harmony_types::k8s_name::K8sName; +use k8s_openapi::api::rbac::v1::PolicyRule; + +use crate::{ + AppContext, AppError, AppIdentity, Context, ContextSpec, HarmonyApp, ImageRefs, + OpenBaoClusterAccess, deploy, +}; + +struct ApplicationTenantProvisioner { + tenant: TenantConfig, + credential_store: OpenBaoClusterAccess, + allow_insecure_source: bool, +} + +#[async_trait] +impl HarmonyApp for ApplicationTenantProvisioner { + fn identity(&self, _ctx: &AppContext) -> AppIdentity { + AppIdentity { + name: "application-tenant".to_string(), + namespace: self.tenant.name.clone(), + } + } + + async fn scores( + &self, + _ctx: &AppContext, + _images: &ImageRefs, + ) -> Result>>, AppError> { + let source = harmony_config::openbao_source( + self.credential_store.namespace.as_ref(), + Some(self.credential_store.url.to_string()), + Some(self.credential_store.zitadel_url.to_string()), + Some(self.credential_store.zitadel_audience.to_string()), + Some(self.credential_store.role.to_string()), + ) + .await + .ok_or_else(|| AppError::Deploy("tenant credential store is unavailable".into()))?; + let namespace = self + .tenant + .name + .parse::() + .map_err(|error| AppError::InvalidComposition(error.to_string()))?; + Ok(vec![ + Box::new(TenantScore { + config: self.tenant.clone(), + }), + Box::new(TenantCredentialScore::new( + namespace, + "harmony-deployer" + .parse() + .expect("static Kubernetes name is valid"), + application_deployer_rules(), + Arc::new(ConfigClient::new(vec![source])), + self.allow_insecure_source, + )), + ]) + } +} + +/// Provision an application tenant with an administrator kubeconfig, then +/// store its namespace-scoped kubeconfig as `ClusterAccess` in OpenBao. +pub async fn provision_application_tenant_with_kubeconfig( + context: Context, + kubeconfig: PathBuf, + tenant: TenantConfig, + credential_store: OpenBaoClusterAccess, + allow_insecure_source: bool, +) -> anyhow::Result<()> { + if !matches!(context.spec, ContextSpec::Remote(_)) { + anyhow::bail!("application tenant provisioning requires a remote context"); + } + let app = ApplicationTenantProvisioner { + tenant, + credential_store, + allow_insecure_source, + }; + let ctx = AppContext::from_kubeconfig(&context, "bootstrap", kubeconfig)?; + deploy(&app, ctx.topology(), &ctx).await?; + Ok(()) +} + +fn application_deployer_rules() -> Vec { + let verbs = || { + [ + "get", "list", "watch", "create", "update", "patch", "delete", + ] + .map(String::from) + .to_vec() + }; + vec![ + rule( + "", + &[ + "configmaps", + "persistentvolumeclaims", + "pods", + "secrets", + "serviceaccounts", + "services", + ], + verbs(), + ), + rule("", &["pods/log"], vec!["get".to_string()]), + rule( + "", + &["pods/exec", "pods/portforward"], + vec!["create".to_string()], + ), + rule( + "apps", + &["deployments", "replicasets", "statefulsets"], + verbs(), + ), + rule("batch", &["jobs"], verbs()), + rule( + "networking.k8s.io", + &["ingresses", "networkpolicies"], + verbs(), + ), + rule("policy", &["poddisruptionbudgets"], verbs()), + rule( + "rbac.authorization.k8s.io", + &["roles", "rolebindings"], + verbs(), + ), + rule("postgresql.cnpg.io", &["clusters"], verbs()), + ] +} + +fn rule(api_group: &str, resources: &[&str], verbs: Vec) -> PolicyRule { + PolicyRule { + api_groups: Some(vec![api_group.to_string()]), + resources: Some(resources.iter().map(|value| (*value).to_string()).collect()), + verbs, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn application_deployer_can_manage_cnpg_without_fleet_permissions() { + let rules = application_deployer_rules(); + assert!(rules.iter().any(|rule| { + rule.api_groups.as_deref() == Some(&["postgresql.cnpg.io".to_string()]) + && rule.resources.as_deref() == Some(&["clusters".to_string()]) + })); + assert!(!rules.iter().any(|rule| { + rule.api_groups + .as_ref() + .is_some_and(|groups| groups.iter().any(|group| group == "fleet.nationtech.io")) + })); + } +} -- 2.39.5 From 47042651e955c00bfd7ccf5ec4768e2c878cc723 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Wed, 29 Jul 2026 21:53:22 -0400 Subject: [PATCH 03/34] feat: add reusable application tenant CLI --- Cargo.lock | 2 + harmony_app/src/lib.rs | 2 +- harmony_app/src/tenant.rs | 142 +++++++-------- harmony_cli/Cargo.toml | 8 +- harmony_cli/src/bin/harmony-tenant.rs | 4 + harmony_cli/src/lib.rs | 1 + harmony_cli/src/tenant.rs | 241 ++++++++++++++++++++++++++ 7 files changed, 330 insertions(+), 70 deletions(-) create mode 100644 harmony_cli/src/bin/harmony-tenant.rs create mode 100644 harmony_cli/src/tenant.rs diff --git a/Cargo.lock b/Cargo.lock index 8d8bb796..2a81978b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4419,11 +4419,13 @@ dependencies = [ "harmony", "harmony_app", "harmony_tui", + "harmony_types", "indicatif", "inquire 0.7.5", "lazy_static", "serde", "serde_json", + "serde_yaml", "tokio", "tracing", "tracing-subscriber", diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index fb25f11b..e26efbad 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -58,4 +58,4 @@ pub use publish::{ is_digest_pinned, }; pub use score::{ComposeAppScore, PublicEndpoint}; -pub use tenant::provision_application_tenant_with_kubeconfig; +pub use tenant::{ApplicationTenantProvisioning, provision_application_tenant_with_kubeconfig}; diff --git a/harmony_app/src/tenant.rs b/harmony_app/src/tenant.rs index 17bd92ae..8eefc28b 100644 --- a/harmony_app/src/tenant.rs +++ b/harmony_app/src/tenant.rs @@ -1,93 +1,99 @@ use std::{path::PathBuf, sync::Arc}; -use async_trait::async_trait; use harmony::{ + interpret::InterpretStatus, + inventory::Inventory, + maestro::Maestro, modules::tenant::{TenantCredentialScore, TenantScore}, score::Score, - topology::{K8sAnywhereTopology, tenant::TenantConfig}, + topology::{K8sAnywhereConfig, K8sAnywhereTopology, tenant::TenantConfig}, }; use harmony_config::ConfigClient; use harmony_types::k8s_name::K8sName; use k8s_openapi::api::rbac::v1::PolicyRule; -use crate::{ - AppContext, AppError, AppIdentity, Context, ContextSpec, HarmonyApp, ImageRefs, - OpenBaoClusterAccess, deploy, -}; +use crate::{AppError, OpenBaoClusterAccess}; -struct ApplicationTenantProvisioner { - tenant: TenantConfig, - credential_store: OpenBaoClusterAccess, - allow_insecure_source: bool, -} - -#[async_trait] -impl HarmonyApp for ApplicationTenantProvisioner { - fn identity(&self, _ctx: &AppContext) -> AppIdentity { - AppIdentity { - name: "application-tenant".to_string(), - namespace: self.tenant.name.clone(), - } - } - - async fn scores( - &self, - _ctx: &AppContext, - _images: &ImageRefs, - ) -> Result>>, AppError> { - let source = harmony_config::openbao_source( - self.credential_store.namespace.as_ref(), - Some(self.credential_store.url.to_string()), - Some(self.credential_store.zitadel_url.to_string()), - Some(self.credential_store.zitadel_audience.to_string()), - Some(self.credential_store.role.to_string()), - ) - .await - .ok_or_else(|| AppError::Deploy("tenant credential store is unavailable".into()))?; - let namespace = self - .tenant - .name - .parse::() - .map_err(|error| AppError::InvalidComposition(error.to_string()))?; - Ok(vec![ - Box::new(TenantScore { - config: self.tenant.clone(), - }), - Box::new(TenantCredentialScore::new( - namespace, - "harmony-deployer" - .parse() - .expect("static Kubernetes name is valid"), - application_deployer_rules(), - Arc::new(ConfigClient::new(vec![source])), - self.allow_insecure_source, - )), - ]) - } +#[derive(Clone, Debug)] +pub struct ApplicationTenantProvisioning { + pub tenant: TenantConfig, + pub deployer_service_account: K8sName, + pub credential_store: OpenBaoClusterAccess, + pub allow_insecure_source: bool, } /// Provision an application tenant with an administrator kubeconfig, then /// store its namespace-scoped kubeconfig as `ClusterAccess` in OpenBao. pub async fn provision_application_tenant_with_kubeconfig( - context: Context, kubeconfig: PathBuf, - tenant: TenantConfig, - credential_store: OpenBaoClusterAccess, - allow_insecure_source: bool, + kube_context: String, + provisioning: ApplicationTenantProvisioning, ) -> anyhow::Result<()> { - if !matches!(context.spec, ContextSpec::Remote(_)) { - anyhow::bail!("application tenant provisioning requires a remote context"); + let kubeconfig = kubeconfig + .to_str() + .ok_or_else(|| anyhow::anyhow!("kubeconfig path must be valid UTF-8"))?; + let topology = K8sAnywhereTopology::with_config(K8sAnywhereConfig::kubeconfig( + kubeconfig, + Some(kube_context), + )); + let scores = application_tenant_scores(&provisioning).await?; + let to_run = scores + .iter() + .map(|score| score.clone_box()) + .collect::>(); + let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology); + maestro.register_all(scores); + maestro + .prepare_topology() + .await + .map_err(|error| anyhow::anyhow!("topology preparation failed: {error}"))?; + for score in to_run { + let name = score.name(); + let outcome = maestro + .interpret(score) + .await + .map_err(|error| anyhow::anyhow!("{name}: {error}"))?; + if !matches!( + outcome.status, + InterpretStatus::SUCCESS | InterpretStatus::NOOP + ) { + anyhow::bail!("{name}: {}: {}", outcome.status, outcome.message); + } } - let app = ApplicationTenantProvisioner { - tenant, - credential_store, - allow_insecure_source, - }; - let ctx = AppContext::from_kubeconfig(&context, "bootstrap", kubeconfig)?; - deploy(&app, ctx.topology(), &ctx).await?; Ok(()) } +async fn application_tenant_scores( + provisioning: &ApplicationTenantProvisioning, +) -> Result>>, AppError> { + let source = harmony_config::openbao_source( + provisioning.credential_store.namespace.as_ref(), + Some(provisioning.credential_store.url.to_string()), + Some(provisioning.credential_store.zitadel_url.to_string()), + Some(provisioning.credential_store.zitadel_audience.to_string()), + Some(provisioning.credential_store.role.to_string()), + ) + .await + .ok_or_else(|| AppError::Deploy("tenant credential store is unavailable".into()))?; + let namespace = provisioning + .tenant + .name + .parse::() + .map_err(|error| AppError::InvalidComposition(error.to_string()))?; + Ok(vec![ + Box::new(TenantScore { + config: provisioning.tenant.clone(), + }), + Box::new(TenantCredentialScore::new( + namespace, + provisioning.deployer_service_account.clone(), + application_deployer_rules(), + Arc::new(ConfigClient::new(vec![source])), + provisioning.allow_insecure_source, + )), + ]) +} + fn application_deployer_rules() -> Vec { let verbs = || { [ diff --git a/harmony_cli/Cargo.toml b/harmony_cli/Cargo.toml index 4d539e56..172b4dfb 100644 --- a/harmony_cli/Cargo.toml +++ b/harmony_cli/Cargo.toml @@ -12,12 +12,14 @@ tui = ["dep:harmony_tui"] [dependencies] serde.workspace = true serde_json.workspace = true +serde_yaml.workspace = true assert_cmd = "2.0.17" anyhow = { workspace = true } -clap = { version = "4.5.35", features = ["derive"] } +clap.workspace = true harmony = { path = "../harmony" } harmony_app = { path = "../harmony_app" } harmony_tui = { path = "../harmony_tui", optional = true } +harmony_types = { path = "../harmony_types" } inquire.workspace = true tokio.workspace = true console = "0.16.0" @@ -26,6 +28,10 @@ lazy_static = "1.5.0" tracing.workspace = true tracing-subscriber.workspace = true +[[bin]] +name = "harmony-tenant" +path = "src/bin/harmony-tenant.rs" + [dev-dependencies] harmony = { path = "../harmony", features = ["testing"] } async-trait = "0.1" diff --git a/harmony_cli/src/bin/harmony-tenant.rs b/harmony_cli/src/bin/harmony-tenant.rs new file mode 100644 index 00000000..48980ff5 --- /dev/null +++ b/harmony_cli/src/bin/harmony-tenant.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + harmony_cli::tenant::tenant_main().await +} diff --git a/harmony_cli/src/lib.rs b/harmony_cli/src/lib.rs index 69b66e59..e1d584a1 100644 --- a/harmony_cli/src/lib.rs +++ b/harmony_cli/src/lib.rs @@ -11,6 +11,7 @@ pub mod app; pub mod cli_logger; // FIXME: Don't make me pub mod cli_reporter; pub mod progress; +pub mod tenant; pub mod theme; #[cfg(feature = "tui")] diff --git a/harmony_cli/src/tenant.rs b/harmony_cli/src/tenant.rs new file mode 100644 index 00000000..63148212 --- /dev/null +++ b/harmony_cli/src/tenant.rs @@ -0,0 +1,241 @@ +use std::{fs, path::PathBuf}; + +use anyhow::{Context as _, ensure}; +use clap::Parser; +use harmony_app::{ + ApplicationTenantProvisioning, OpenBaoClusterAccess, ResourceLimits, TenantConfig, + provision_application_tenant_with_kubeconfig, +}; +use harmony_types::k8s_name::K8sName; + +#[derive(Debug, Parser)] +#[command( + version, + about = "Provision an application tenant and publish its namespace-scoped cluster access" +)] +struct TenantCli { + /// Administrator kubeconfig used only as the provisioning source. + #[arg(long, env = "KUBECONFIG")] + kubeconfig: PathBuf, + + /// Exact context selected from the source kubeconfig. + #[arg(long)] + kube_context: String, + + /// Stable tenant identifier. + #[arg(long)] + tenant_id: String, + + /// Kubernetes namespace to create for the tenant. + #[arg(long)] + namespace: String, + + /// ServiceAccount receiving application deployment permissions. + #[arg(long, default_value = "harmony-deployer")] + deployer_service_account: String, + + #[arg(long, default_value_t = 4.0)] + cpu_request_cores: f32, + + #[arg(long, default_value_t = 4.0)] + cpu_limit_cores: f32, + + #[arg(long, default_value_t = 4.0)] + memory_request_gb: f32, + + #[arg(long, default_value_t = 4.0)] + memory_limit_gb: f32, + + #[arg(long, default_value_t = 20.0)] + storage_total_gb: f32, + + #[arg(long, default_value_t = 10)] + service_limit: u32, + + /// OpenBao server receiving the generated ClusterAccess value. + #[arg(long)] + openbao_url: String, + + /// OpenBao secret namespace. Defaults to the Kubernetes namespace. + #[arg(long)] + openbao_namespace: Option, + + /// OpenBao JWT role used when token authentication is unavailable. + #[arg(long)] + openbao_role: String, + + #[arg(long)] + zitadel_url: String, + + #[arg(long)] + zitadel_audience: String, + + /// Permit an administrator kubeconfig that disables TLS verification. + #[arg(long)] + allow_insecure_source: bool, + + /// Execute provisioning. Without this flag the command only validates and prints its plan. + #[arg(long)] + yes: bool, +} + +pub async fn tenant_main() -> anyhow::Result<()> { + let cli = TenantCli::parse(); + run(cli).await +} + +async fn run(cli: TenantCli) -> anyhow::Result<()> { + validate_resource_limits(&cli)?; + validate_kubeconfig_context(&cli.kubeconfig, &cli.kube_context)?; + ensure!( + !cli.tenant_id.trim().is_empty(), + "tenant ID cannot be empty" + ); + cli.namespace + .parse::() + .map_err(|error| anyhow::anyhow!("invalid tenant namespace: {error}"))?; + + let openbao_namespace = cli + .openbao_namespace + .clone() + .unwrap_or_else(|| cli.namespace.clone()); + let tenant = TenantConfig { + id: cli.tenant_id.clone().into(), + name: cli.namespace.clone(), + resource_limits: ResourceLimits { + cpu_request_cores: cli.cpu_request_cores, + cpu_limit_cores: cli.cpu_limit_cores, + memory_request_gb: cli.memory_request_gb, + memory_limit_gb: cli.memory_limit_gb, + storage_total_gb: cli.storage_total_gb, + service_limit: cli.service_limit, + }, + ..Default::default() + }; + let provisioning = ApplicationTenantProvisioning { + tenant, + deployer_service_account: cli + .deployer_service_account + .parse() + .map_err(|error| anyhow::anyhow!("invalid deployer ServiceAccount: {error}"))?, + credential_store: OpenBaoClusterAccess { + namespace: openbao_namespace + .parse() + .map_err(|error| anyhow::anyhow!("invalid OpenBao namespace: {error}"))?, + url: cli + .openbao_url + .parse() + .map_err(|error| anyhow::anyhow!("invalid OpenBao URL: {error}"))?, + role: cli + .openbao_role + .parse() + .map_err(|error| anyhow::anyhow!("invalid OpenBao role: {error}"))?, + zitadel_url: cli + .zitadel_url + .parse() + .map_err(|error| anyhow::anyhow!("invalid Zitadel URL: {error}"))?, + zitadel_audience: cli + .zitadel_audience + .parse() + .map_err(|error| anyhow::anyhow!("invalid Zitadel audience: {error}"))?, + }, + allow_insecure_source: cli.allow_insecure_source, + }; + + println!("Tenant provisioning plan:"); + println!(" source kubeconfig: {}", cli.kubeconfig.display()); + println!(" source context: {}", cli.kube_context); + println!(" tenant: {}", cli.tenant_id); + println!(" namespace: {}", cli.namespace); + println!( + " deployer ServiceAccount: {}", + cli.deployer_service_account + ); + println!( + " ClusterAccess destination: secret/{openbao_namespace}/ClusterAccess at {}", + cli.openbao_url + ); + + if !cli.yes { + println!("Dry run only; pass --yes to execute this plan."); + return Ok(()); + } + + crate::cli_logger::init(); + crate::cli_reporter::init(); + provision_application_tenant_with_kubeconfig(cli.kubeconfig, cli.kube_context, provisioning) + .await +} + +fn validate_resource_limits(cli: &TenantCli) -> anyhow::Result<()> { + ensure!(cli.cpu_request_cores > 0.0, "CPU request must be positive"); + ensure!(cli.cpu_limit_cores > 0.0, "CPU limit must be positive"); + ensure!( + cli.cpu_request_cores <= cli.cpu_limit_cores, + "CPU request cannot exceed CPU limit" + ); + ensure!( + cli.memory_request_gb > 0.0, + "memory request must be positive" + ); + ensure!(cli.memory_limit_gb > 0.0, "memory limit must be positive"); + ensure!( + cli.memory_request_gb <= cli.memory_limit_gb, + "memory request cannot exceed memory limit" + ); + ensure!(cli.storage_total_gb > 0.0, "storage total must be positive"); + ensure!(cli.service_limit > 0, "service limit must be positive"); + Ok(()) +} + +fn validate_kubeconfig_context(path: &PathBuf, expected: &str) -> anyhow::Result<()> { + let contents = fs::read_to_string(path) + .with_context(|| format!("reading kubeconfig '{}'", path.display()))?; + let kubeconfig: serde_yaml::Value = serde_yaml::from_str(&contents) + .with_context(|| format!("parsing kubeconfig '{}'", path.display()))?; + let context_exists = kubeconfig + .get("contexts") + .and_then(serde_yaml::Value::as_sequence) + .is_some_and(|contexts| { + contexts.iter().any(|context| { + context.get("name").and_then(serde_yaml::Value::as_str) == Some(expected) + }) + }); + ensure!( + context_exists, + "kubeconfig '{}' has no context named '{expected}'", + path.display() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requires_yes_to_execute() { + let cli = TenantCli::try_parse_from([ + "harmony-tenant", + "--kubeconfig", + "/tmp/admin.kubeconfig", + "--kube-context", + "production-admin", + "--tenant-id", + "client-a", + "--namespace", + "client-a", + "--openbao-url", + "https://secrets.example.com", + "--openbao-role", + "harmony-client-a", + "--zitadel-url", + "https://sso.example.com", + "--zitadel-audience", + "12345", + ]) + .expect("arguments should parse"); + + assert!(!cli.yes); + } +} -- 2.39.5 From 4df57af92727d67a410b29221f3433be968b6193 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 07:22:37 -0400 Subject: [PATCH 04/34] Revert "feat: add reusable application tenant CLI" This reverts commit 47042651e955c00bfd7ccf5ec4768e2c878cc723. --- Cargo.lock | 2 - harmony_app/src/lib.rs | 2 +- harmony_app/src/tenant.rs | 142 ++++++++------- harmony_cli/Cargo.toml | 8 +- harmony_cli/src/bin/harmony-tenant.rs | 4 - harmony_cli/src/lib.rs | 1 - harmony_cli/src/tenant.rs | 241 -------------------------- 7 files changed, 70 insertions(+), 330 deletions(-) delete mode 100644 harmony_cli/src/bin/harmony-tenant.rs delete mode 100644 harmony_cli/src/tenant.rs diff --git a/Cargo.lock b/Cargo.lock index 2a81978b..8d8bb796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4419,13 +4419,11 @@ dependencies = [ "harmony", "harmony_app", "harmony_tui", - "harmony_types", "indicatif", "inquire 0.7.5", "lazy_static", "serde", "serde_json", - "serde_yaml", "tokio", "tracing", "tracing-subscriber", diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index e26efbad..fb25f11b 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -58,4 +58,4 @@ pub use publish::{ is_digest_pinned, }; pub use score::{ComposeAppScore, PublicEndpoint}; -pub use tenant::{ApplicationTenantProvisioning, provision_application_tenant_with_kubeconfig}; +pub use tenant::provision_application_tenant_with_kubeconfig; diff --git a/harmony_app/src/tenant.rs b/harmony_app/src/tenant.rs index 8eefc28b..17bd92ae 100644 --- a/harmony_app/src/tenant.rs +++ b/harmony_app/src/tenant.rs @@ -1,99 +1,93 @@ use std::{path::PathBuf, sync::Arc}; +use async_trait::async_trait; use harmony::{ - interpret::InterpretStatus, - inventory::Inventory, - maestro::Maestro, modules::tenant::{TenantCredentialScore, TenantScore}, score::Score, - topology::{K8sAnywhereConfig, K8sAnywhereTopology, tenant::TenantConfig}, + topology::{K8sAnywhereTopology, tenant::TenantConfig}, }; use harmony_config::ConfigClient; use harmony_types::k8s_name::K8sName; use k8s_openapi::api::rbac::v1::PolicyRule; -use crate::{AppError, OpenBaoClusterAccess}; +use crate::{ + AppContext, AppError, AppIdentity, Context, ContextSpec, HarmonyApp, ImageRefs, + OpenBaoClusterAccess, deploy, +}; -#[derive(Clone, Debug)] -pub struct ApplicationTenantProvisioning { - pub tenant: TenantConfig, - pub deployer_service_account: K8sName, - pub credential_store: OpenBaoClusterAccess, - pub allow_insecure_source: bool, +struct ApplicationTenantProvisioner { + tenant: TenantConfig, + credential_store: OpenBaoClusterAccess, + allow_insecure_source: bool, +} + +#[async_trait] +impl HarmonyApp for ApplicationTenantProvisioner { + fn identity(&self, _ctx: &AppContext) -> AppIdentity { + AppIdentity { + name: "application-tenant".to_string(), + namespace: self.tenant.name.clone(), + } + } + + async fn scores( + &self, + _ctx: &AppContext, + _images: &ImageRefs, + ) -> Result>>, AppError> { + let source = harmony_config::openbao_source( + self.credential_store.namespace.as_ref(), + Some(self.credential_store.url.to_string()), + Some(self.credential_store.zitadel_url.to_string()), + Some(self.credential_store.zitadel_audience.to_string()), + Some(self.credential_store.role.to_string()), + ) + .await + .ok_or_else(|| AppError::Deploy("tenant credential store is unavailable".into()))?; + let namespace = self + .tenant + .name + .parse::() + .map_err(|error| AppError::InvalidComposition(error.to_string()))?; + Ok(vec![ + Box::new(TenantScore { + config: self.tenant.clone(), + }), + Box::new(TenantCredentialScore::new( + namespace, + "harmony-deployer" + .parse() + .expect("static Kubernetes name is valid"), + application_deployer_rules(), + Arc::new(ConfigClient::new(vec![source])), + self.allow_insecure_source, + )), + ]) + } } /// Provision an application tenant with an administrator kubeconfig, then /// store its namespace-scoped kubeconfig as `ClusterAccess` in OpenBao. pub async fn provision_application_tenant_with_kubeconfig( + context: Context, kubeconfig: PathBuf, - kube_context: String, - provisioning: ApplicationTenantProvisioning, + tenant: TenantConfig, + credential_store: OpenBaoClusterAccess, + allow_insecure_source: bool, ) -> anyhow::Result<()> { - let kubeconfig = kubeconfig - .to_str() - .ok_or_else(|| anyhow::anyhow!("kubeconfig path must be valid UTF-8"))?; - let topology = K8sAnywhereTopology::with_config(K8sAnywhereConfig::kubeconfig( - kubeconfig, - Some(kube_context), - )); - let scores = application_tenant_scores(&provisioning).await?; - let to_run = scores - .iter() - .map(|score| score.clone_box()) - .collect::>(); - let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology); - maestro.register_all(scores); - maestro - .prepare_topology() - .await - .map_err(|error| anyhow::anyhow!("topology preparation failed: {error}"))?; - for score in to_run { - let name = score.name(); - let outcome = maestro - .interpret(score) - .await - .map_err(|error| anyhow::anyhow!("{name}: {error}"))?; - if !matches!( - outcome.status, - InterpretStatus::SUCCESS | InterpretStatus::NOOP - ) { - anyhow::bail!("{name}: {}: {}", outcome.status, outcome.message); - } + if !matches!(context.spec, ContextSpec::Remote(_)) { + anyhow::bail!("application tenant provisioning requires a remote context"); } + let app = ApplicationTenantProvisioner { + tenant, + credential_store, + allow_insecure_source, + }; + let ctx = AppContext::from_kubeconfig(&context, "bootstrap", kubeconfig)?; + deploy(&app, ctx.topology(), &ctx).await?; Ok(()) } -async fn application_tenant_scores( - provisioning: &ApplicationTenantProvisioning, -) -> Result>>, AppError> { - let source = harmony_config::openbao_source( - provisioning.credential_store.namespace.as_ref(), - Some(provisioning.credential_store.url.to_string()), - Some(provisioning.credential_store.zitadel_url.to_string()), - Some(provisioning.credential_store.zitadel_audience.to_string()), - Some(provisioning.credential_store.role.to_string()), - ) - .await - .ok_or_else(|| AppError::Deploy("tenant credential store is unavailable".into()))?; - let namespace = provisioning - .tenant - .name - .parse::() - .map_err(|error| AppError::InvalidComposition(error.to_string()))?; - Ok(vec![ - Box::new(TenantScore { - config: provisioning.tenant.clone(), - }), - Box::new(TenantCredentialScore::new( - namespace, - provisioning.deployer_service_account.clone(), - application_deployer_rules(), - Arc::new(ConfigClient::new(vec![source])), - provisioning.allow_insecure_source, - )), - ]) -} - fn application_deployer_rules() -> Vec { let verbs = || { [ diff --git a/harmony_cli/Cargo.toml b/harmony_cli/Cargo.toml index 172b4dfb..4d539e56 100644 --- a/harmony_cli/Cargo.toml +++ b/harmony_cli/Cargo.toml @@ -12,14 +12,12 @@ tui = ["dep:harmony_tui"] [dependencies] serde.workspace = true serde_json.workspace = true -serde_yaml.workspace = true assert_cmd = "2.0.17" anyhow = { workspace = true } -clap.workspace = true +clap = { version = "4.5.35", features = ["derive"] } harmony = { path = "../harmony" } harmony_app = { path = "../harmony_app" } harmony_tui = { path = "../harmony_tui", optional = true } -harmony_types = { path = "../harmony_types" } inquire.workspace = true tokio.workspace = true console = "0.16.0" @@ -28,10 +26,6 @@ lazy_static = "1.5.0" tracing.workspace = true tracing-subscriber.workspace = true -[[bin]] -name = "harmony-tenant" -path = "src/bin/harmony-tenant.rs" - [dev-dependencies] harmony = { path = "../harmony", features = ["testing"] } async-trait = "0.1" diff --git a/harmony_cli/src/bin/harmony-tenant.rs b/harmony_cli/src/bin/harmony-tenant.rs deleted file mode 100644 index 48980ff5..00000000 --- a/harmony_cli/src/bin/harmony-tenant.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[tokio::main] -async fn main() -> anyhow::Result<()> { - harmony_cli::tenant::tenant_main().await -} diff --git a/harmony_cli/src/lib.rs b/harmony_cli/src/lib.rs index e1d584a1..69b66e59 100644 --- a/harmony_cli/src/lib.rs +++ b/harmony_cli/src/lib.rs @@ -11,7 +11,6 @@ pub mod app; pub mod cli_logger; // FIXME: Don't make me pub mod cli_reporter; pub mod progress; -pub mod tenant; pub mod theme; #[cfg(feature = "tui")] diff --git a/harmony_cli/src/tenant.rs b/harmony_cli/src/tenant.rs deleted file mode 100644 index 63148212..00000000 --- a/harmony_cli/src/tenant.rs +++ /dev/null @@ -1,241 +0,0 @@ -use std::{fs, path::PathBuf}; - -use anyhow::{Context as _, ensure}; -use clap::Parser; -use harmony_app::{ - ApplicationTenantProvisioning, OpenBaoClusterAccess, ResourceLimits, TenantConfig, - provision_application_tenant_with_kubeconfig, -}; -use harmony_types::k8s_name::K8sName; - -#[derive(Debug, Parser)] -#[command( - version, - about = "Provision an application tenant and publish its namespace-scoped cluster access" -)] -struct TenantCli { - /// Administrator kubeconfig used only as the provisioning source. - #[arg(long, env = "KUBECONFIG")] - kubeconfig: PathBuf, - - /// Exact context selected from the source kubeconfig. - #[arg(long)] - kube_context: String, - - /// Stable tenant identifier. - #[arg(long)] - tenant_id: String, - - /// Kubernetes namespace to create for the tenant. - #[arg(long)] - namespace: String, - - /// ServiceAccount receiving application deployment permissions. - #[arg(long, default_value = "harmony-deployer")] - deployer_service_account: String, - - #[arg(long, default_value_t = 4.0)] - cpu_request_cores: f32, - - #[arg(long, default_value_t = 4.0)] - cpu_limit_cores: f32, - - #[arg(long, default_value_t = 4.0)] - memory_request_gb: f32, - - #[arg(long, default_value_t = 4.0)] - memory_limit_gb: f32, - - #[arg(long, default_value_t = 20.0)] - storage_total_gb: f32, - - #[arg(long, default_value_t = 10)] - service_limit: u32, - - /// OpenBao server receiving the generated ClusterAccess value. - #[arg(long)] - openbao_url: String, - - /// OpenBao secret namespace. Defaults to the Kubernetes namespace. - #[arg(long)] - openbao_namespace: Option, - - /// OpenBao JWT role used when token authentication is unavailable. - #[arg(long)] - openbao_role: String, - - #[arg(long)] - zitadel_url: String, - - #[arg(long)] - zitadel_audience: String, - - /// Permit an administrator kubeconfig that disables TLS verification. - #[arg(long)] - allow_insecure_source: bool, - - /// Execute provisioning. Without this flag the command only validates and prints its plan. - #[arg(long)] - yes: bool, -} - -pub async fn tenant_main() -> anyhow::Result<()> { - let cli = TenantCli::parse(); - run(cli).await -} - -async fn run(cli: TenantCli) -> anyhow::Result<()> { - validate_resource_limits(&cli)?; - validate_kubeconfig_context(&cli.kubeconfig, &cli.kube_context)?; - ensure!( - !cli.tenant_id.trim().is_empty(), - "tenant ID cannot be empty" - ); - cli.namespace - .parse::() - .map_err(|error| anyhow::anyhow!("invalid tenant namespace: {error}"))?; - - let openbao_namespace = cli - .openbao_namespace - .clone() - .unwrap_or_else(|| cli.namespace.clone()); - let tenant = TenantConfig { - id: cli.tenant_id.clone().into(), - name: cli.namespace.clone(), - resource_limits: ResourceLimits { - cpu_request_cores: cli.cpu_request_cores, - cpu_limit_cores: cli.cpu_limit_cores, - memory_request_gb: cli.memory_request_gb, - memory_limit_gb: cli.memory_limit_gb, - storage_total_gb: cli.storage_total_gb, - service_limit: cli.service_limit, - }, - ..Default::default() - }; - let provisioning = ApplicationTenantProvisioning { - tenant, - deployer_service_account: cli - .deployer_service_account - .parse() - .map_err(|error| anyhow::anyhow!("invalid deployer ServiceAccount: {error}"))?, - credential_store: OpenBaoClusterAccess { - namespace: openbao_namespace - .parse() - .map_err(|error| anyhow::anyhow!("invalid OpenBao namespace: {error}"))?, - url: cli - .openbao_url - .parse() - .map_err(|error| anyhow::anyhow!("invalid OpenBao URL: {error}"))?, - role: cli - .openbao_role - .parse() - .map_err(|error| anyhow::anyhow!("invalid OpenBao role: {error}"))?, - zitadel_url: cli - .zitadel_url - .parse() - .map_err(|error| anyhow::anyhow!("invalid Zitadel URL: {error}"))?, - zitadel_audience: cli - .zitadel_audience - .parse() - .map_err(|error| anyhow::anyhow!("invalid Zitadel audience: {error}"))?, - }, - allow_insecure_source: cli.allow_insecure_source, - }; - - println!("Tenant provisioning plan:"); - println!(" source kubeconfig: {}", cli.kubeconfig.display()); - println!(" source context: {}", cli.kube_context); - println!(" tenant: {}", cli.tenant_id); - println!(" namespace: {}", cli.namespace); - println!( - " deployer ServiceAccount: {}", - cli.deployer_service_account - ); - println!( - " ClusterAccess destination: secret/{openbao_namespace}/ClusterAccess at {}", - cli.openbao_url - ); - - if !cli.yes { - println!("Dry run only; pass --yes to execute this plan."); - return Ok(()); - } - - crate::cli_logger::init(); - crate::cli_reporter::init(); - provision_application_tenant_with_kubeconfig(cli.kubeconfig, cli.kube_context, provisioning) - .await -} - -fn validate_resource_limits(cli: &TenantCli) -> anyhow::Result<()> { - ensure!(cli.cpu_request_cores > 0.0, "CPU request must be positive"); - ensure!(cli.cpu_limit_cores > 0.0, "CPU limit must be positive"); - ensure!( - cli.cpu_request_cores <= cli.cpu_limit_cores, - "CPU request cannot exceed CPU limit" - ); - ensure!( - cli.memory_request_gb > 0.0, - "memory request must be positive" - ); - ensure!(cli.memory_limit_gb > 0.0, "memory limit must be positive"); - ensure!( - cli.memory_request_gb <= cli.memory_limit_gb, - "memory request cannot exceed memory limit" - ); - ensure!(cli.storage_total_gb > 0.0, "storage total must be positive"); - ensure!(cli.service_limit > 0, "service limit must be positive"); - Ok(()) -} - -fn validate_kubeconfig_context(path: &PathBuf, expected: &str) -> anyhow::Result<()> { - let contents = fs::read_to_string(path) - .with_context(|| format!("reading kubeconfig '{}'", path.display()))?; - let kubeconfig: serde_yaml::Value = serde_yaml::from_str(&contents) - .with_context(|| format!("parsing kubeconfig '{}'", path.display()))?; - let context_exists = kubeconfig - .get("contexts") - .and_then(serde_yaml::Value::as_sequence) - .is_some_and(|contexts| { - contexts.iter().any(|context| { - context.get("name").and_then(serde_yaml::Value::as_str) == Some(expected) - }) - }); - ensure!( - context_exists, - "kubeconfig '{}' has no context named '{expected}'", - path.display() - ); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn requires_yes_to_execute() { - let cli = TenantCli::try_parse_from([ - "harmony-tenant", - "--kubeconfig", - "/tmp/admin.kubeconfig", - "--kube-context", - "production-admin", - "--tenant-id", - "client-a", - "--namespace", - "client-a", - "--openbao-url", - "https://secrets.example.com", - "--openbao-role", - "harmony-client-a", - "--zitadel-url", - "https://sso.example.com", - "--zitadel-audience", - "12345", - ]) - .expect("arguments should parse"); - - assert!(!cli.yes); - } -} -- 2.39.5 From 9c13d062deaa2f56e7d68aa266cb459debdddff7 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 16:00:38 -0400 Subject: [PATCH 05/34] refactor: centralize Zitadel and OpenBao management --- Cargo.lock | 15 + harmony/Cargo.toml | 1 + harmony/src/modules/zitadel/setup.rs | 506 +++------------- harmony_auth/Cargo.toml | 3 + harmony_auth_cli/Cargo.toml | 11 + harmony_secret/src/deployment_grants.rs | 287 +-------- harmony_secret/src/lib.rs | 2 + harmony_secret/src/openbao_policy.rs | 318 ++++++++++ harmony_zitadel_auth/Cargo.toml | 4 + harmony_zitadel_auth/src/lib.rs | 1 + harmony_zitadel_auth/src/management.rs | 775 ++++++++++++++++++++++++ 11 files changed, 1210 insertions(+), 713 deletions(-) create mode 100644 harmony_secret/src/openbao_policy.rs create mode 100644 harmony_zitadel_auth/src/management.rs diff --git a/Cargo.lock b/Cargo.lock index 8d8bb796..5925c2fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3999,6 +3999,7 @@ dependencies = [ "harmony_secret", "harmony_secret_derive", "harmony_types", + "harmony_zitadel_auth", "helm-wrapper-rs", "hex", "http 1.4.0", @@ -4369,6 +4370,9 @@ version = "0.1.0" dependencies = [ "async-trait", "chrono", + "harmony_secret", + "harmony_types", + "harmony_zitadel_auth", "reqwest 0.12.28", "serde", "serde_json", @@ -4382,9 +4386,18 @@ name = "harmony_auth_cli" version = "0.1.0" dependencies = [ "clap", + "harmony-k8s", + "harmony_app", "harmony_auth", + "harmony_config", + "harmony_types", + "inquire 0.7.5", + "schemars 0.8.22", + "serde", "serde_json", + "tempfile", "tokio", + "tracing", "tracing-subscriber", ] @@ -4640,6 +4653,7 @@ dependencies = [ "harmony-reconciler-contracts", "harmony_config", "harmony_zitadel_jwt", + "httptest", "jsonwebtoken", "openidconnect", "rand 0.9.4", @@ -4648,6 +4662,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "thiserror 2.0.18", "time", "tokio", "tracing", diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 9e36d25e..97d5d73f 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -89,6 +89,7 @@ walkdir = "2.5.0" harmony_inventory_agent = { path = "../harmony_inventory_agent" } harmony_secret_derive = { path = "../harmony_secret_derive" } harmony_secret = { path = "../harmony_secret" } +harmony_zitadel_auth = { path = "../harmony_zitadel_auth" } askama.workspace = true sha2 = "0.10" sqlx.workspace = true diff --git a/harmony/src/modules/zitadel/setup.rs b/harmony/src/modules/zitadel/setup.rs index 8405994e..934e7e9b 100644 --- a/harmony/src/modules/zitadel/setup.rs +++ b/harmony/src/modules/zitadel/setup.rs @@ -15,6 +15,7 @@ use crate::{ topology::{K8sclient, Topology}, }; use harmony_types::id::Id; +use harmony_zitadel_auth::management::ManagementClient; use super::{ OidcApplicationRef, OidcMachineIdentityRef, OidcProviderRef, @@ -140,14 +141,6 @@ pub enum MachineKeyType { Json, } -impl MachineKeyType { - fn api_value(self) -> &'static str { - match self { - MachineKeyType::Json => "KEY_TYPE_JSON", - } - } -} - /// A machine (service-account) user for service-to-service automation. /// /// When `machine_key` is set, a key is provisioned and cached in @@ -772,16 +765,6 @@ struct OidcConfig { client_id: Option, } -#[derive(Deserialize)] -struct RoleSearchResult { - result: Option>, -} - -#[derive(Deserialize)] -struct RoleEntry { - key: String, -} - #[derive(Deserialize)] struct UserSearchResult { result: Option>, @@ -794,21 +777,10 @@ struct UserSearchEntry { user_name: Option, #[serde(rename = "preferredLoginName", default)] preferred_login_name: Option, - #[serde(default)] - human: Option, - #[serde(default)] - machine: Option, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum UserKind { - Human, - Machine, } struct FoundUser { id: String, - kind: UserKind, } #[derive(Deserialize)] @@ -817,37 +789,6 @@ struct UserCreateResponse { user_id: String, } -/// Response when creating a machine key. Zitadel returns the keyId plus -/// a `keyDetails` JSON that we round-trip as the keyfile content. -#[derive(Deserialize)] -struct MachineKeyResponse { - #[serde(rename = "keyId")] - #[allow(dead_code)] - key_id: String, - /// Base64-encoded JSON keyfile content (Zitadel returns the file as - /// a single base64 blob). - #[serde(rename = "keyDetails")] - key_details: String, -} - -#[derive(Deserialize)] -struct UserGrantSearchResult { - result: Option>, -} - -#[derive(Deserialize)] -struct UserGrantEntry { - id: String, - #[serde(rename = "projectId")] - project_id: String, -} - -#[derive(Deserialize)] -struct UserGrantCreateResponse { - #[serde(rename = "userGrantId")] - user_grant_id: String, -} - #[derive(Deserialize)] struct MachineSecretResponse { #[serde(rename = "clientId")] @@ -857,6 +798,21 @@ struct MachineSecretResponse { } impl ZitadelSetupInterpret { + fn management_client(&self, pat: &str) -> Result { + let client = ManagementClient::new( + self.api_url(""), + pat, + self.score.admin_org_id.clone(), + self.score.skip_tls, + ) + .map_err(|error| InterpretError::new(error.to_string()))?; + Ok(if self.score.endpoint.is_some() { + client.with_host_header(&self.score.host) + } else { + client + }) + } + /// Build the request URL for `path`. When `endpoint` is set, returns /// ``; otherwise `://[:]`, /// omitting the port when it matches the scheme default. @@ -1780,69 +1736,6 @@ impl ZitadelSetupInterpret { // Roles // ------------------------------------------------------------------ - async fn role_exists( - &self, - client: &reqwest::Client, - pat: &str, - project_id: &str, - role_key: &str, - ) -> Result { - let resp = self - .post( - client, - &format!("/management/v1/projects/{project_id}/roles/_search"), - ) - .bearer_auth(pat) - .json(&serde_json::json!({})) - .send() - .await - .map_err(|e| format!("Failed to search roles: {e}"))?; - - let result: RoleSearchResult = resp - .json() - .await - .map_err(|e| format!("Failed to parse role search: {e}"))?; - - Ok(result - .result - .unwrap_or_default() - .into_iter() - .any(|r| r.key == role_key)) - } - - async fn create_role( - &self, - client: &reqwest::Client, - pat: &str, - project_id: &str, - role: &ZitadelRole, - ) -> Result<(), String> { - let mut body = serde_json::json!({ - "roleKey": role.key, - "displayName": role.display_name, - }); - if let Some(group) = &role.group { - body["group"] = serde_json::Value::String(group.clone()); - } - - let resp = self - .post( - client, - &format!("/management/v1/projects/{project_id}/roles"), - ) - .bearer_auth(pat) - .json(&body) - .send() - .await - .map_err(|e| format!("Failed to create role: {e}"))?; - - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Create role '{}' failed: {body}", role.key)); - } - Ok(()) - } - async fn ensure_role( &self, client: &reqwest::Client, @@ -1854,22 +1747,15 @@ impl ZitadelSetupInterpret { .ensure_project(client, pat, &role.project_name, config) .await?; - if self - .role_exists(client, pat, &project_id, &role.key) + self.management_client(pat)? + .ensure_project_role( + &project_id, + &role.key, + &role.display_name, + role.group.as_deref(), + ) .await - .map_err(InterpretError::new)? - { - debug!("[ZitadelSetup] Role '{}' already exists", role.key); - return Ok(()); - } - - self.create_role(client, pat, &project_id, role) - .await - .map_err(InterpretError::new)?; - info!( - "[ZitadelSetup] Role '{}' created in project '{}'", - role.key, role.project_name - ); + .map_err(|error| InterpretError::new(error.to_string()))?; Ok(()) } @@ -1906,7 +1792,7 @@ impl ZitadelSetupInterpret { .await .map_err(|e| format!("Failed to parse user search: {e}"))?; - result + Ok(result .result .unwrap_or_default() .into_iter() @@ -1914,123 +1800,21 @@ impl ZitadelSetupInterpret { u.user_name.as_deref() == Some(username) || u.preferred_login_name.as_deref() == Some(username) }) - .map(|user| { - let kind = if user.machine.is_some() { - UserKind::Machine - } else if user.human.is_some() { - UserKind::Human - } else { - // Zitadel user search results always contain one of these - // variants; refusing an unknown shape avoids adopting the - // wrong principal type. - return Err("user search result has no human or machine type".to_string()); - }; - Ok(FoundUser { id: user.id, kind }) - }) - .transpose() - } - - async fn find_user_of_kind( - &self, - client: &reqwest::Client, - pat: &str, - username: &str, - expected: UserKind, - ) -> Result, String> { - match self.find_user(client, pat, username).await? { - Some(found) if found.kind == expected => Ok(Some(found.id)), - Some(_) => Err(format!( - "user '{username}' already exists with a different principal type" - )), - None => Ok(None), - } - } - - async fn find_machine_user( - &self, - client: &reqwest::Client, - pat: &str, - username: &str, - ) -> Result, String> { - self.find_user_of_kind(client, pat, username, UserKind::Machine) - .await + .map(|user| FoundUser { id: user.id })) } async fn find_human_user( &self, - client: &reqwest::Client, + _client: &reqwest::Client, pat: &str, username: &str, ) -> Result, String> { - self.find_user_of_kind(client, pat, username, UserKind::Human) + self.management_client(pat) + .map_err(|error| error.to_string())? + .find_human(username) .await - } - - async fn create_machine_user( - &self, - client: &reqwest::Client, - pat: &str, - user: &ZitadelMachineUser, - ) -> Result { - let resp = self - .post(client, "/management/v1/users/machine") - .bearer_auth(pat) - .json(&serde_json::json!({ - "userName": user.username, - "name": user.name, - "description": format!("Provisioned by Harmony ZitadelSetupScore"), - "accessTokenType": "ACCESS_TOKEN_TYPE_JWT" - })) - .send() - .await - .map_err(|e| format!("Failed to create machine user: {e}"))?; - - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(format!( - "Create machine user '{}' failed: {body}", - user.username - )); - } - - let parsed: UserCreateResponse = - serde_json::from_str(&resp.text().await.map_err(|e| format!("Read body: {e}"))?) - .map_err(|e| format!("Parse machine user response: {e}"))?; - Ok(parsed.user_id) - } - - async fn create_machine_key( - &self, - client: &reqwest::Client, - pat: &str, - user_id: &str, - key_type: MachineKeyType, - ) -> Result { - let resp = self - .post(client, &format!("/management/v1/users/{user_id}/keys")) - .bearer_auth(pat) - .json(&serde_json::json!({ - "type": key_type.api_value() - })) - .send() - .await - .map_err(|e| format!("Failed to create machine key: {e}"))?; - - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Create machine key failed: {body}")); - } - - let parsed: MachineKeyResponse = - serde_json::from_str(&resp.text().await.map_err(|e| format!("Read body: {e}"))?) - .map_err(|e| format!("Parse machine key response: {e}"))?; - - // `keyDetails` is base64-encoded JSON keyfile content. - use base64::Engine; - let bytes = base64::engine::general_purpose::STANDARD - .decode(&parsed.key_details) - .map_err(|e| format!("Decode keyDetails base64: {e}"))?; - String::from_utf8(bytes).map_err(|e| format!("keyDetails contained non-UTF8 bytes: {e}")) + .map(|user| user.map(|user| user.id)) + .map_err(|error| error.to_string()) } /// Upsert the roles→groups flattening Action and attach it to the @@ -2198,80 +1982,6 @@ impl ZitadelSetupInterpret { .and_then(|a| a["id"].as_str().map(str::to_string))) } - async fn find_user_grant( - &self, - client: &reqwest::Client, - pat: &str, - user_id: &str, - project_id: &str, - ) -> Result, String> { - // The per-user `/management/v1/users/{userId}/grants/_search` - // endpoint Zitadel's docs hint at returns 405 Method Not Allowed - // in current Zitadel (verified against v3.x). The collection - // endpoint `/management/v1/users/grants/_search` accepts query - // filters and is what works in practice — filter by userIdQuery - // server-side, then narrow to the matching project_id locally. - let resp = self - .post(client, "/management/v1/users/grants/_search") - .bearer_auth(pat) - .json(&serde_json::json!({ - "queries": [ - { "userIdQuery": { "userId": user_id } } - ] - })) - .send() - .await - .map_err(|e| format!("Failed to search user grants: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("user-grant search returned {status}: {body}")); - } - - let result: UserGrantSearchResult = resp - .json() - .await - .map_err(|e| format!("Failed to parse user grant search: {e}"))?; - - Ok(result - .result - .unwrap_or_default() - .into_iter() - .find(|g| g.project_id == project_id) - .map(|g| g.id)) - } - - async fn create_user_grant( - &self, - client: &reqwest::Client, - pat: &str, - user_id: &str, - project_id: &str, - role_keys: &[String], - ) -> Result { - let resp = self - .post(client, &format!("/management/v1/users/{user_id}/grants")) - .bearer_auth(pat) - .json(&serde_json::json!({ - "projectId": project_id, - "roleKeys": role_keys - })) - .send() - .await - .map_err(|e| format!("Failed to create user grant: {e}"))?; - - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Create user grant failed: {body}")); - } - - let parsed: UserGrantCreateResponse = - serde_json::from_str(&resp.text().await.map_err(|e| format!("Read body: {e}"))?) - .map_err(|e| format!("Parse user grant response: {e}"))?; - Ok(parsed.user_grant_id) - } - async fn ensure_contract_project_assignment( &self, client: &reqwest::Client, @@ -2293,35 +2003,11 @@ impl ZitadelSetupInterpret { .iter() .map(|role| role.key.clone()) .collect(); - let grant_id = if let Some(grant_id) = self - .find_user_grant(client, pat, &user_id, &project_id) + let grant_id = self + .management_client(pat)? + .set_project_role_grant(&user_id, &project_id, &role_keys) .await - .map_err(InterpretError::new)? - { - let response = self - .put( - client, - &format!("/management/v1/users/{user_id}/grants/{grant_id}"), - ) - .bearer_auth(pat) - .json(&serde_json::json!({ "roleKeys": role_keys })) - .send() - .await - .map_err(|error| InterpretError::new(format!("Update user grant: {error}")))?; - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - if !is_zitadel_no_changes(&body) { - return Err(InterpretError::new(format!( - "Update grant for '{username}' failed: {body}" - ))); - } - } - grant_id - } else { - self.create_user_grant(client, pat, &user_id, &project_id, &role_keys) - .await - .map_err(InterpretError::new)? - }; + .map_err(|error| InterpretError::new(error.to_string()))?; config.user_grants.insert( ZitadelClientConfig::user_grant_key(username, project_name), grant_id, @@ -2340,17 +2026,12 @@ impl ZitadelSetupInterpret { // than trusting the cache: a cached id pointing at a // user that was deleted server-side would otherwise be // propagated through the rest of the apply. - let user_id = match self - .find_machine_user(client, pat, &user.username) + let management = self.management_client(pat)?; + let user_id = management + .ensure_machine(&user.username, &user.name) .await - .map_err(InterpretError::new)? - { - Some(id) => id, - None => self - .create_machine_user(client, pat, user) - .await - .map_err(InterpretError::new)?, - }; + .map_err(|error| InterpretError::new(error.to_string()))? + .id; if config.machine_user_ids.get(&user.username) != Some(&user_id) { config.machine_keys.remove(&user.username); config.machine_client_ids.remove(&user.username); @@ -2368,13 +2049,12 @@ impl ZitadelSetupInterpret { // material on subsequent reads, so the cache MUST hold it; if // the cache is missing the key, we provision a new one (the // old one becomes orphaned but stays valid until expiry). - if let Some(key_type) = user.machine_key - && !config.machine_keys.contains_key(&user.username) - { - let key_json = self - .create_machine_key(client, pat, &user_id, key_type) + if user.machine_key.is_some() && !config.machine_keys.contains_key(&user.username) { + let key_json = management + .create_json_machine_key(&user_id) .await - .map_err(InterpretError::new)?; + .map_err(|error| InterpretError::new(error.to_string()))? + .json; info!("[ZitadelSetup] Machine key created for '{}'", user.username); config.machine_keys.insert(user.username.clone(), key_json); config @@ -2400,27 +2080,10 @@ impl ZitadelSetupInterpret { // cached grant id silently leaves stale role bindings if // Zitadel was reset. let grant_key = ZitadelClientConfig::user_grant_key(&user.username, project_name); - let grant_id = if let Some(id) = self - .find_user_grant(client, pat, &user_id, &project_id) + let grant_id = management + .ensure_project_role_grant(&user_id, &project_id, &user.grant_roles) .await - .map_err(InterpretError::new)? - { - debug!( - "[ZitadelSetup] Grant for '{}' on project '{}' already exists: {id}", - user.username, project_name - ); - id - } else { - let id = self - .create_user_grant(client, pat, &user_id, &project_id, &user.grant_roles) - .await - .map_err(InterpretError::new)?; - info!( - "[ZitadelSetup] Grant created: '{}' → project '{}' with roles {:?}", - user.username, project_name, user.grant_roles - ); - id - }; + .map_err(|error| InterpretError::new(error.to_string()))?; config.user_grants.insert(grant_key, grant_id); } @@ -2654,17 +2317,11 @@ impl ZitadelSetupInterpret { .ensure_project(client, pat, project_name, config) .await?; let grant_key = ZitadelClientConfig::user_grant_key(&user.email, project_name); - let grant_id = if let Some(id) = self - .find_user_grant(client, pat, &user_id, &project_id) + let grant_id = self + .management_client(pat)? + .ensure_project_role_grant(&user_id, &project_id, &user.grant_roles) .await - .map_err(InterpretError::new)? - { - id - } else { - self.create_user_grant(client, pat, &user_id, &project_id, &user.grant_roles) - .await - .map_err(InterpretError::new)? - }; + .map_err(|error| InterpretError::new(error.to_string()))?; config.user_grants.insert(grant_key, grant_id); } Ok(()) @@ -2685,9 +2342,11 @@ impl ZitadelSetupInterpret { return Ok(()); } let user_id = self - .find_machine_user(client, pat, username) + .management_client(pat)? + .find_machine(username) .await - .map_err(InterpretError::new)? + .map_err(|error| InterpretError::new(error.to_string()))? + .map(|user| user.id) .ok_or_else(|| { InterpretError::new(format!( "machine_secrets references unknown user '{username}' — declare it in machine_users" @@ -2869,45 +2528,25 @@ pub async fn mint_device_credentials( )) })?; - let user_id = match interp - .find_machine_user(&client, admin_token, device_username) + let management = interp.management_client(admin_token)?; + let user_id = management + .ensure_machine(device_username, device_display_name) .await - .map_err(InterpretError::new)? - { - Some(id) => id, - None => { - let user = ZitadelMachineUser { - username: device_username.to_string(), - name: device_display_name.to_string(), - create_pat: false, - machine_key: None, - project_name: None, - grant_roles: vec![], - }; - interp - .create_machine_user(&client, admin_token, &user) - .await - .map_err(InterpretError::new)? - } - }; + .map_err(|error| InterpretError::new(error.to_string()))? + .id; - if !role_keys.is_empty() - && interp - .find_user_grant(&client, admin_token, &user_id, &project_id) + if !role_keys.is_empty() { + management + .ensure_project_role_grant(&user_id, &project_id, role_keys) .await - .map_err(InterpretError::new)? - .is_none() - { - interp - .create_user_grant(&client, admin_token, &user_id, &project_id, role_keys) - .await - .map_err(InterpretError::new)?; + .map_err(|error| InterpretError::new(error.to_string()))?; } - let key_json = interp - .create_machine_key(&client, admin_token, &user_id, MachineKeyType::Json) + let key_json = management + .create_json_machine_key(&user_id) .await - .map_err(InterpretError::new)?; + .map_err(|error| InterpretError::new(error.to_string()))? + .json; Ok(MintedDeviceCredentials { project_id, @@ -3784,11 +3423,6 @@ mod tests { assert!(cfg.user_grants.is_empty()); } - #[test] - fn machine_key_type_maps_to_zitadel_api_value() { - assert_eq!(MachineKeyType::Json.api_value(), "KEY_TYPE_JSON"); - } - #[test] fn machine_keys_accessor_returns_cached_material() { let mut cfg = ZitadelClientConfig::default(); diff --git a/harmony_auth/Cargo.toml b/harmony_auth/Cargo.toml index 6cde4cde..da01dbd4 100644 --- a/harmony_auth/Cargo.toml +++ b/harmony_auth/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true [dependencies] async-trait.workspace = true chrono = { workspace = true, features = ["serde"] } +harmony_secret = { path = "../harmony_secret" } +harmony_types = { path = "../harmony_types" } +harmony_zitadel_auth = { path = "../harmony_zitadel_auth" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/harmony_auth_cli/Cargo.toml b/harmony_auth_cli/Cargo.toml index fad83d54..1396d7c5 100644 --- a/harmony_auth_cli/Cargo.toml +++ b/harmony_auth_cli/Cargo.toml @@ -11,7 +11,18 @@ path = "src/main.rs" [dependencies] harmony_auth = { path = "../harmony_auth" } +harmony_app = { path = "../harmony_app" } +harmony-k8s = { path = "../harmony-k8s" } +harmony_config = { path = "../harmony_config" } +harmony_types = { path = "../harmony_types" } clap.workspace = true +inquire.workspace = true +schemars = "0.8" +serde.workspace = true serde_json.workspace = true tokio.workspace = true +tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/harmony_secret/src/deployment_grants.rs b/harmony_secret/src/deployment_grants.rs index f9215f00..ba497f1b 100644 --- a/harmony_secret/src/deployment_grants.rs +++ b/harmony_secret/src/deployment_grants.rs @@ -6,37 +6,23 @@ //! the role's `groups_claim`, so attaching/detaching the policy binds //! for every member's existing tokens at request time — O(groups) //! writes per deployment change, regardless of fleet size (ADR-025). -//! -//! External groups are matched to the token claim through a group alias -//! on the JWT mount; we create group + alias on first grant so the grant -//! can precede any device's first login. - -use std::collections::HashSet; use async_trait::async_trait; -use reqwest::StatusCode; -use serde_json::json; -use tokio::sync::OnceCell; - use harmony_reconciler_contracts::{ DEVICE_PULL_SECRET_PATH, DeploymentName, DeploymentSecretGrants, SecretAccessError, validate_image_pull_secret_reference, }; +use crate::OpenBaoPolicyManager; + const JWT_AUTH_MOUNT: &str = "jwt"; pub struct OpenBaoDeploymentSecretGrants { - client: reqwest::Client, - base_url: String, - token: String, + policies: OpenBaoPolicyManager, kv_mount: String, /// Path prefix under the KV mount where the fleet's secrets live /// (`/data///…`). secret_prefix: String, - jwt_mount: String, - /// JWT auth mount accessor, resolved once and reused — needed to bind - /// a group alias to the login claim value. - jwt_accessor: OnceCell, } impl OpenBaoDeploymentSecretGrants { @@ -58,20 +44,12 @@ impl OpenBaoDeploymentSecretGrants { jwt_mount: String, ) -> Self { Self { - client: reqwest::Client::new(), - base_url: base_url.trim_end_matches('/').to_string(), - token, + policies: OpenBaoPolicyManager::new(base_url, token, jwt_mount), kv_mount, secret_prefix, - jwt_mount, - jwt_accessor: OnceCell::new(), } } - fn err(context: impl std::fmt::Display, e: impl std::fmt::Display) -> SecretAccessError { - SecretAccessError::Backend(format!("{context}: {e}")) - } - fn policy_name(deployment: &DeploymentName) -> String { format!("deployment-{}", deployment.as_str()) } @@ -103,243 +81,6 @@ path "{kv}/metadata/{prefix}/{dep}/*" {{ capabilities = ["read", "list"] }}"#, } Ok(hcl) } - - async fn request( - &self, - method: reqwest::Method, - path: &str, - body: Option, - ) -> Result { - let mut req = self - .client - .request(method.clone(), format!("{}/v1/{path}", self.base_url)) - .header("X-Vault-Token", &self.token); - if let Some(body) = body { - req = req.json(&body); - } - req.send() - .await - .map_err(|e| Self::err(format!("{method} {path}"), e)) - } - - /// JWT mount accessor from `sys/auth`, cached for the client's life. - async fn jwt_accessor(&self) -> Result<&str, SecretAccessError> { - self.jwt_accessor - .get_or_try_init(|| async { - let body: serde_json::Value = self - .request(reqwest::Method::GET, "sys/auth", None) - .await? - .error_for_status() - .map_err(|e| Self::err("GET sys/auth", e))? - .json() - .await - .map_err(|e| Self::err("parse sys/auth", e))?; - // sys/auth nests mounts under `data` over the HTTP API but - // emits them at the document root via the CLI; tolerate both. - let mount_key = format!("{}/", self.jwt_mount); - body.get("data") - .and_then(|d| d.get(&mount_key)) - .or_else(|| body.get(&mount_key)) - .and_then(|m| m.get("accessor")) - .and_then(|a| a.as_str()) - .map(str::to_string) - .ok_or_else(|| { - Self::err( - "resolve jwt accessor", - format!("mount '{mount_key}' not found in sys/auth"), - ) - }) - }) - .await - .map(String::as_str) - } - - async fn upsert_policy( - &self, - deployment: &DeploymentName, - image_pull_secrets: &[String], - ) -> Result<(), SecretAccessError> { - let name = Self::policy_name(deployment); - let hcl = self.policy_hcl(deployment, image_pull_secrets)?; - self.request( - reqwest::Method::PUT, - &format!("sys/policies/acl/{name}"), - Some(json!({ "policy": hcl })), - ) - .await? - .error_for_status() - .map_err(|e| Self::err(format!("write policy {name}"), e))?; - Ok(()) - } - - async fn delete_policy(&self, deployment: &DeploymentName) -> Result<(), SecretAccessError> { - let name = Self::policy_name(deployment); - let resp = self - .request( - reqwest::Method::DELETE, - &format!("sys/policies/acl/{name}"), - None, - ) - .await?; - if !resp.status().is_success() && resp.status() != StatusCode::NOT_FOUND { - return Err(Self::err(format!("delete policy {name}"), resp.status())); - } - Ok(()) - } - - /// Group's current policy list, or `None` when the group doesn't - /// exist. - async fn read_group_policies( - &self, - group: &str, - ) -> Result>, SecretAccessError> { - let resp = self - .request( - reqwest::Method::GET, - &format!("identity/group/name/{group}"), - None, - ) - .await?; - if resp.status() == StatusCode::NOT_FOUND { - return Ok(None); - } - let body: serde_json::Value = resp - .error_for_status() - .map_err(|e| Self::err(format!("read group {group}"), e))? - .json() - .await - .map_err(|e| Self::err(format!("parse group {group}"), e))?; - Ok(Some( - body["data"]["policies"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(), - )) - } - - async fn write_group_policies( - &self, - group: &str, - policies: &[String], - ) -> Result<(), SecretAccessError> { - self.request( - reqwest::Method::POST, - &format!("identity/group/name/{group}"), - Some(json!({ "type": "external", "policies": policies })), - ) - .await? - .error_for_status() - .map_err(|e| Self::err(format!("write group {group}"), e))?; - Ok(()) - } - - /// Ensure the external group exists with its alias bound to the JWT - /// mount, and that it carries `policy`. - async fn attach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> { - match self.read_group_policies(group).await? { - Some(policies) if policies.iter().any(|p| p == policy) => {} - Some(mut policies) => { - policies.push(policy.to_string()); - self.write_group_policies(group, &policies).await?; - } - None => { - self.write_group_policies(group, &[policy.to_string()]) - .await?; - } - } - self.ensure_group_alias(group).await?; - Ok(()) - } - - async fn detach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> { - if let Some(policies) = self.read_group_policies(group).await? - && policies.iter().any(|p| p == policy) - { - let remaining: Vec = policies.into_iter().filter(|p| p != policy).collect(); - self.write_group_policies(group, &remaining).await?; - } - Ok(()) - } - - async fn ensure_group_alias(&self, group: &str) -> Result<(), SecretAccessError> { - let accessor = self.jwt_accessor().await?.to_string(); - let body: serde_json::Value = self - .request( - reqwest::Method::GET, - &format!("identity/group/name/{group}"), - None, - ) - .await? - .error_for_status() - .map_err(|e| Self::err(format!("read group {group}"), e))? - .json() - .await - .map_err(|e| Self::err(format!("parse group {group}"), e))?; - if !body["data"]["alias"]["id"] - .as_str() - .unwrap_or("") - .is_empty() - { - return Ok(()); - } - let canonical_id = body["data"]["id"] - .as_str() - .ok_or_else(|| Self::err("read group", format!("{group} missing data.id")))?; - let resp = self - .request( - reqwest::Method::POST, - "identity/group-alias", - Some(json!({ - "name": group, - "mount_accessor": accessor, - "canonical_id": canonical_id, - })), - ) - .await?; - if resp.status().is_success() { - return Ok(()); - } - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - let alias_exists = status == StatusCode::BAD_REQUEST - && body.to_ascii_lowercase().contains("alias") - && body.to_ascii_lowercase().contains("already"); - if !alias_exists { - return Err(Self::err( - format!("create group alias {group}"), - format!("{status}: {body}"), - )); - } - Ok(()) - } - - /// Names of all identity groups, empty when none exist. - async fn list_groups(&self) -> Result, SecretAccessError> { - let resp = self - .request(reqwest::Method::GET, "identity/group/name?list=true", None) - .await?; - if resp.status() == StatusCode::NOT_FOUND { - return Ok(vec![]); - } - let body: serde_json::Value = resp - .error_for_status() - .map_err(|e| Self::err("list groups", e))? - .json() - .await - .map_err(|e| Self::err("parse group list", e))?; - Ok(body["data"]["keys"] - .as_array() - .map(|a| { - a.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default()) - } } #[async_trait] @@ -350,20 +91,12 @@ impl DeploymentSecretGrants for OpenBaoDeploymentSecretGrants { ) -> Result<(), SecretAccessError> { for (deployment, groups, image_pull_secrets) in grants { let policy = Self::policy_name(deployment); - let allowed: HashSet<&str> = groups.iter().map(String::as_str).collect(); - for group in self.list_groups().await? { - if !allowed.contains(group.as_str()) { - self.detach(&group, &policy).await?; - } - } - if groups.is_empty() { - self.delete_policy(deployment).await?; - continue; - } - self.upsert_policy(deployment, image_pull_secrets).await?; - for group in groups { - self.attach(group, &policy).await?; - } + let policy_hcl = (!groups.is_empty()) + .then(|| self.policy_hcl(deployment, image_pull_secrets)) + .transpose()?; + self.policies + .reconcile_acl_policy(&policy, policy_hcl.as_deref(), groups) + .await?; } Ok(()) } diff --git a/harmony_secret/src/lib.rs b/harmony_secret/src/lib.rs index 54577483..e5c94cff 100644 --- a/harmony_secret/src/lib.rs +++ b/harmony_secret/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; mod deployment_grants; +mod openbao_policy; pub mod store; use crate::config::SECRET_NAMESPACE; @@ -33,6 +34,7 @@ use tokio::sync::OnceCell; pub use deployment_grants::OpenBaoDeploymentSecretGrants; pub use harmony_secret_derive::Secret; +pub use openbao_policy::OpenBaoPolicyManager; // The Secret trait remains the same. // pub trait Secret: Serialize + DeserializeOwned + Sized { diff --git a/harmony_secret/src/openbao_policy.rs b/harmony_secret/src/openbao_policy.rs new file mode 100644 index 00000000..66a71196 --- /dev/null +++ b/harmony_secret/src/openbao_policy.rs @@ -0,0 +1,318 @@ +use std::{collections::HashSet, fmt}; + +use harmony_reconciler_contracts::SecretAccessError; +use reqwest::StatusCode; +use serde_json::json; +use tokio::sync::OnceCell; + +/// Reconciles an OpenBao ACL policy with external identity groups and JWT aliases. +pub struct OpenBaoPolicyManager { + client: reqwest::Client, + base_url: String, + token: String, + jwt_mount: String, + jwt_accessor: OnceCell, +} + +impl fmt::Debug for OpenBaoPolicyManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OpenBaoPolicyManager") + .field("base_url", &self.base_url) + .field("jwt_mount", &self.jwt_mount) + .finish_non_exhaustive() + } +} + +impl OpenBaoPolicyManager { + pub fn new(base_url: String, token: String, jwt_mount: String) -> Self { + Self { + client: reqwest::Client::new(), + base_url: base_url.trim_end_matches('/').to_string(), + token, + jwt_mount, + jwt_accessor: OnceCell::new(), + } + } + + /// Upserts `policy_hcl` and grants it only to `groups`. + /// + /// Passing `None` removes the policy. Other policies attached to each + /// identity group are preserved. + pub async fn reconcile_acl_policy( + &self, + name: &str, + policy_hcl: Option<&str>, + groups: &[String], + ) -> Result<(), SecretAccessError> { + let allowed: HashSet<&str> = groups.iter().map(String::as_str).collect(); + for group in self.list_groups().await? { + if !allowed.contains(group.as_str()) { + self.detach(&group, name).await?; + } + } + + let Some(policy_hcl) = policy_hcl else { + self.delete_policy(name).await?; + return Ok(()); + }; + + self.upsert_policy(name, policy_hcl).await?; + for group in groups { + self.attach(group, name).await?; + } + Ok(()) + } + + fn err(context: impl fmt::Display, error: impl fmt::Display) -> SecretAccessError { + SecretAccessError::Backend(format!("{context}: {error}")) + } + + async fn request( + &self, + method: reqwest::Method, + path: &str, + body: Option, + ) -> Result { + let mut request = self + .client + .request(method.clone(), format!("{}/v1/{path}", self.base_url)) + .header("X-Vault-Token", &self.token); + if let Some(body) = body { + request = request.json(&body); + } + request + .send() + .await + .map_err(|error| Self::err(format!("{method} {path}"), error)) + } + + async fn upsert_policy(&self, name: &str, policy_hcl: &str) -> Result<(), SecretAccessError> { + self.request( + reqwest::Method::PUT, + &format!("sys/policies/acl/{name}"), + Some(json!({ "policy": policy_hcl })), + ) + .await? + .error_for_status() + .map_err(|error| Self::err(format!("write policy {name}"), error))?; + Ok(()) + } + + async fn delete_policy(&self, name: &str) -> Result<(), SecretAccessError> { + let response = self + .request( + reqwest::Method::DELETE, + &format!("sys/policies/acl/{name}"), + None, + ) + .await?; + if !response.status().is_success() && response.status() != StatusCode::NOT_FOUND { + return Err(Self::err( + format!("delete policy {name}"), + response.status(), + )); + } + Ok(()) + } + + async fn read_group_policies( + &self, + group: &str, + ) -> Result>, SecretAccessError> { + let response = self + .request( + reqwest::Method::GET, + &format!("identity/group/name/{group}"), + None, + ) + .await?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + let body: serde_json::Value = response + .error_for_status() + .map_err(|error| Self::err(format!("read group {group}"), error))? + .json() + .await + .map_err(|error| Self::err(format!("parse group {group}"), error))?; + Ok(Some( + body["data"]["policies"] + .as_array() + .map(|policies| { + policies + .iter() + .filter_map(|policy| policy.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + )) + } + + async fn write_group_policies( + &self, + group: &str, + policies: &[String], + ) -> Result<(), SecretAccessError> { + self.request( + reqwest::Method::POST, + &format!("identity/group/name/{group}"), + Some(json!({ "type": "external", "policies": policies })), + ) + .await? + .error_for_status() + .map_err(|error| Self::err(format!("write group {group}"), error))?; + Ok(()) + } + + async fn attach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> { + match self.read_group_policies(group).await? { + Some(policies) if policies.iter().any(|existing| existing == policy) => {} + Some(mut policies) => { + policies.push(policy.to_string()); + self.write_group_policies(group, &policies).await?; + } + None => { + self.write_group_policies(group, &[policy.to_string()]) + .await?; + } + } + self.ensure_group_alias(group).await + } + + async fn detach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> { + if let Some(policies) = self.read_group_policies(group).await? + && policies.iter().any(|existing| existing == policy) + { + let remaining: Vec = policies + .into_iter() + .filter(|existing| existing != policy) + .collect(); + self.write_group_policies(group, &remaining).await?; + } + Ok(()) + } + + async fn ensure_group_alias(&self, group: &str) -> Result<(), SecretAccessError> { + let accessor = self.jwt_accessor().await?.to_string(); + let body: serde_json::Value = self + .request( + reqwest::Method::GET, + &format!("identity/group/name/{group}"), + None, + ) + .await? + .error_for_status() + .map_err(|error| Self::err(format!("read group {group}"), error))? + .json() + .await + .map_err(|error| Self::err(format!("parse group {group}"), error))?; + if !body["data"]["alias"]["id"] + .as_str() + .unwrap_or("") + .is_empty() + { + return Ok(()); + } + let canonical_id = body["data"]["id"] + .as_str() + .ok_or_else(|| Self::err("read group", format!("{group} missing data.id")))?; + let response = self + .request( + reqwest::Method::POST, + "identity/group-alias", + Some(json!({ + "name": group, + "mount_accessor": accessor, + "canonical_id": canonical_id, + })), + ) + .await?; + if response.status().is_success() { + return Ok(()); + } + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let body_lower = body.to_ascii_lowercase(); + if status != StatusCode::BAD_REQUEST + || !body_lower.contains("alias") + || !body_lower.contains("already") + { + return Err(Self::err( + format!("create group alias {group}"), + format!("{status}: {body}"), + )); + } + Ok(()) + } + + async fn jwt_accessor(&self) -> Result<&str, SecretAccessError> { + self.jwt_accessor + .get_or_try_init(|| async { + let body: serde_json::Value = self + .request(reqwest::Method::GET, "sys/auth", None) + .await? + .error_for_status() + .map_err(|error| Self::err("GET sys/auth", error))? + .json() + .await + .map_err(|error| Self::err("parse sys/auth", error))?; + // The HTTP API usually nests mounts under `data`, while some + // compatible responses expose them at the document root. + let mount_key = format!("{}/", self.jwt_mount); + body.get("data") + .and_then(|data| data.get(&mount_key)) + .or_else(|| body.get(&mount_key)) + .and_then(|mount| mount.get("accessor")) + .and_then(|accessor| accessor.as_str()) + .map(str::to_string) + .ok_or_else(|| { + Self::err( + "resolve jwt accessor", + format!("mount '{mount_key}' not found in sys/auth"), + ) + }) + }) + .await + .map(String::as_str) + } + + async fn list_groups(&self) -> Result, SecretAccessError> { + let response = self + .request(reqwest::Method::GET, "identity/group/name?list=true", None) + .await?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(vec![]); + } + let body: serde_json::Value = response + .error_for_status() + .map_err(|error| Self::err("list groups", error))? + .json() + .await + .map_err(|error| Self::err("parse group list", error))?; + Ok(body["data"]["keys"] + .as_array() + .map(|groups| { + groups + .iter() + .filter_map(|group| group.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_omits_token() { + let manager = OpenBaoPolicyManager::new( + "https://openbao.example".into(), + "super-secret-token".into(), + "jwt".into(), + ); + + assert!(!format!("{manager:?}").contains("super-secret-token")); + } +} diff --git a/harmony_zitadel_auth/Cargo.toml b/harmony_zitadel_auth/Cargo.toml index 7ff56ee2..abeb2506 100644 --- a/harmony_zitadel_auth/Cargo.toml +++ b/harmony_zitadel_auth/Cargo.toml @@ -28,8 +28,12 @@ tokio = { workspace = true, features = ["time"] } arc-swap = "1" time = "0.3" tracing = { workspace = true } +thiserror.workspace = true jsonwebtoken = "9" openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } axum = { version = "0.8", optional = true } axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true } + +[dev-dependencies] +httptest = "0.16" diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index af932922..767cb056 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; mod device_groups; pub mod jwks; pub mod login; +pub mod management; pub mod session; pub use config::{OperatorCookieKey, ZitadelAuthConfig}; diff --git a/harmony_zitadel_auth/src/management.rs b/harmony_zitadel_auth/src/management.rs new file mode 100644 index 00000000..010e7bf7 --- /dev/null +++ b/harmony_zitadel_auth/src/management.rs @@ -0,0 +1,775 @@ +use std::fmt; + +use base64::Engine; +use reqwest::{Method, StatusCode}; +use serde::Deserialize; +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum ManagementError { + #[error("failed to build Zitadel HTTP client: {0}")] + BuildClient(reqwest::Error), + #[error("Zitadel Management API request failed: {0}")] + Request(#[from] reqwest::Error), + #[error("invalid Zitadel Management API response: {0}")] + Json(#[from] serde_json::Error), + #[error("Zitadel {operation} returned {status}: {body}")] + Api { + operation: &'static str, + status: StatusCode, + body: String, + }, + #[error("Zitadel project '{0}' does not exist")] + ProjectNotFound(String), + #[error("Zitadel user '{username}' is {actual}, not {expected}")] + WrongUserKind { + username: String, + expected: UserKind, + actual: UserKind, + }, + #[error("Zitadel user '{0}' has no human or machine type")] + UnknownUserKind(String), + #[error("invalid base64 machine key: {0}")] + MachineKeyBase64(#[from] base64::DecodeError), + #[error("machine key is not UTF-8: {0}")] + MachineKeyUtf8(#[from] std::string::FromUtf8Error), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserKind { + Human, + Machine, +} + +impl fmt::Display for UserKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Human => "human", + Self::Machine => "machine", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Project { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct User { + pub id: String, + pub username: String, +} + +pub struct CreatedMachineKey { + pub json: String, +} + +#[derive(Clone)] +pub struct ManagementClient { + http: reqwest::Client, + base_url: String, + pat: String, + org_id: Option, + host_header: Option, +} + +impl fmt::Debug for ManagementClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ManagementClient") + .field("base_url", &self.base_url) + .field("org_id", &self.org_id) + .field("host_header", &self.host_header) + .finish_non_exhaustive() + } +} + +impl ManagementClient { + pub fn new( + base_url: impl Into, + pat: impl Into, + org_id: Option, + accept_invalid_certs: bool, + ) -> Result { + let http = reqwest::Client::builder() + .danger_accept_invalid_certs(accept_invalid_certs) + .build() + .map_err(ManagementError::BuildClient)?; + Ok(Self { + http, + base_url: base_url.into().trim_end_matches('/').to_string(), + pat: pat.into(), + org_id, + host_header: None, + }) + } + + pub fn with_host_header(mut self, host: impl Into) -> Self { + self.host_header = Some(host.into()); + self + } + + fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder { + let mut request = self + .http + .request(method, format!("{}{}", self.base_url, path)) + .bearer_auth(&self.pat); + if let Some(org_id) = &self.org_id { + request = request.header("x-zitadel-orgid", org_id); + } + if let Some(host) = &self.host_header { + request = request.header("host", host); + } + request + } + + async fn response( + &self, + operation: &'static str, + request: reqwest::RequestBuilder, + ) -> Result { + let response = request.send().await?; + let status = response.status(); + let body = response.text().await?; + if !status.is_success() { + return Err(ManagementError::Api { + operation, + status, + body, + }); + } + Ok(body) + } + + pub async fn project(&self, name: &str) -> Result { + let body = self + .response( + "project search", + self.request(Method::POST, "/management/v1/projects/_search") + .json(&json!({ + "queries": [{ "nameQuery": { + "name": name, + "method": "TEXT_QUERY_METHOD_EQUALS" + }}] + })), + ) + .await?; + serde_json::from_str::(&body)? + .result + .unwrap_or_default() + .into_iter() + .find(|project| project.name == name) + .map(Into::into) + .ok_or_else(|| ManagementError::ProjectNotFound(name.to_string())) + } + + pub async fn ensure_project_role( + &self, + project_id: &str, + key: &str, + display_name: &str, + group: Option<&str>, + ) -> Result<(), ManagementError> { + let path = format!("/management/v1/projects/{project_id}/roles/_search"); + let body = self + .response( + "role search", + self.request(Method::POST, &path).json(&json!({})), + ) + .await?; + if serde_json::from_str::(&body)? + .result + .unwrap_or_default() + .iter() + .any(|role| role.key == key) + { + return Ok(()); + } + + let mut payload = json!({ "roleKey": key, "displayName": display_name }); + if let Some(group) = group { + payload["group"] = group.into(); + } + let path = format!("/management/v1/projects/{project_id}/roles"); + self.response( + "role creation", + self.request(Method::POST, &path).json(&payload), + ) + .await?; + Ok(()) + } + + pub async fn find_human(&self, username: &str) -> Result, ManagementError> { + self.find_user(username, UserKind::Human).await + } + + pub async fn find_machine(&self, username: &str) -> Result, ManagementError> { + self.find_user(username, UserKind::Machine).await + } + + async fn find_user( + &self, + username: &str, + expected: UserKind, + ) -> Result, ManagementError> { + let body = self + .response( + "user search", + self.request(Method::POST, "/management/v1/users/_search") + .json(&json!({ "queries": [{ "userNameQuery": { + "userName": username, + "method": "TEXT_QUERY_METHOD_EQUALS" + }}] })), + ) + .await?; + let mut found = serde_json::from_str::(&body)? + .result + .unwrap_or_default() + .into_iter() + .find(|user| { + user.user_name.as_deref() == Some(username) + || user.preferred_login_name.as_deref() == Some(username) + }); + if found.is_none() { + let body = self + .response( + "user login search", + self.request(Method::POST, "/management/v1/users/_search") + .json(&json!({ "queries": [{ "loginNameQuery": { + "loginName": username, + "method": "TEXT_QUERY_METHOD_EQUALS" + }}] })), + ) + .await?; + found = serde_json::from_str::(&body)? + .result + .unwrap_or_default() + .into_iter() + .find(|user| { + user.user_name.as_deref() == Some(username) + || user.preferred_login_name.as_deref() == Some(username) + }); + } + let Some(found) = found else { + return Ok(None); + }; + let actual = match (found.human.is_some(), found.machine.is_some()) { + (true, false) => UserKind::Human, + (false, true) => UserKind::Machine, + _ => return Err(ManagementError::UnknownUserKind(username.to_string())), + }; + if actual != expected { + return Err(ManagementError::WrongUserKind { + username: username.to_string(), + expected, + actual, + }); + } + Ok(Some(User { + id: found.id, + username: username.to_string(), + })) + } + + pub async fn ensure_machine( + &self, + username: &str, + name: &str, + ) -> Result { + if let Some(user) = self.find_machine(username).await? { + return Ok(user); + } + self.create_machine(username, name).await + } + + pub async fn create_machine( + &self, + username: &str, + name: &str, + ) -> Result { + let body = self + .response( + "machine user creation", + self.request(Method::POST, "/management/v1/users/machine") + .json(&json!({ + "userName": username, + "name": name, + "description": "Provisioned by Harmony", + "accessTokenType": "ACCESS_TOKEN_TYPE_JWT" + })), + ) + .await?; + let created: UserCreateResponse = serde_json::from_str(&body)?; + Ok(User { + id: created.user_id, + username: username.to_string(), + }) + } + + pub async fn create_json_machine_key( + &self, + user_id: &str, + ) -> Result { + let path = format!("/management/v1/users/{user_id}/keys"); + let body = self + .response( + "machine key creation", + self.request(Method::POST, &path) + .json(&json!({ "type": "KEY_TYPE_JSON" })), + ) + .await?; + let key: MachineKeyResponse = serde_json::from_str(&body)?; + Ok(CreatedMachineKey { + json: String::from_utf8( + base64::engine::general_purpose::STANDARD.decode(key.key_details)?, + )?, + }) + } + + pub async fn delete_user(&self, user_id: &str) -> Result<(), ManagementError> { + let path = format!("/management/v1/users/{user_id}"); + self.response("user deletion", self.request(Method::DELETE, &path)) + .await?; + Ok(()) + } + + pub async fn ensure_project_role_grant( + &self, + user_id: &str, + project_id: &str, + role_keys: &[String], + ) -> Result { + let body = self + .response( + "user grant search", + self.request(Method::POST, "/management/v1/users/grants/_search") + .json(&json!({ "queries": [{ "userIdQuery": { "userId": user_id }}] })), + ) + .await?; + let grant = serde_json::from_str::(&body)? + .result + .unwrap_or_default() + .into_iter() + .find(|grant| grant.project_id == project_id); + + if let Some(grant) = grant { + let mut roles = grant.role_keys; + let original_role_count = roles.len(); + for role in role_keys { + if !roles.contains(role) { + roles.push(role.clone()); + } + } + if roles.len() != original_role_count { + let path = format!("/management/v1/users/{user_id}/grants/{}", grant.id); + self.response( + "user grant update", + self.request(Method::PUT, &path) + .json(&json!({ "roleKeys": roles })), + ) + .await?; + } + return Ok(grant.id); + } + + let path = format!("/management/v1/users/{user_id}/grants"); + let body = self + .response( + "user grant creation", + self.request(Method::POST, &path) + .json(&json!({ "projectId": project_id, "roleKeys": role_keys })), + ) + .await?; + Ok(serde_json::from_str::(&body)?.user_grant_id) + } + + pub async fn set_project_role_grant( + &self, + user_id: &str, + project_id: &str, + role_keys: &[String], + ) -> Result { + let body = self + .response( + "user grant search", + self.request(Method::POST, "/management/v1/users/grants/_search") + .json(&json!({ "queries": [{ "userIdQuery": { "userId": user_id }}] })), + ) + .await?; + if let Some(grant) = serde_json::from_str::(&body)? + .result + .unwrap_or_default() + .into_iter() + .find(|grant| grant.project_id == project_id) + { + let path = format!("/management/v1/users/{user_id}/grants/{}", grant.id); + let response = self + .request(Method::PUT, &path) + .json(&json!({ "roleKeys": role_keys })) + .send() + .await?; + let status = response.status(); + let body = response.text().await?; + if !status.is_success() && !is_no_changes(&body) { + return Err(ManagementError::Api { + operation: "user grant update", + status, + body, + }); + } + return Ok(grant.id); + } + + let path = format!("/management/v1/users/{user_id}/grants"); + let body = self + .response( + "user grant creation", + self.request(Method::POST, &path) + .json(&json!({ "projectId": project_id, "roleKeys": role_keys })), + ) + .await?; + Ok(serde_json::from_str::(&body)?.user_grant_id) + } + + pub async fn action_in_token_flow(&self, name: &str) -> Result { + let body = self + .response( + "action search", + self.request(Method::POST, "/management/v1/actions/_search") + .json(&json!({ "queries": [{ "actionNameQuery": { "name": name }}] })), + ) + .await?; + let Some(action) = serde_json::from_str::(&body)? + .result + .unwrap_or_default() + .into_iter() + .find(|action| action.name == name) + else { + return Ok(false); + }; + let flow: serde_json::Value = serde_json::from_str( + &self + .response( + "token flow read", + self.request(Method::GET, "/management/v1/flows/2"), + ) + .await?, + )?; + Ok(["4", "5"].into_iter().all(|trigger| { + flow["flow"]["triggerActions"] + .as_array() + .into_iter() + .flatten() + .filter(|entry| { + entry["triggerType"]["id"].as_str() == Some(trigger) + || entry["triggerType"].as_str() == Some(trigger) + }) + .flat_map(|entry| entry["actions"].as_array().into_iter().flatten()) + .any(|entry| entry["id"].as_str() == Some(action.id.as_str())) + })) + } +} + +#[derive(Deserialize)] +struct ProjectSearchResult { + result: Option>, +} + +#[derive(Deserialize)] +struct ProjectEntry { + id: String, + name: String, +} + +impl From for Project { + fn from(value: ProjectEntry) -> Self { + Self { + id: value.id, + name: value.name, + } + } +} + +#[derive(Deserialize)] +struct RoleSearchResult { + result: Option>, +} + +#[derive(Deserialize)] +struct RoleEntry { + key: String, +} + +#[derive(Deserialize)] +struct UserSearchResult { + result: Option>, +} + +#[derive(Deserialize)] +struct UserEntry { + id: String, + #[serde(rename = "userName")] + user_name: Option, + #[serde(rename = "preferredLoginName")] + preferred_login_name: Option, + human: Option, + machine: Option, +} + +#[derive(Deserialize)] +struct UserCreateResponse { + #[serde(rename = "userId")] + user_id: String, +} + +#[derive(Deserialize)] +struct MachineKeyResponse { + #[serde(rename = "keyDetails")] + key_details: String, +} + +fn is_no_changes(body: &str) -> bool { + body.contains("\"code\":9") && (body.contains("COMMAND-1m88i") || body.contains("No changes")) +} + +#[derive(Deserialize)] +struct UserGrantSearchResult { + result: Option>, +} + +#[derive(Deserialize)] +struct UserGrantEntry { + id: String, + #[serde(rename = "projectId")] + project_id: String, + #[serde(rename = "roleKeys", default)] + role_keys: Vec, +} + +#[derive(Deserialize)] +struct UserGrantCreateResponse { + #[serde(rename = "userGrantId")] + user_grant_id: String, +} + +#[derive(Deserialize)] +struct ActionSearchResult { + result: Option>, +} + +#[derive(Deserialize)] +struct ActionEntry { + id: String, + name: String, +} + +#[cfg(test)] +mod tests { + use httptest::{Expectation, Server, matchers::*, responders::*}; + use serde_json::json; + + use super::*; + + fn client(server: &Server) -> ManagementClient { + ManagementClient::new( + server.url_str(""), + "super-secret-pat", + Some("org-1".into()), + false, + ) + .unwrap() + } + + #[tokio::test] + async fn project_lookup_is_exact_and_sends_org_context() { + let server = Server::run(); + server.expect( + Expectation::matching(all_of![ + request::method_path("POST", "/management/v1/projects/_search"), + request::headers(contains(("x-zitadel-orgid", "org-1"))), + request::body(json_decoded(eq(json!({ + "queries": [{"nameQuery": { + "name": "fleet", + "method": "TEXT_QUERY_METHOD_EQUALS" + }}] + })))) + ]) + .respond_with(json_encoded(json!({ + "result": [ + {"id": "near", "name": "fleet-dev"}, + {"id": "exact", "name": "fleet"} + ] + }))), + ); + + assert_eq!(client(&server).project("fleet").await.unwrap().id, "exact"); + } + + #[tokio::test] + async fn machine_lookup_rejects_a_human_with_the_same_username() { + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("POST", "/management/v1/users/_search")) + .respond_with(json_encoded(json!({ + "result": [{"id": "human-1", "userName": "robot", "human": {}}] + }))), + ); + + assert!(matches!( + client(&server).find_machine("robot").await, + Err(ManagementError::WrongUserKind { + expected: UserKind::Machine, + actual: UserKind::Human, + .. + }) + )); + } + + #[tokio::test] + async fn create_machine_propagates_an_account_name_conflict() { + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path("POST", "/management/v1/users/machine")) + .respond_with(status_code(409)), + ); + + assert!(matches!( + client(&server).create_machine("robot", "Robot").await, + Err(ManagementError::Api { + status: StatusCode::CONFLICT, + .. + }) + )); + } + + #[tokio::test] + async fn machine_key_decodes_the_one_time_json_payload() { + let server = Server::run(); + server.expect( + Expectation::matching(all_of![ + request::method_path("POST", "/management/v1/users/user-1/keys"), + request::body(json_decoded(eq(json!({"type": "KEY_TYPE_JSON"})))) + ]) + .respond_with(json_encoded(json!({ + "keyId": "key-1", + "keyDetails": "eyJrZXkiOiJwcml2YXRlIn0=" + }))), + ); + + assert_eq!( + client(&server) + .create_json_machine_key("user-1") + .await + .unwrap() + .json, + r#"{"key":"private"}"# + ); + } + + #[tokio::test] + async fn grant_update_adds_roles_without_removing_existing_ones() { + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path( + "POST", + "/management/v1/users/grants/_search", + )) + .respond_with(json_encoded(json!({ + "result": [{ + "id": "grant-1", + "projectId": "project-1", + "roleKeys": ["existing"] + }] + }))), + ); + server.expect( + Expectation::matching(all_of![ + request::method_path("PUT", "/management/v1/users/user-1/grants/grant-1"), + request::body(json_decoded(eq(json!({ + "roleKeys": ["existing", "requested"] + })))) + ]) + .respond_with(status_code(200)), + ); + + let roles = vec!["requested".to_string()]; + assert_eq!( + client(&server) + .ensure_project_role_grant("user-1", "project-1", &roles) + .await + .unwrap(), + "grant-1" + ); + } + + #[tokio::test] + async fn set_grant_replaces_roles_for_declarative_contracts() { + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path( + "POST", + "/management/v1/users/grants/_search", + )) + .respond_with(json_encoded(json!({ + "result": [{ + "id": "grant-1", + "projectId": "project-1", + "roleKeys": ["stale"] + }] + }))), + ); + server.expect( + Expectation::matching(all_of![ + request::method_path("PUT", "/management/v1/users/user-1/grants/grant-1"), + request::body(json_decoded(eq(json!({ "roleKeys": ["declared"] })))) + ]) + .respond_with(status_code(200)), + ); + + assert_eq!( + client(&server) + .set_project_role_grant("user-1", "project-1", &["declared".to_string()]) + .await + .unwrap(), + "grant-1" + ); + } + + #[tokio::test] + async fn action_requires_both_token_flow_triggers() { + let server = Server::run(); + server.expect( + Expectation::matching(request::method_path( + "POST", + "/management/v1/actions/_search", + )) + .respond_with(json_encoded(json!({ + "result": [{"id": "action-1", "name": "harmonyGroupsClaim"}] + }))), + ); + server.expect( + Expectation::matching(request::method_path("GET", "/management/v1/flows/2")) + .respond_with(json_encoded(json!({ + "flow": {"triggerActions": [ + {"triggerType": {"id": "4"}, "actions": [{"id": "action-1"}]}, + {"triggerType": {"id": "5"}, "actions": [{"id": "action-1"}]} + ]} + }))), + ); + + assert!( + client(&server) + .action_in_token_flow("harmonyGroupsClaim") + .await + .unwrap() + ); + } + + #[test] + fn debug_does_not_expose_the_pat() { + let server = Server::run(); + let debug = format!("{:?}", client(&server)); + assert!(!debug.contains("super-secret-pat")); + } +} -- 2.39.5 From 0d157bd4da76971599a249365457b37c8ee0a18e Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 16:00:56 -0400 Subject: [PATCH 06/34] feat: reconcile tenant authorization --- harmony_auth/src/backend.rs | 456 +++++++++++++++++++++++++++++++++++- harmony_auth/src/lib.rs | 267 +++++++++++++++++++++ 2 files changed, 718 insertions(+), 5 deletions(-) diff --git a/harmony_auth/src/backend.rs b/harmony_auth/src/backend.rs index 831a0cb0..aa63a8cc 100644 --- a/harmony_auth/src/backend.rs +++ b/harmony_auth/src/backend.rs @@ -5,14 +5,19 @@ use std::{ use crate::{ Assignment, AssignmentPlan, AssignmentRequest, AuthError, AuthService, BackendConnection, - ConnectionStatus, Identity, IdentityAccess, IdentityFilter, IdentityKind, IdentityWithAccess, - ImportedAccess, JwtRole, OpenBaoPolicy, RemovalPlan, Scope, TenantSummary, + ConnectionStatus, DeployerCreateRequest, DeployerCreateResult, Identity, IdentityAccess, + IdentityFilter, IdentityKind, IdentityWithAccess, ImportedAccess, JwtRole, OpenBaoPolicy, + ProvisionStep, RemovalPlan, Scope, TenantAuthConfig, TenantCapability, TenantCreateRequest, + TenantCreateResult, TenantDefinition, TenantSummary, valid_slug, }; use async_trait::async_trait; use reqwest::{Client, Method, StatusCode}; use serde_json::{Map, Value, json}; use uuid::Uuid; +use harmony_secret::OpenBaoPolicyManager; +use harmony_zitadel_auth::management::ManagementClient; + pub struct BackendAuth { client: Client, zitadel_url: String, @@ -33,6 +38,12 @@ struct PolicyDetails { body: String, } +struct ValidatedTenantAuth { + zitadel: ManagementClient, + project: harmony_zitadel_auth::management::Project, + openbao_jwt_role: String, +} + impl BackendAuth { pub fn new( zitadel_url: String, @@ -57,6 +68,432 @@ impl BackendAuth { &self.zitadel_url } + pub async fn tenant_definition( + &self, + slug: &str, + ) -> Result, AuthError> { + let slug = Scope::new(slug, None)?.tenant; + self.json(&format!("harmony_auth/data/tenants/{slug}")) + .await? + .map(|data| serde_json::from_value(data["data"].clone()).map_err(backend)) + .transpose() + } + + async fn validate_tenant_auth( + &self, + config: &TenantAuthConfig, + completed: &mut impl FnMut(ProvisionStep) -> Result<(), AuthError>, + ) -> Result { + if config.project.trim().is_empty() || config.groups_action.trim().is_empty() { + return Err(AuthError::Invalid( + "cloud identity project and groups Action cannot be empty".into(), + )); + } + if !valid_slug(&config.openbao_kv_mount) || !valid_slug(&config.openbao_jwt_mount) { + return Err(AuthError::Invalid( + "secret-store mount names may contain lowercase letters, numbers, and dashes" + .into(), + )); + } + let zitadel = ManagementClient::new( + &self.zitadel_url, + &self.zitadel_pat, + config.zitadel_org_id.clone(), + false, + ) + .map_err(backend)?; + let project = zitadel.project(&config.project).await.map_err(backend)?; + completed(ProvisionStep::detail(format!( + "Found Zitadel project '{}' with Resource ID '{}'", + project.name, project.id + )))?; + if !zitadel + .action_in_token_flow(&config.groups_action) + .await + .map_err(backend)? + { + return Err(AuthError::Invalid(format!( + "Zitadel Action '{}' must be attached to the Complement Token flow at both Pre Userinfo creation and Pre access token creation", + config.groups_action + ))); + } + completed(ProvisionStep::detail(format!( + "Found Zitadel Action '{}' on Complement Token triggers Pre Userinfo creation and Pre access token creation", + config.groups_action + )))?; + let jwt_config = self + .json(&format!("auth/{}/config", config.openbao_jwt_mount)) + .await? + .ok_or_else(|| AuthError::Invalid("cloud secret login is not configured".into()))?; + if jwt_config["bound_issuer"] + .as_str() + .map(|issuer| issuer.trim_end_matches('/')) + != Some(self.zitadel_url.trim_end_matches('/')) + { + return Err(AuthError::Invalid( + "cloud identity issuer does not match the secret-store login configuration".into(), + )); + } + completed(ProvisionStep::detail(format!( + "OpenBao JWT mount '{}' trusts Zitadel issuer '{}'", + config.openbao_jwt_mount, self.zitadel_url + )))?; + let role_names = match config.openbao_jwt_role.as_deref() { + Some(role) if role.trim().is_empty() => { + return Err(AuthError::Invalid( + "secret login role cannot be empty".into(), + )); + } + Some(role) => vec![role.to_string()], + None => self.role_names(&config.openbao_jwt_mount).await?, + }; + let mut compatible = Vec::new(); + for name in role_names { + let Some(role) = self + .json(&format!("auth/{}/role/{name}", config.openbao_jwt_mount)) + .await? + else { + continue; + }; + let audiences = strings(&role["bound_audiences"]); + if role["groups_claim"].as_str() == Some("groups") + && audiences.len() == 1 + && audiences.first() == Some(&project.id) + { + compatible.push(name); + } + } + if compatible.is_empty() { + return Err(AuthError::Invalid(format!( + "no compatible OpenBao role found under auth/{}/role: expected groups_claim 'groups' and bound_audiences ['{}'] for Zitadel project '{}'", + config.openbao_jwt_mount, project.id, project.name + ))); + } + if compatible.len() > 1 { + return Err(AuthError::Invalid(format!( + "found {} compatible OpenBao roles under auth/{}/role; set OPENBAO_JWT_ROLE to select one", + compatible.len(), + config.openbao_jwt_mount + ))); + } + let openbao_jwt_role = compatible.pop().unwrap(); + completed(ProvisionStep::detail(format!( + "Found OpenBao JWT role '{}' with Zitadel project audience '{}', groups_claim 'groups', and auth mount '{}'", + openbao_jwt_role, project.id, config.openbao_jwt_mount + )))?; + Ok(ValidatedTenantAuth { + zitadel, + project, + openbao_jwt_role, + }) + } + + pub async fn create_tenant( + &self, + request: TenantCreateRequest, + ) -> Result { + self.create_tenant_with_progress(request, |_| Ok(())).await + } + + pub async fn create_tenant_with_progress( + &self, + request: TenantCreateRequest, + mut completed: impl FnMut(ProvisionStep) -> Result<(), AuthError>, + ) -> Result { + request.tenant.validate()?; + let auth = self + .validate_tenant_auth(&request.auth, &mut completed) + .await?; + let zitadel = &auth.zitadel; + let project = &auth.project; + completed(ProvisionStep::checkpoint( + "Tenant identity and secret-login baseline validated", + format!( + "Ensure Zitadel roles '{}:owner', '{}:deployer', and '{}:viewer' in project '{}' ({})", + request.tenant.slug, + request.tenant.slug, + request.tenant.slug, + project.name, + project.id + ), + ))?; + + for (index, capability) in TenantCapability::ALL.into_iter().enumerate() { + let key = capability.role(&request.tenant.slug); + zitadel + .ensure_project_role( + &project.id, + &key, + &format!("{} {}", request.tenant.slug, capability.name()), + None, + ) + .await + .map_err(backend)?; + let message = format!( + "Zitadel role '{}' is ready in project '{}' ({})", + key, project.name, project.id + ); + completed(if index + 1 == TenantCapability::ALL.len() { + let next = if request.tenant.owner_usernames.is_empty() { + format!( + "Create OpenBao owner and deployer policies for tenant '{}'", + request.tenant.slug + ) + } else { + format!( + "Grant Zitadel role '{}:owner' to {}", + request.tenant.slug, + request.tenant.owner_usernames.join(", ") + ) + }; + ProvisionStep::checkpoint(message, next) + } else { + ProvisionStep::detail(message) + })?; + } + for (index, username) in request.tenant.owner_usernames.iter().enumerate() { + let user = zitadel + .find_human(username) + .await + .map_err(backend)? + .ok_or_else(|| { + AuthError::Invalid(format!("human identity '{username}' not found")) + })?; + zitadel + .ensure_project_role_grant( + &user.id, + &project.id, + &[TenantCapability::Owner.role(&request.tenant.slug)], + ) + .await + .map_err(backend)?; + let message = format!( + "Granted Zitadel role '{}' to owner '{}' (user ID '{}')", + TenantCapability::Owner.role(&request.tenant.slug), + username, + user.id + ); + completed(if index + 1 == request.tenant.owner_usernames.len() { + ProvisionStep::checkpoint( + message, + format!( + "Create OpenBao owner and deployer policies for tenant '{}'", + request.tenant.slug + ), + ) + } else { + ProvisionStep::detail(message) + })?; + } + let policies = OpenBaoPolicyManager::new( + self.openbao_url.clone(), + self.openbao_token.clone(), + request.auth.openbao_jwt_mount.clone(), + ); + for (index, capability) in [TenantCapability::Owner, TenantCapability::Deployer] + .into_iter() + .enumerate() + { + let policy = capability.policy_name(&request.tenant.slug); + let group = capability.role(&request.tenant.slug); + policies + .reconcile_acl_policy( + &policy, + Some( + &capability + .openbao_policy(&request.tenant.slug, &request.auth.openbao_kv_mount), + ), + std::slice::from_ref(&group), + ) + .await + .map_err(backend)?; + let message = format!( + "OpenBao policy '{}' grants group '{}' access to '{}/data/{}/*' and '{}/metadata/{}/*'", + policy, + group, + request.auth.openbao_kv_mount, + request.tenant.slug, + request.auth.openbao_kv_mount, + request.tenant.slug + ); + completed(if index == 1 { + ProvisionStep::checkpoint( + message, + format!( + "Store the tenant definition at OpenBao path 'harmony_auth/data/tenants/{}'", + request.tenant.slug + ), + ) + } else { + ProvisionStep::detail(message) + })?; + } + + self.ensure_intent_mount().await?; + completed(ProvisionStep::detail( + "OpenBao tenant-state mount 'harmony_auth/' is ready", + ))?; + let changed = + self.tenant_definition(&request.tenant.slug).await?.as_ref() != Some(&request.tenant); + if changed { + self.openbao( + Method::POST, + &format!("harmony_auth/data/tenants/{}", request.tenant.slug), + Some(json!({ "data": request.tenant })), + ) + .await? + .error_for_status() + .map_err(backend)?; + } + completed(ProvisionStep::checkpoint( + format!( + "Tenant definition {} at OpenBao path 'harmony_auth/data/tenants/{}'", + if changed { "stored" } else { "already matches" }, + request.tenant.slug + ), + format!( + "Provision Kubernetes resources for namespace '{}'", + request.tenant.namespace + ), + ))?; + + Ok(TenantCreateResult { + tenant: request.tenant, + project_id: project.id.clone(), + openbao_jwt_role: auth.openbao_jwt_role, + }) + } + + async fn prepare_deployer( + &self, + request: &DeployerCreateRequest, + completed: &mut impl FnMut(ProvisionStep), + ) -> Result<(TenantDefinition, ValidatedTenantAuth), AuthError> { + let tenant = self + .tenant_definition(&request.tenant) + .await? + .ok_or_else(|| { + AuthError::Invalid(format!("tenant '{}' does not exist", request.tenant)) + })?; + completed(ProvisionStep::detail(format!( + "Found tenant '{}' with namespace '{}'", + tenant.slug, tenant.namespace + ))); + if request.username.trim().is_empty() || request.display_name.trim().is_empty() { + return Err(AuthError::Invalid( + "deployer account and display name cannot be empty".into(), + )); + } + let auth = { + let mut report = |step| { + completed(step); + Ok(()) + }; + self.validate_tenant_auth(&request.auth, &mut report) + .await? + }; + if auth + .zitadel + .find_machine(&request.username) + .await + .map_err(backend)? + .is_some() + { + return Err(AuthError::Invalid(format!( + "deployer account '{}' already exists; no access was changed", + request.username + ))); + } + completed(ProvisionStep::checkpoint( + format!( + "Zitadel deployer account name '{}' is available", + request.username + ), + format!( + "Create Zitadel machine account '{}' and grant role '{}:deployer'", + request.username, tenant.slug + ), + )); + Ok((tenant, auth)) + } + + pub async fn plan_deployer(&self, request: &DeployerCreateRequest) -> Result<(), AuthError> { + self.prepare_deployer(request, &mut |_| {}) + .await + .map(|_| ()) + } + + pub async fn create_deployer_with_progress( + &self, + request: DeployerCreateRequest, + mut completed: impl FnMut(ProvisionStep), + ) -> Result { + let (tenant, auth) = self.prepare_deployer(&request, &mut completed).await?; + let user = auth + .zitadel + .create_machine(&request.username, &request.display_name) + .await + .map_err(backend)?; + completed(ProvisionStep::detail(format!( + "Created Zitadel machine account '{}' with user ID '{}'", + request.username, user.id + ))); + let deployer_role = TenantCapability::Deployer.role(&tenant.slug); + if let Err(error) = auth + .zitadel + .ensure_project_role_grant( + &user.id, + &auth.project.id, + std::slice::from_ref(&deployer_role), + ) + .await + { + if let Err(rollback) = auth.zitadel.delete_user(&user.id).await { + return Err(AuthError::Backend(format!( + "{error}; deleting the partially created deployer also failed: {rollback}" + ))); + } + return Err(backend(error)); + } + completed(ProvisionStep::detail(format!( + "Granted Zitadel role '{}' to machine account '{}' in project '{}' ({})", + deployer_role, request.username, auth.project.name, auth.project.id + ))); + let key = match auth.zitadel.create_json_machine_key(&user.id).await { + Ok(key) => key, + Err(error) => { + if let Err(rollback) = auth.zitadel.delete_user(&user.id).await { + return Err(AuthError::Backend(format!( + "{error}; deleting the partially created deployer also failed: {rollback}" + ))); + } + return Err(backend(error)); + } + }; + completed(ProvisionStep::detail(format!( + "Generated one-time Zitadel credentials for machine account '{}'", + request.username + ))); + Ok(DeployerCreateResult { + tenant: tenant.slug, + username: request.username, + user_id: user.id, + key_json: key.json, + }) + } + + pub async fn delete_machine_identity( + &self, + org_id: Option, + user_id: &str, + ) -> Result<(), AuthError> { + ManagementClient::new(&self.zitadel_url, &self.zitadel_pat, org_id, false) + .map_err(backend)? + .delete_user(user_id) + .await + .map_err(backend) + } + pub async fn validate(&self) -> Result<(), String> { let status = self.connection_status().await; match (status.zitadel.connected, status.openbao.connected) { @@ -151,16 +588,16 @@ impl BackendAuth { Ok(Some(body["data"].clone())) } - async fn role_names(&self) -> Result, AuthError> { + async fn role_names(&self, mount: &str) -> Result, AuthError> { Ok(self - .json("auth/jwt/role?list=true") + .json(&format!("auth/{mount}/role?list=true")) .await? .map_or_else(Vec::new, |data| strings(&data["keys"]))) } async fn roles(&self) -> Result, AuthError> { let mut roles = Vec::new(); - for name in self.role_names().await? { + for name in self.role_names("jwt").await? { if let Some(raw) = self.json(&format!("auth/jwt/role/{name}")).await? { let subject_id = role_subject(&raw); if !subject_id.is_empty() { @@ -245,6 +682,15 @@ impl BackendAuth { .await? .error_for_status() .map_err(backend)?; + } else { + let mount = &mounts["harmony_auth/"]; + if mount["type"].as_str() != Some("kv") + || mount["options"]["version"].as_str() != Some("2") + { + return Err(AuthError::Invalid( + "OpenBao mount 'harmony_auth/' must be KV v2".into(), + )); + } } Ok(()) } diff --git a/harmony_auth/src/lib.rs b/harmony_auth/src/lib.rs index 48d04f62..55350322 100644 --- a/harmony_auth/src/lib.rs +++ b/harmony_auth/src/lib.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use harmony_types::k8s_name::K8sName; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -153,6 +154,225 @@ pub struct TenantSummary { pub services: usize, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TenantCapability { + Owner, + Deployer, + Viewer, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProvisionStep { + message: String, + next_operation: Option, +} + +impl ProvisionStep { + pub(crate) fn detail(message: impl Into) -> Self { + Self { + message: message.into(), + next_operation: None, + } + } + + pub(crate) fn checkpoint( + message: impl Into, + next_operation: impl Into, + ) -> Self { + Self { + message: message.into(), + next_operation: Some(next_operation.into()), + } + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn next_operation(&self) -> Option<&str> { + self.next_operation.as_deref() + } +} + +impl TenantCapability { + pub const ALL: [Self; 3] = [Self::Owner, Self::Deployer, Self::Viewer]; + + pub fn name(self) -> &'static str { + match self { + Self::Owner => "owner", + Self::Deployer => "deployer", + Self::Viewer => "viewer", + } + } + + pub fn role(self, tenant: &str) -> String { + format!("{tenant}:{}", self.name()) + } + + pub fn policy_name(self, tenant: &str) -> String { + format!("harmony-{tenant}-{}", self.name()) + } + + pub fn openbao_policy(self, tenant: &str, mount: &str) -> String { + let capabilities = match self { + Self::Owner => "[\"create\", \"delete\", \"patch\", \"read\", \"update\"]", + Self::Deployer | Self::Viewer => "[\"read\"]", + }; + format!( + "path \"{mount}/data/{tenant}/*\" {{ capabilities = {capabilities} }}\n\ + path \"{mount}/metadata/{tenant}/*\" {{ capabilities = [\"list\", \"read\"] }}" + ) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TenantResources { + pub cpu_request_cores: f32, + pub cpu_limit_cores: f32, + pub memory_request_gb: f32, + pub memory_limit_gb: f32, + pub storage_total_gb: f32, + pub service_limit: u32, +} + +impl Default for TenantResources { + fn default() -> Self { + Self { + cpu_request_cores: 4.0, + cpu_limit_cores: 4.0, + memory_request_gb: 4.0, + memory_limit_gb: 4.0, + storage_total_gb: 20.0, + service_limit: 10, + } + } +} + +impl TenantResources { + pub fn validate(&self) -> Result<(), AuthError> { + let finite_positive = |value: f32| value.is_finite() && value > 0.0; + if !finite_positive(self.cpu_request_cores) + || !finite_positive(self.cpu_limit_cores) + || self.cpu_request_cores > self.cpu_limit_cores + { + return Err(AuthError::Invalid( + "CPU values must be finite and positive, with request <= limit".into(), + )); + } + if !finite_positive(self.memory_request_gb) + || !finite_positive(self.memory_limit_gb) + || self.memory_request_gb > self.memory_limit_gb + { + return Err(AuthError::Invalid( + "memory values must be finite and positive, with request <= limit".into(), + )); + } + if !finite_positive(self.storage_total_gb) || self.service_limit == 0 { + return Err(AuthError::Invalid( + "storage and service limits must be positive".into(), + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TenantDefinition { + pub id: String, + pub slug: String, + pub namespace: String, + pub resources: TenantResources, + #[serde(default)] + pub owner_usernames: Vec, +} + +impl TenantDefinition { + pub fn new( + id: impl Into, + slug: &str, + namespace: impl Into, + resources: TenantResources, + ) -> Result { + let slug = Scope::new(slug, None)?.tenant; + let id = id.into(); + let namespace = namespace.into(); + if id.trim().is_empty() || namespace.trim().is_empty() { + return Err(AuthError::Invalid( + "tenant ID and namespace cannot be empty".into(), + )); + } + namespace + .parse::() + .map_err(|error| AuthError::Invalid(format!("invalid tenant namespace: {error}")))?; + resources.validate()?; + Ok(Self { + id, + slug, + namespace, + resources, + owner_usernames: Vec::new(), + }) + } + + pub fn with_owner_usernames(mut self, usernames: impl IntoIterator) -> Self { + self.owner_usernames.extend(usernames); + self.owner_usernames.sort(); + self.owner_usernames.dedup(); + self + } + + pub fn validate(&self) -> Result<(), AuthError> { + if Scope::new(&self.slug, None)?.tenant != self.slug + || self.id.trim().is_empty() + || self.namespace.trim().is_empty() + { + return Err(AuthError::Invalid("invalid tenant definition".into())); + } + self.namespace + .parse::() + .map_err(|error| AuthError::Invalid(format!("invalid tenant namespace: {error}")))?; + self.resources.validate() + } +} + +#[derive(Clone, Debug)] +pub struct TenantCreateRequest { + pub tenant: TenantDefinition, + pub auth: TenantAuthConfig, +} + +#[derive(Clone, Debug)] +pub struct TenantAuthConfig { + pub project: String, + pub zitadel_org_id: Option, + pub groups_action: String, + pub openbao_kv_mount: String, + pub openbao_jwt_mount: String, + pub openbao_jwt_role: Option, +} + +pub struct TenantCreateResult { + pub tenant: TenantDefinition, + pub project_id: String, + pub openbao_jwt_role: String, +} + +#[derive(Clone, Debug)] +pub struct DeployerCreateRequest { + pub tenant: String, + pub username: String, + pub display_name: String, + pub auth: TenantAuthConfig, +} + +pub struct DeployerCreateResult { + pub tenant: String, + pub username: String, + pub user_id: String, + pub key_json: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct IdentityWithAccess { pub identity: Identity, @@ -383,4 +603,51 @@ mod tests { Err(AuthError::Invalid(_)) )); } + + #[test] + fn tenant_capabilities_follow_adr_027() { + assert_eq!(TenantCapability::Owner.role("acme"), "acme:owner"); + assert_eq!( + TenantCapability::Deployer.policy_name("acme"), + "harmony-acme-deployer" + ); + assert!( + TenantCapability::Owner + .openbao_policy("acme", "secret") + .contains("\"update\"") + ); + assert!( + !TenantCapability::Deployer + .openbao_policy("acme", "secret") + .contains("\"update\"") + ); + } + + #[test] + fn provision_steps_distinguish_details_from_checkpoints() { + let detail = ProvisionStep::detail("found project"); + let checkpoint = ProvisionStep::checkpoint("baseline ready", "create tenant roles"); + + assert_eq!(detail.message(), "found project"); + assert_eq!(detail.next_operation(), None); + assert_eq!(checkpoint.next_operation(), Some("create tenant roles")); + } + + #[test] + fn tenant_definition_normalizes_slug_and_rejects_invalid_limits() { + let tenant = TenantDefinition::new( + "tenant-1", + " Acme ", + "acme-apps", + TenantResources::default(), + ) + .unwrap(); + assert_eq!(tenant.slug, "acme"); + + let invalid = TenantResources { + cpu_request_cores: f32::INFINITY, + ..TenantResources::default() + }; + assert!(TenantDefinition::new("tenant-1", "acme", "acme", invalid).is_err()); + } } -- 2.39.5 From 72edf6dd83d1b2a2c805e206ca307b010ef2c0c9 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 16:01:12 -0400 Subject: [PATCH 07/34] feat: provision verified tenant cluster access --- fleet/harmony-fleet-deploy/src/app.rs | 1 + harmony-k8s/src/client.rs | 100 ++++++++++++++++++- harmony/src/domain/topology/tenant/k8s.rs | 4 +- harmony/src/modules/tenant/credentials.rs | 112 +++++++++++++++++++--- harmony_app/src/app.rs | 44 ++++++--- harmony_app/src/context.rs | 1 + harmony_app/src/lib.rs | 8 +- harmony_app/src/tenant.rs | 111 +++++++++++++++------ harmony_config/src/lib.rs | 62 ++++++++++-- harmony_secret/src/store/openbao.rs | 4 +- 10 files changed, 378 insertions(+), 69 deletions(-) diff --git a/fleet/harmony-fleet-deploy/src/app.rs b/fleet/harmony-fleet-deploy/src/app.rs index c1924e2f..967a4f18 100644 --- a/fleet/harmony-fleet-deploy/src/app.rs +++ b/fleet/harmony-fleet-deploy/src/app.rs @@ -278,6 +278,7 @@ impl HarmonyApp for FleetTenantProvisionApp { let source = harmony_config::openbao_source( store.namespace.as_ref(), Some(store.url.to_string()), + None, Some(store.zitadel_url.to_string()), Some(store.zitadel_audience.to_string()), Some(store.role.to_string()), diff --git a/harmony-k8s/src/client.rs b/harmony-k8s/src/client.rs index d6dc4b70..018cb0c3 100644 --- a/harmony-k8s/src/client.rs +++ b/harmony-k8s/src/client.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use kube::config::{KubeConfigOptions, Kubeconfig}; use kube::{Client, Config, Discovery, Error}; -use log::error; +use log::{error, info}; use serde::Serialize; use tokio::sync::{OnceCell, RwLock}; @@ -61,6 +61,22 @@ impl ClusterConnection { tls_verified: cluster.insecure_skip_tls_verify != Some(true), }) } + + fn from_config(name: String, config: &Config) -> Result { + let server = safe_endpoint(&config.cluster_url.to_string(), "cluster server", true)?; + let proxy_url = config + .proxy_url + .as_ref() + .map(|url| safe_endpoint(&url.to_string(), "cluster proxy", false)) + .transpose()?; + Ok(Self { + name, + server, + tls_server_name: config.tls_server_name.clone(), + proxy_url, + tls_verified: !config.accept_invalid_certs, + }) + } } fn safe_endpoint(endpoint: &str, name: &str, require_https: bool) -> Result { @@ -121,6 +137,21 @@ impl std::fmt::Debug for K8sClient { } impl K8sClient { + pub fn validate_kubeconfig_context( + path: &str, + context: String, + ) -> Result { + let kubeconfig = Kubeconfig::read_from(path) + .map_err(|error| format!("failed to load kubeconfig from {path}: {error}"))?; + ClusterConnection::from_kubeconfig( + &kubeconfig, + &KubeConfigOptions { + context: Some(context), + ..Default::default() + }, + ) + } + /// Create a client, reading `DRY_RUN` from the environment. pub fn new(client: Client) -> Self { Self { @@ -174,7 +205,13 @@ impl K8sClient { return None; } }; - let connection = ClusterConnection::from_kubeconfig(&k, opts) + let context = opts + .context + .clone() + .or_else(|| k.current_context.clone()) + .unwrap_or_default(); + let cluster_name = ClusterConnection::from_kubeconfig(&k, opts) + .map(|connection| connection.name) .map_err(|error| error!("Tenant credentials unavailable for {path}: {error}")) .ok(); let config = match Config::from_custom_kubeconfig(k, opts).await { @@ -184,6 +221,17 @@ impl K8sClient { return None; } }; + let connection = cluster_name.and_then(|name| { + ClusterConnection::from_config(name, &config) + .map_err(|error| error!("Tenant credentials unavailable for {path}: {error}")) + .ok() + }); + if let Some(connection) = &connection { + info!( + "Loaded Kubernetes context '{context}' for cluster '{}' at '{}'", + connection.name, connection.server + ); + } let client = match Client::try_from(config) { Ok(client) => client, Err(error) => { @@ -236,6 +284,54 @@ users: ); } + #[tokio::test] + async fn resolved_connection_uses_explicit_context_in_multi_cluster_config() { + let kubeconfig: Kubeconfig = serde_yaml::from_str( + r#" +current-context: cluster-a-admin +contexts: + - name: cluster-a-admin + context: { cluster: cluster-a, user: cluster-a-admin } + - name: cluster-b-admin + context: { cluster: cluster-b, user: cluster-b-admin } +clusters: + - name: cluster-a + cluster: { server: "https://192.0.2.10:6443" } + - name: cluster-b + cluster: + server: https://api.cluster-b.example.com:6443 + tls-server-name: api.cluster-b.example.com +users: + - name: cluster-a-admin + user: { token: cluster-a-secret } + - name: cluster-b-admin + user: { token: cluster-b-secret } +"#, + ) + .unwrap(); + let options = KubeConfigOptions { + context: Some("cluster-b-admin".into()), + ..Default::default() + }; + let name = ClusterConnection::from_kubeconfig(&kubeconfig, &options) + .unwrap() + .name; + let config = Config::from_custom_kubeconfig(kubeconfig, &options) + .await + .unwrap(); + + assert_eq!( + ClusterConnection::from_config(name, &config).unwrap(), + ClusterConnection { + name: "cluster-b".into(), + server: "https://api.cluster-b.example.com:6443/".into(), + tls_server_name: Some("api.cluster-b.example.com".into()), + proxy_url: None, + tls_verified: true, + } + ); + } + #[test] fn connection_records_insecure_tls() { let kubeconfig: Kubeconfig = serde_yaml::from_str( diff --git a/harmony/src/domain/topology/tenant/k8s.rs b/harmony/src/domain/topology/tenant/k8s.rs index 65c2cd8b..44b5ce7b 100644 --- a/harmony/src/domain/topology/tenant/k8s.rs +++ b/harmony/src/domain/topology/tenant/k8s.rs @@ -114,9 +114,9 @@ impl K8sTenantManager { }, "spec": { "hard": { - "limits.cpu": format!("{:.0}",config.resource_limits.cpu_limit_cores), + "limits.cpu": config.resource_limits.cpu_limit_cores.to_string(), "limits.memory": format!("{:.3}Gi", config.resource_limits.memory_limit_gb), - "requests.cpu": format!("{:.0}",config.resource_limits.cpu_request_cores), + "requests.cpu": config.resource_limits.cpu_request_cores.to_string(), "requests.memory": format!("{:.3}Gi", config.resource_limits.memory_request_gb), "requests.storage": format!("{:.3}Gi", config.resource_limits.storage_total_gb), "pods": "20", diff --git a/harmony/src/modules/tenant/credentials.rs b/harmony/src/modules/tenant/credentials.rs index 2dd20948..8971805d 100644 --- a/harmony/src/modules/tenant/credentials.rs +++ b/harmony/src/modules/tenant/credentials.rs @@ -4,14 +4,15 @@ use std::time::Duration; use async_trait::async_trait; use harmony_config::{Config, ConfigClient}; -use harmony_k8s::ClusterConnection; +use harmony_k8s::{ClusterConnection, K8sClient}; use harmony_types::id::Id; use harmony_types::k8s_name::K8sName; -use k8s_openapi::api::core::v1::{Secret, ServiceAccount}; +use k8s_openapi::api::core::v1::{Namespace, Secret, ServiceAccount}; use k8s_openapi::api::rbac::v1::{ ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleBinding, RoleRef, Subject, }; use kube::api::ObjectMeta; +use kube::config::{KubeConfigOptions, Kubeconfig}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -32,6 +33,7 @@ pub struct TenantCredentialScore { namespace: K8sName, name: K8sName, rules: Vec, + role_subjects: Vec, #[serde(skip)] store: Arc, allow_insecure_source: bool, @@ -59,11 +61,17 @@ impl TenantCredentialScore { namespace, name, rules, + role_subjects: Vec::new(), store, allow_insecure_source, } } + pub fn with_role_subjects(mut self, subjects: Vec) -> Self { + self.role_subjects = subjects; + self + } + fn service_account(&self) -> ServiceAccount { ServiceAccount { metadata: ObjectMeta { @@ -88,6 +96,8 @@ impl TenantCredentialScore { } fn role_binding(&self) -> RoleBinding { + let mut subjects = vec![self.subject()]; + subjects.extend(self.role_subjects.clone()); RoleBinding { metadata: ObjectMeta { name: Some(self.name.to_string()), @@ -99,7 +109,7 @@ impl TenantCredentialScore { kind: "Role".to_string(), name: self.name.to_string(), }, - subjects: Some(vec![self.subject()]), + subjects: Some(subjects), } } @@ -280,15 +290,50 @@ impl Interpret for TenantCredentialInterpret { &token, &certificate_authority_data, )?; + let generated_config = kube::Config::from_custom_kubeconfig( + serde_yaml::from_str::(&kubeconfig).map_err(|error| { + InterpretError::new(format!("parse generated tenant kubeconfig: {error}")) + })?, + &KubeConfigOptions::default(), + ) + .await + .map_err(|error| { + InterpretError::new(format!("load generated tenant kubeconfig: {error}")) + })?; + let generated_client = + K8sClient::new(kube::Client::try_from(generated_config).map_err(|error| { + InterpretError::new(format!("create generated tenant client: {error}")) + })?); + if generated_client + .get_resource::(namespace, None) + .await + .map_err(|error| { + InterpretError::new(format!("verify generated tenant credentials: {error}")) + })? + .is_none() + { + return Err(InterpretError::new(format!( + "generated tenant credentials cannot read namespace '{namespace}'" + ))); + } + let access = ClusterAccess { kubeconfig }; self.score .store - .set(&ClusterAccess { kubeconfig }) + .set(&access) .await .map_err(|error| InterpretError::new(format!("store tenant ClusterAccess: {error}")))?; + let stored: ClusterAccess = self.score.store.get().await.map_err(|error| { + InterpretError::new(format!("verify stored ClusterAccess: {error}")) + })?; + if stored.kubeconfig != access.kubeconfig { + return Err(InterpretError::new( + "stored ClusterAccess does not match the verified tenant credentials".to_string(), + )); + } Ok(Outcome::success(format!( - "tenant deployer access stored for namespace '{}'", - self.score.namespace + "tenant deployer access stored for namespace '{}' on cluster '{}' at '{}'", + self.score.namespace, connection.name, connection.server ))) } @@ -317,13 +362,16 @@ fn tenant_kubeconfig( certificate_authority_data: &str, ) -> Result { let context = format!("{user}@{}", connection.name); - let mut cluster = serde_json::Map::from_iter([ - ("server".to_string(), connection.server.clone().into()), - ( + let mut cluster = + serde_json::Map::from_iter([("server".to_string(), connection.server.clone().into())]); + if connection.tls_verified { + cluster.insert( "certificate-authority-data".to_string(), certificate_authority_data.into(), - ), - ]); + ); + } else { + cluster.insert("insecure-skip-tls-verify".to_string(), true.into()); + } if let Some(name) = &connection.tls_server_name { cluster.insert("tls-server-name".to_string(), name.clone().into()); } @@ -374,6 +422,27 @@ mod tests { assert!(!kubeconfig.contains("cluster-admin")); } + #[test] + fn kubeconfig_preserves_insecure_source_tls_mode() { + let kubeconfig = tenant_kubeconfig( + &ClusterConnection { + name: "lab".to_string(), + server: "https://192.0.2.10:6443".to_string(), + tls_server_name: None, + proxy_url: None, + tls_verified: false, + }, + "customer-fleet", + "fleet-deployer", + "tenant-token", + "unused-ca", + ) + .unwrap(); + + assert!(kubeconfig.contains("insecure-skip-tls-verify: true")); + assert!(!kubeconfig.contains("certificate-authority-data")); + } + #[test] fn score_serialization_excludes_config_destination() { let score = TenantCredentialScore::new( @@ -387,4 +456,25 @@ mod tests { let serialized = serde_json::to_string(&score).unwrap(); assert!(!serialized.contains("store")); } + + #[test] + fn additional_role_subjects_share_the_deployer_role() { + let score = TenantCredentialScore::new( + "customer-fleet".parse().unwrap(), + "fleet-deployer".parse().unwrap(), + Vec::new(), + Arc::new(ConfigClient::new(Vec::new())), + false, + ) + .with_role_subjects(vec![Subject { + api_group: Some("rbac.authorization.k8s.io".into()), + kind: "User".into(), + name: "alice@example.com".into(), + namespace: None, + }]); + + let subjects = score.role_binding().subjects.unwrap(); + assert_eq!(subjects.len(), 2); + assert_eq!(subjects[1].name, "alice@example.com"); + } } diff --git a/harmony_app/src/app.rs b/harmony_app/src/app.rs index 5e11007a..b751e5a3 100644 --- a/harmony_app/src/app.rs +++ b/harmony_app/src/app.rs @@ -137,7 +137,31 @@ pub async fn deploy_with_options( app.validate_deploy_images(&options.images)?; let images = options.images.clone(); let scores = app.scores_with_options(ctx, options).await?; - let to_run: Vec>> = scores.iter().map(|s| s.clone_box()).collect(); + let steps = interpret_scores(topology, scores).await?; + + Ok(DeployReport { + context: ctx.name().to_string(), + namespace: ctx.namespace().to_string(), + cluster: ctx.cluster_target().map(str::to_string), + tag: ctx.version().to_string(), + images, + steps, + }) +} + +pub async fn interpret_scores( + topology: T, + scores: Vec>>, +) -> Result, AppError> { + interpret_scores_with_progress(topology, scores, |_, _| Ok(())).await +} + +pub async fn interpret_scores_with_progress( + topology: T, + scores: Vec>>, + mut completed: impl FnMut(&StepOutcome, bool) -> Result<(), AppError>, +) -> Result, AppError> { + let to_run: Vec>> = scores.iter().map(|score| score.clone_box()).collect(); let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology); maestro.register_all(scores); @@ -147,7 +171,8 @@ pub async fn deploy_with_options( .map_err(|e| AppError::Deploy(format!("topology preparation failed: {e}")))?; let mut steps = Vec::new(); - for score in to_run { + let score_count = to_run.len(); + for (index, score) in to_run.into_iter().enumerate() { let name = score.name(); let outcome = maestro .interpret(score) @@ -162,19 +187,14 @@ pub async fn deploy_with_options( outcome.status, outcome.message ))); } - steps.push(StepOutcome { + let step = StepOutcome { name, message: outcome.message, - }); + }; + completed(&step, index + 1 < score_count)?; + steps.push(step); } - Ok(DeployReport { - context: ctx.name().to_string(), - namespace: ctx.namespace().to_string(), - cluster: ctx.cluster_target().map(str::to_string), - tag: ctx.version().to_string(), - images, - steps, - }) + Ok(steps) } /// Build + publish, then deploy (ADR-026 §4). diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index 6b1cc5b2..ec1d3a11 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -429,6 +429,7 @@ async fn build_config_sources( let source = harmony_config::openbao_source( access.namespace.as_ref(), Some(access.url.to_string()), + None, Some(access.zitadel_url.to_string()), Some(access.zitadel_audience.to_string()), Some(access.role.to_string()), diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index fb25f11b..69956c7b 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -32,7 +32,8 @@ pub mod tenant; pub use app::{ AppIdentity, DeployOptions, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, - WorkloadStatus, deploy, deploy_with_options, logs, ship, ship_with_options, status, + WorkloadStatus, deploy, deploy_with_options, interpret_scores, interpret_scores_with_progress, + logs, ship, ship_with_options, status, }; pub use application::{ Application, ApplicationValidationError, Command, Cpu, DatabaseRef, FileRef, HealthCheck, @@ -58,4 +59,7 @@ pub use publish::{ is_digest_pinned, }; pub use score::{ComposeAppScore, PublicEndpoint}; -pub use tenant::provision_application_tenant_with_kubeconfig; +pub use tenant::{ + provision_application_tenant_on_context_with_progress, + provision_application_tenant_with_kubeconfig, +}; diff --git a/harmony_app/src/tenant.rs b/harmony_app/src/tenant.rs index 17bd92ae..2ec7908f 100644 --- a/harmony_app/src/tenant.rs +++ b/harmony_app/src/tenant.rs @@ -4,15 +4,15 @@ use async_trait::async_trait; use harmony::{ modules::tenant::{TenantCredentialScore, TenantScore}, score::Score, - topology::{K8sAnywhereTopology, tenant::TenantConfig}, + topology::{K8sAnywhereConfig, K8sAnywhereTopology, tenant::TenantConfig}, }; use harmony_config::ConfigClient; use harmony_types::k8s_name::K8sName; -use k8s_openapi::api::rbac::v1::PolicyRule; +use k8s_openapi::api::rbac::v1::{PolicyRule, Subject}; use crate::{ AppContext, AppError, AppIdentity, Context, ContextSpec, HarmonyApp, ImageRefs, - OpenBaoClusterAccess, deploy, + OpenBaoClusterAccess, StepOutcome, deploy, interpret_scores_with_progress, }; struct ApplicationTenantProvisioner { @@ -21,6 +21,79 @@ struct ApplicationTenantProvisioner { allow_insecure_source: bool, } +pub async fn provision_application_tenant_on_context_with_progress( + kubeconfig: PathBuf, + kube_context: String, + tenant: TenantConfig, + credential_store: OpenBaoClusterAccess, + openbao_token: String, + owner_usernames: Vec, + completed: impl FnMut(&StepOutcome, bool) -> Result<(), AppError>, +) -> Result<(), AppError> { + let user_subjects = owner_usernames + .into_iter() + .map(|name| Subject { + api_group: Some("rbac.authorization.k8s.io".to_string()), + kind: "User".to_string(), + name, + namespace: None, + }) + .collect(); + // This operator-driven path preserves the selected session's TLS mode; the CLI warns when verification is disabled. + let scores = application_tenant_scores( + tenant, + &credential_store, + Some(openbao_token), + true, + user_subjects, + ) + .await?; + let topology = K8sAnywhereTopology::with_config(K8sAnywhereConfig::kubeconfig( + kubeconfig.to_string_lossy(), + Some(kube_context), + )); + interpret_scores_with_progress(topology, scores, completed).await?; + Ok(()) +} + +async fn application_tenant_scores( + tenant: TenantConfig, + credential_store: &OpenBaoClusterAccess, + openbao_token: Option, + allow_insecure_source: bool, + role_subjects: Vec, +) -> Result>>, AppError> { + let source = harmony_config::openbao_source( + credential_store.namespace.as_ref(), + Some(credential_store.url.to_string()), + openbao_token, + Some(credential_store.zitadel_url.to_string()), + Some(credential_store.zitadel_audience.to_string()), + Some(credential_store.role.to_string()), + ) + .await + .ok_or_else(|| AppError::Deploy("tenant credential store is unavailable".into()))?; + let namespace = tenant + .name + .parse::() + .map_err(|error| AppError::InvalidComposition(error.to_string()))?; + Ok(vec![ + Box::new(TenantScore { config: tenant }), + Box::new( + TenantCredentialScore::new( + namespace, + "harmony-deployer" + .parse() + .expect("static Kubernetes name is valid"), + application_deployer_rules(), + Arc::new(ConfigClient::new(vec![source])), + allow_insecure_source, + ) + .with_role_subjects(role_subjects), + ), + ]) +} + #[async_trait] impl HarmonyApp for ApplicationTenantProvisioner { fn identity(&self, _ctx: &AppContext) -> AppIdentity { @@ -35,34 +108,14 @@ impl HarmonyApp for ApplicationTenantProvisioner { _ctx: &AppContext, _images: &ImageRefs, ) -> Result>>, AppError> { - let source = harmony_config::openbao_source( - self.credential_store.namespace.as_ref(), - Some(self.credential_store.url.to_string()), - Some(self.credential_store.zitadel_url.to_string()), - Some(self.credential_store.zitadel_audience.to_string()), - Some(self.credential_store.role.to_string()), + application_tenant_scores( + self.tenant.clone(), + &self.credential_store, + None, + self.allow_insecure_source, + Vec::new(), ) .await - .ok_or_else(|| AppError::Deploy("tenant credential store is unavailable".into()))?; - let namespace = self - .tenant - .name - .parse::() - .map_err(|error| AppError::InvalidComposition(error.to_string()))?; - Ok(vec![ - Box::new(TenantScore { - config: self.tenant.clone(), - }), - Box::new(TenantCredentialScore::new( - namespace, - "harmony-deployer" - .parse() - .expect("static Kubernetes name is valid"), - application_deployer_rules(), - Arc::new(ConfigClient::new(vec![source])), - self.allow_insecure_source, - )), - ]) } } diff --git a/harmony_config/src/lib.rs b/harmony_config/src/lib.rs index e1462af8..a909d2ba 100644 --- a/harmony_config/src/lib.rs +++ b/harmony_config/src/lib.rs @@ -232,7 +232,7 @@ impl ConfigClient { /// Build an OpenBao-backed `StoreSource` purely from env — the default chain. async fn openbao_from_env(namespace: &str) -> Option> { - openbao_source(namespace, None, None, None, None).await + openbao_source(namespace, None, None, None, None, None).await } /// Build an OpenBao-backed `StoreSource`. Explicit arguments override their env @@ -243,6 +243,7 @@ async fn openbao_from_env(namespace: &str) -> Option> { pub async fn openbao_source( namespace: &str, openbao_url: Option, + openbao_token: Option, zitadel_sso_url: Option, zitadel_audience: Option, openbao_jwt_role: Option, @@ -260,6 +261,7 @@ pub async fn openbao_source( let sso_url = zitadel_sso_url.or_else(|| env("HARMONY_SSO_URL")); let jwt_role = openbao_jwt_role.or_else(|| env("OPENBAO_JWT_ROLE")); let jwt_auth_mount = env("OPENBAO_JWT_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string()); + let token = openbao_token.or_else(|| env("OPENBAO_TOKEN")); // Headless Zitadel-machine → OpenBao (JWT-bearer) rung: needs a machine // keyfile (path or inline JSON) plus the project-ID audience. Absent any @@ -286,12 +288,14 @@ pub async fn openbao_source( let kv_mount = env("OPENBAO_KV_MOUNT").unwrap_or_else(|| "secret".to_string()); let skip_tls = env("OPENBAO_SKIP_TLS").as_deref() == Some("true"); - let inline_machine_identity = zitadel_jwt_bearer.as_ref().is_some_and(|config| { - config - .key_json - .as_deref() - .is_some_and(|key| !key.trim().is_empty()) - }) && jwt_role.is_some(); + let inline_machine_identity = token.is_none() + && zitadel_jwt_bearer.as_ref().is_some_and(|config| { + config + .key_json + .as_deref() + .is_some_and(|key| !key.trim().is_empty()) + }) + && jwt_role.is_some(); let store = if inline_machine_identity { match (zitadel_jwt_bearer.as_ref(), jwt_role.as_deref()) { (Some(config), Some(role)) => { @@ -313,7 +317,7 @@ pub async fn openbao_source( kv_mount, auth_mount: env("OPENBAO_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string()), skip_tls, - token: env("OPENBAO_TOKEN"), + token, username: env("OPENBAO_USERNAME"), password: env("OPENBAO_PASSWORD"), zitadel_sso_url: sso_url, @@ -328,7 +332,7 @@ pub async fn openbao_source( match store { Ok(store) => Some(Arc::new(StoreSource::new(namespace.to_string(), store))), Err(e) => { - warn!("OpenBao unreachable ({e}); source omitted from chain"); + warn!("OpenBao source unavailable ({e}); source omitted from chain"); None } } @@ -1023,6 +1027,31 @@ mod tests { assert_eq!(parsed, config); } + #[cfg(unix)] + #[tokio::test] + async fn local_file_source_protects_secret_config() { + use std::os::unix::fs::PermissionsExt; + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let source = LocalFileSource::new(dir.path().to_path_buf()); + source + .set( + ConfigClass::Secret, + "Credentials", + &serde_json::json!({"token": "secret"}), + ) + .await + .unwrap(); + + let mode = std::fs::metadata(dir.path().join("Credentials.json")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + #[tokio::test] async fn test_sqlite_set_and_get() { use tempfile::NamedTempFile; @@ -1389,6 +1418,21 @@ mod tests { assert!(result.is_none()); } + #[tokio::test] + async fn explicit_openbao_token_builds_source_without_environment_auth() { + let source = openbao_source( + "tenant", + Some("https://explicit-token.invalid".into()), + Some("context-token".into()), + None, + None, + None, + ) + .await; + + assert!(source.is_some()); + } + #[tokio::test] async fn test_full_chain_with_prompt_source_falls_through_to_prompt() { use tempfile::NamedTempFile; diff --git a/harmony_secret/src/store/openbao.rs b/harmony_secret/src/store/openbao.rs index 6a06713e..e808860b 100644 --- a/harmony_secret/src/store/openbao.rs +++ b/harmony_secret/src/store/openbao.rs @@ -87,9 +87,9 @@ impl OpenbaoSecretStore { options.base_url ); - // 1. If token is provided via env var, use it directly + // 1. If a token is provided, use it directly. if let Some(t) = &options.token { - debug!("OPENBAO_STORE: Using token from environment variable"); + debug!("OPENBAO_STORE: Using supplied token"); return Self::with_token( &options.base_url, options.skip_tls, -- 2.39.5 From 79780ce40621addbfa83f0fdda657e71f8ab60e9 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 16:01:32 -0400 Subject: [PATCH 08/34] feat: add context-based tenant administration --- docs/guides/harmony-auth-cli.md | 180 +++- harmony_auth_cli/src/main.rs | 1217 ++++++++++++++++++++++- harmony_config/src/source/local_file.rs | 29 +- 3 files changed, 1371 insertions(+), 55 deletions(-) diff --git a/docs/guides/harmony-auth-cli.md b/docs/guides/harmony-auth-cli.md index cf68b690..3edf963d 100644 --- a/docs/guides/harmony-auth-cli.md +++ b/docs/guides/harmony-auth-cli.md @@ -1,12 +1,12 @@ # Harmony Auth CLI -> **Status: read-only commands implemented.** Mutation commands follow the -> ADR-027 group migration. +> **Status:** read-only inspection and ADR-027 tenant identity provisioning are +> implemented. Identity grant and revoke commands remain pending. -`harmony-auth` inspects and manages the relationship between Zitadel -identities and OpenBao access. It presents tenants, identities, and Harmony -permissions first. JWT roles, policy names, subject claims, and HCL remain -available through advanced output. +`harmony-auth` inspects access and provisions tenants across Zitadel, OpenBao, +and Kubernetes. It presents tenants, identities, and Harmony permissions first. +JWT roles, policy names, subject claims, and HCL remain available through +advanced output. The CLI is the preferred interface while the web UI matures. Both interfaces use the same `harmony_auth` operations and return the same effective access. @@ -33,43 +33,61 @@ shown as imported access. The CLI does not rename, rewrite, or hide them. ```text harmony-auth +├── context configure ├── connection check ├── identity list ├── identity show ├── tenant list +├── tenant create +├── tenant deployer create └── tenant show ``` There are no flat aliases. `harmony-auth list` is not valid. -## Connection and credentials +## Context and credentials -Every command except `--help` and `--version` requires: - -| Flag | Environment | Meaning | -|---|---|---| -| `--zitadel-url` | `ZITADEL_URL` | Zitadel base URL | -| `--openbao-url` | `OPENBAO_URL` | OpenBao base URL | -| none | `ZITADEL_PAT` | Zitadel service-account PAT | -| none | `OPENBAO_TOKEN` | Temporary OpenBao administrator token | - -Secrets are environment-only because command-line arguments remain in shell -history and may be visible in the process list. Secret values never appear in -help output, normal output, JSON, or logs. - -Example: +Every command selects one named environment through `--context` or +`HARMONY_CONTEXT`: ```sh -export ZITADEL_URL=https://sso.example.com -export ZITADEL_PAT=... -export OPENBAO_URL=https://secrets.example.com -export OPENBAO_TOKEN=... +harmony-auth connection check --context prod +``` +Configure the context once: + +```sh +harmony-auth context configure --context prod +``` + +The guided setup collects the complete context in one session: + +| Field | Meaning | +|---|---| +| `zitadel_url` | Zitadel issuer and Management API URL | +| `zitadel_pat` | Zitadel administrator PAT | +| `zitadel_project` | Existing Zitadel project that contains tenant roles | +| `openbao_url` | OpenBao API URL | +| `openbao_token` | OpenBao administrator token | +| `kubeconfig` | Administrator kubeconfig path | +| `kube_context` | Exact administrator kube context | + +Harmony Config stores the profile at +`/contexts//HarmonyAuthContext.json`. The file is mode +`0600`; PAT and token prompts are masked. Each answer is saved immediately, so +rerunning `context configure` after an interruption resumes at the first missing +field. Commands either load the complete profile or report the context as +unconfigured. They do not fail through a sequence of missing provider +environment variables. + +`HARMONY_CONTEXT` can set a shell's default context: + +```sh +export HARMONY_CONTEXT=prod harmony-auth connection check ``` -The CLI does not persist profiles or credentials. Browser profile storage and -session credential refresh remain web UI concerns. +Secret values never appear in help output, normal output, JSON, or logs. `connection check` attempts both backends even when one fails. It reports each status without printing provider response bodies: @@ -222,6 +240,102 @@ manual migration. ## Tenant commands +### Create a tenant + +`tenant create` configures owner access, secret access, resource limits, +network isolation, and namespace-scoped deployment credentials. It runs +`TenantScore` and `TenantCredentialScore` against an explicit administrator +kube context. + +```sh +harmony-auth tenant create acme \ + --context prod \ + --owner acme-admin +``` + +Missing resource limits are prompted with defaults. Each answer is saved +immediately in the selected context's tenant draft, so an interrupted or +plan-only run resumes without repeating completed prompts. Later plans print +the saved limits. Applied values are stored in the authoritative tenant +definition under `harmony_auth/data/tenants/`. Use flags such as +`--cpu-limit-cores` for unattended use. + +The selected Harmony context supplies the default kubeconfig path and kube +context. Command flags override those defaults for the current invocation; +local paths are not stored in the tenant definition. Owner usernames are bound +to the namespace deployer Role until OKD group claims are available. + +For an OKD OpenID provider that maps Zitadel's `preferred_username` to the +OpenShift username, values passed through `--owner` must be OpenShift usernames +such as `acme-admin`, not email addresses unless the username itself is an +email. + +The command only prints a plan unless `--apply` is set. Owner additions are +additive. Omitting an existing owner does not revoke access; identity revocation +remains a separate pending command. + +Tenant creation does not grant viewer access. The tenant's secret subtree also +contains namespace deployment credentials, so viewer access remains disabled +until those credentials are separated. + +The shared identity and secret-login baseline must exist before tenant +creation. Operators can override its defaults through hidden environment +configuration: + +| Environment | Default | Meaning | +|---|---|---| +| `ZITADEL_ORG_ID` | PAT organization | Zitadel organization containing the project | +| `HARMONY_GROUPS_ACTION` | `harmonyGroupsClaim` | Action that adds tenant roles to tokens | +| `OPENBAO_KV_MOUNT` | `secret` | Tenant secret mount | +| `OPENBAO_JWT_AUTH_MOUNT` | `jwt` | Zitadel-backed OpenBao auth mount | +| `OPENBAO_JWT_ROLE` | auto-discovered | Shared login role; set only when more than one compatible role exists | + +Use `--step-by-step` with `--apply` to pause after each completed component: + +```sh +harmony-auth tenant create acme \ + --context prod \ + --owner acme-admin \ + --apply \ + --step-by-step +``` + +The command pauses after baseline validation, tenant permissions, owner access, +secret access, stored tenant state, and between the Kubernetes tenant and +credential Scores. Each prompt names the operation it will run next, including +the target Zitadel project, OpenBao path, namespace, and kube context where +applicable. All completed operations are logged at `INFO`, including detail +between checkpoints. The administrator can test from another terminal before +approving the next operation. Declining stops safely; rerunning the same command +continues through idempotent operations. Interactive mode cannot be combined +with `--json` and requires a terminal. + +### Create a CI deployer + +CI deployer creation is separate from tenant creation: + +```sh +harmony-auth tenant deployer create acme acme-ci \ + --context prod \ + --credentials ./acme-ci-key.json +``` + +This prints a plan and verifies that the tenant exists, the shared baseline is +valid, and the account name is unused. Add `--apply` to create the account, +grant tenant deployer access, and write its one-time credentials: + +```sh +harmony-auth tenant deployer create acme acme-ci \ + --context prod \ + --credentials ./acme-ci-key.json \ + --apply +``` + +The credentials file is created with mode `0600` and is never overwritten. The +command fails without changing access if the account already exists. If writing +the credentials fails after account creation, it deletes the new account; the +error reports if that cleanup also fails. + ### List tenants ```sh @@ -275,10 +389,13 @@ The first release uses these result shapes: | Command | `result` fields | |---|---| +| `context configure` | `context`, `path` | | `connection check` | `zitadel: { connected }`, `openbao: { connected }` | | `identity list` | `identities: [{ identity, access }]` | | `identity show` | `identity`, `access` | | `tenant list` | `tenants: [{ scope, humans, services }]` | +| `tenant create` | `tenant`, `applied` | +| `tenant deployer create` | `tenant`, `account`, `credentials`, `applied` | | `tenant show` | `tenant`, `project`, `identities: [{ identity, access }]` | `identity` contains `subject_id`, `kind`, `display_name`, `login_name`, @@ -358,6 +475,7 @@ harmony_auth_cli ─┘ `harmony_auth_cli` owns: - Clap arguments and environment mapping +- named Harmony context profile loading - terminal and JSON rendering - binary exit codes and logging setup @@ -369,8 +487,12 @@ tenants, reconcile JWT roles, or implement permission rules. - Authorization discovers existing per-subject JWT roles as imported access. - Grant and revoke wait for Zitadel role and OpenBao external-group operations. -- The CLI does not create Zitadel identities. +- Tenant deployer creation can create a Zitadel machine identity; general + identity creation is not implemented. - The CLI does not provide a generic OpenBao policy editor. +- OKD group-to-RBAC mapping, read-only viewer RBAC, short-lived Kubernetes + credentials, and WireGuard identity linking remain deferred. - Tenant administrators are not yet authenticated as constrained actors; the supplied OpenBao token determines backend authority. -- The CLI does not store profiles or credentials. +- Context profiles are local mode-`0600` files; credential refresh and remote + profile synchronization are not implemented. diff --git a/harmony_auth_cli/src/main.rs b/harmony_auth_cli/src/main.rs index 0f783e4c..ced6b0b7 100644 --- a/harmony_auth_cli/src/main.rs +++ b/harmony_auth_cli/src/main.rs @@ -1,25 +1,74 @@ -use std::{env, process::ExitCode}; - -use clap::{Parser, Subcommand, ValueEnum}; -use harmony_auth::{ - AuthError, AuthService, BackendAuth, ConnectionStatus, IdentityAccess, IdentityFilter, - IdentityKind, IdentityWithAccess, Scope, TenantSummary, +use std::{ + fs::OpenOptions, io::IsTerminal, io::Write, os::unix::fs::OpenOptionsExt, path::PathBuf, + process::ExitCode, sync::Arc, }; + +use clap::{Args, Parser, Subcommand, ValueEnum}; +use harmony_app::{ + OpenBaoClusterAccess, ResourceLimits, TenantConfig, + provision_application_tenant_on_context_with_progress, +}; +use harmony_auth::{ + AuthError, AuthService, BackendAuth, ConnectionStatus, DeployerCreateRequest, + DeployerCreateResult, IdentityAccess, IdentityFilter, IdentityKind, IdentityWithAccess, + ProvisionStep, Scope, TenantAuthConfig, TenantCreateRequest, TenantCreateResult, + TenantDefinition, TenantResources, TenantSummary, +}; +use harmony_config::{ + Config, ConfigClass, ConfigClient, ConfigSource, FieldPrompter, FieldType, InquirePrompter, + LocalFileSource, +}; +use harmony_k8s::K8sClient; +use harmony_types::context::{ContextName, HttpUrl}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, json}; #[derive(Parser)] #[command( name = "harmony-auth", version, - about = "Inspect Harmony authorization" + about = "Manage Harmony access and tenants" )] struct Cli { - #[arg(long, env = "ZITADEL_URL", global = true)] - zitadel_url: Option, + /// Named Harmony environment to use. + #[arg(long, env = "HARMONY_CONTEXT", global = true)] + context: Option, - #[arg(long, env = "OPENBAO_URL", global = true)] - openbao_url: Option, + #[arg(long, env = "ZITADEL_ORG_ID", global = true, hide = true)] + zitadel_org_id: Option, + #[arg( + long, + env = "HARMONY_GROUPS_ACTION", + default_value = "harmonyGroupsClaim", + global = true, + hide = true + )] + groups_action: String, + + #[arg( + long, + env = "OPENBAO_KV_MOUNT", + default_value = "secret", + global = true, + hide = true + )] + openbao_kv_mount: String, + + #[arg( + long, + env = "OPENBAO_JWT_AUTH_MOUNT", + default_value = "jwt", + global = true, + hide = true + )] + openbao_jwt_mount: String, + + #[arg(long, env = "OPENBAO_JWT_ROLE", global = true, hide = true)] + openbao_jwt_role: Option, + + /// Emit the versioned machine-readable result. #[arg(long, global = true)] json: bool, @@ -29,6 +78,11 @@ struct Cli { #[derive(Subcommand)] enum Command { + /// Manage named Harmony environments. + Context { + #[command(subcommand)] + command: ContextCommand, + }, Connection { #[command(subcommand)] command: ConnectionCommand, @@ -43,6 +97,12 @@ enum Command { }, } +#[derive(Subcommand)] +enum ContextCommand { + /// Configure every connection and credential required by this context. + Configure, +} + #[derive(Subcommand)] enum ConnectionCommand { Check, @@ -68,6 +128,16 @@ enum IdentityCommand { #[derive(Subcommand)] enum TenantCommand { List, + #[command( + about = "Create an isolated application tenant", + long_about = "Create an isolated tenant with owner access, capacity limits, network isolation, and deployment credentials.\n\nWithout --apply, the command prints what the tenant will receive. During apply, progress names both the user-visible result and the backing system that enforces it.", + after_long_help = "ACCESS FLOW:\n Owner login -> tenant owner permission -> tenant secrets and namespace access\n CI deployer -> tenant deployer permission -> deployment secrets -> namespace credentials\n\nRun `harmony-auth tenant deployer create --help` to create a CI deployer after the tenant is ready. Provider connections, credentials, and the default cluster target come from the selected Harmony context." + )] + Create(Box), + Deployer { + #[command(subcommand)] + command: TenantDeployerCommand, + }, Show { tenant: String, #[arg(long)] @@ -75,6 +145,82 @@ enum TenantCommand { }, } +#[derive(Subcommand)] +enum TenantDeployerCommand { + #[command( + about = "Create a CI deployer account for a tenant", + long_about = "Create a CI account that can read the tenant's deployment secrets and use its namespace-scoped deployment credentials. The generated credentials are written once to a new 0600 file.", + after_long_help = "ACCESS FLOW:\n CI credentials -> cloud identity -> tenant deployer permission\n -> deployment secrets -> namespace-scoped cluster access\n\nThe command deliberately fails if the account already exists. Credential rotation and account deletion will be separate explicit operations." + )] + Create(Box), +} + +#[derive(Args)] +struct DeployerCreateArgs { + /// Tenant receiving the deployer account. + tenant: String, + /// New CI account name. + account: String, + /// Human-readable account name. Defaults to ` deployer`. + #[arg(long)] + display_name: Option, + /// New 0600 file receiving the one-time CI credentials. + #[arg(long)] + credentials: PathBuf, + /// Create the account. Without this flag no changes are made. + #[arg(long)] + apply: bool, +} + +#[derive(Args)] +struct TenantCreateArgs { + /// Tenant name used for access, secret paths, and the default namespace. + tenant: String, + /// Stable Harmony tenant ID. Defaults to the tenant slug. + #[arg(long)] + tenant_id: Option, + /// Kubernetes namespace implementing this tenant. Defaults to the tenant slug. + #[arg(long)] + namespace: Option, + /// Override the selected context's administrator kubeconfig. + #[arg(long, env = "KUBECONFIG")] + kubeconfig: Option, + /// Override the selected context's administrator kube context. + #[arg(long)] + kube_context: Option, + /// Guaranteed CPU requested across the tenant, in cores. + #[arg(long)] + cpu_request_cores: Option, + /// Maximum CPU available across the tenant, in cores. + #[arg(long)] + cpu_limit_cores: Option, + /// Guaranteed memory requested across the tenant, in GiB. + #[arg(long)] + memory_request_gb: Option, + /// Maximum memory available across the tenant, in GiB. + #[arg(long)] + memory_limit_gb: Option, + /// Maximum persistent storage requested across the tenant, in GiB. + #[arg(long)] + storage_total_gb: Option, + /// Maximum number of Kubernetes Services in the tenant. + #[arg(long)] + service_limit: Option, + /// Give an existing human owner access to this tenant. + /// + /// Owner receives read/write access to tenant secrets and deployment access + /// to the namespace. Use the current OpenShift username. Repeat for multiple + /// owners. + #[arg(long)] + owner: Vec, + /// Apply the plan. Without this flag no tenant resources are changed. + #[arg(long)] + apply: bool, + /// Pause after each component so the administrator can test before continuing. + #[arg(long, requires = "apply")] + step_by_step: bool, +} + #[derive(Clone, ValueEnum)] enum Kind { Human, @@ -82,17 +228,68 @@ enum Kind { } enum Output { + ContextConfigured(ContextConfiguredOutput), Connection(ConnectionStatus), IdentityList(Vec), IdentityShow(IdentityWithAccess, bool), TenantList(Vec), + TenantCreate(TenantCreateOutput), + DeployerCreate(DeployerCreateOutput), TenantShow(Scope, Vec), } +#[derive(Serialize)] +struct ContextConfiguredOutput { + context: String, + path: PathBuf, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Config)] +struct HarmonyAuthContext { + zitadel_url: String, + #[config(secret)] + zitadel_pat: String, + zitadel_project: String, + openbao_url: String, + // TODO: Replace persisted tokens with Zitadel browser login and an in-memory OpenBao session. + #[config(secret)] + openbao_token: String, + kubeconfig: String, + kube_context: String, +} + +#[derive(Serialize)] +struct TenantCreateOutput { + tenant: TenantDefinition, + context: String, + zitadel_project: String, + openbao_kv_mount: String, + openbao_jwt_role: Option, + applied: bool, +} + +#[derive(Serialize)] +struct DeployerCreateOutput { + tenant: String, + account: String, + credentials: PathBuf, + applied: bool, +} + +struct TenantResourceDraft<'a> { + values: serde_json::Map, + source: Option<&'a LocalFileSource>, +} + +const TENANT_RESOURCES_DRAFT_KEY: &str = "TenantResources"; + #[tokio::main] async fn main() -> ExitCode { tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) .with_writer(std::io::stderr) .init(); let cli = Cli::parse(); @@ -132,15 +329,38 @@ async fn main() -> ExitCode { } async fn run(cli: &Cli) -> Result { + let context_name = selected_context(cli)?; + if matches!( + &cli.command, + Command::Context { + command: ContextCommand::Configure + } + ) { + if !std::io::stdin().is_terminal() { + return Err(AuthError::Invalid( + "context configuration requires an interactive terminal".into(), + )); + } + let directory = context_directory(&context_name)?; + let source = LocalFileSource::new(directory.clone()); + configure_context(&source, &InquirePrompter, &context_name).await?; + let path = directory.join(format!("{}.json", HarmonyAuthContext::KEY)); + return Ok(Output::ContextConfigured(ContextConfiguredOutput { + context: context_name.to_string(), + path, + })); + } + let config = load_context(&context_name).await?; let auth = BackendAuth::new( - connection_value(cli.zitadel_url.as_ref(), "ZITADEL_URL")?, - credential("ZITADEL_PAT")?, - connection_value(cli.openbao_url.as_ref(), "OPENBAO_URL")?, - credential("OPENBAO_TOKEN")?, + config.zitadel_url.clone(), + config.zitadel_pat.clone(), + config.openbao_url.clone(), + config.openbao_token.clone(), ) .map_err(AuthError::Backend)?; match &cli.command { + Command::Context { .. } => unreachable!("context configuration returned before dispatch"), Command::Connection { command: ConnectionCommand::Check, } => Ok(Output::Connection(auth.connection_status().await)), @@ -177,6 +397,271 @@ async fn run(cli: &Cli) -> Result { Command::Tenant { command: TenantCommand::List, } => Ok(Output::TenantList(auth.tenants().await?)), + Command::Tenant { + command: TenantCommand::Create(args), + } => { + let TenantCreateArgs { + tenant, + tenant_id, + namespace, + kubeconfig, + kube_context, + cpu_request_cores, + cpu_limit_cores, + memory_request_gb, + memory_limit_gb, + storage_total_gb, + service_limit, + owner, + apply, + step_by_step, + } = args.as_ref(); + if *step_by_step && cli.json { + return Err(AuthError::Invalid( + "--step-by-step cannot be combined with --json".into(), + )); + } + if *step_by_step && !std::io::stdin().is_terminal() { + return Err(AuthError::Invalid( + "--step-by-step requires an interactive terminal".into(), + )); + } + let interactive = !cli.json && std::io::stdin().is_terminal(); + let stored = auth.tenant_definition(tenant).await?; + let stored_resources = stored.as_ref().map(|tenant| &tenant.resources); + let (draft_source, draft_path) = tenant_resource_source(&context_name, tenant)?; + let mut draft = TenantResourceDraft { + values: if stored_resources.is_none() { + load_resource_draft(&draft_source).await? + } else { + serde_json::Map::new() + }, + source: stored_resources.is_none().then_some(&draft_source), + }; + let defaults = TenantResources::default(); + let resources = TenantResources { + cpu_request_cores: resource_value( + cpu_request_cores.or(stored_resources.map(|value| value.cpu_request_cores)), + defaults.cpu_request_cores, + "cpu_request_cores", + "CPU request cores", + interactive, + &mut draft, + ) + .await?, + cpu_limit_cores: resource_value( + cpu_limit_cores.or(stored_resources.map(|value| value.cpu_limit_cores)), + defaults.cpu_limit_cores, + "cpu_limit_cores", + "CPU limit cores", + interactive, + &mut draft, + ) + .await?, + memory_request_gb: resource_value( + memory_request_gb.or(stored_resources.map(|value| value.memory_request_gb)), + defaults.memory_request_gb, + "memory_request_gb", + "Memory request GiB", + interactive, + &mut draft, + ) + .await?, + memory_limit_gb: resource_value( + memory_limit_gb.or(stored_resources.map(|value| value.memory_limit_gb)), + defaults.memory_limit_gb, + "memory_limit_gb", + "Memory limit GiB", + interactive, + &mut draft, + ) + .await?, + storage_total_gb: resource_value( + storage_total_gb.or(stored_resources.map(|value| value.storage_total_gb)), + defaults.storage_total_gb, + "storage_total_gb", + "Storage total GiB", + interactive, + &mut draft, + ) + .await?, + service_limit: resource_value( + service_limit.or(stored_resources.map(|value| value.service_limit)), + defaults.service_limit, + "service_limit", + "Service limit", + interactive, + &mut draft, + ) + .await?, + }; + let kubeconfig = required_value( + kubeconfig + .as_ref() + .map(|path| path.to_string_lossy().to_string()) + .or_else(|| Some(config.kubeconfig.clone())), + "Administrator kubeconfig path", + interactive, + )?; + let kube_context = required_value( + kube_context + .clone() + .or_else(|| Some(config.kube_context.clone())), + "Administrator kube context", + interactive, + )?; + let cluster = K8sClient::validate_kubeconfig_context(&kubeconfig, kube_context.clone()) + .map_err(AuthError::Invalid)?; + if !cluster.tls_verified { + tracing::warn!( + "administrator kube context '{kube_context}' selects cluster '{}' at '{}' with TLS verification disabled; generated tenant credentials will preserve insecure-skip-tls-verify", + cluster.name, + cluster.server + ); + } + let definition = TenantDefinition::new( + tenant_id + .clone() + .or_else(|| stored.as_ref().map(|tenant| tenant.id.clone())) + .unwrap_or_else(|| tenant.clone()), + tenant, + namespace + .clone() + .or_else(|| stored.as_ref().map(|tenant| tenant.namespace.clone())) + .unwrap_or_else(|| tenant.clone()), + resources, + )? + .with_owner_usernames( + stored + .as_ref() + .into_iter() + .flat_map(|tenant| tenant.owner_usernames.clone()) + .chain(owner.clone()), + ); + if !apply { + return Ok(Output::TenantCreate(TenantCreateOutput { + tenant: definition, + context: context_name.to_string(), + zitadel_project: config.zitadel_project.clone(), + openbao_kv_mount: cli.openbao_kv_mount.clone(), + openbao_jwt_role: None, + applied: false, + })); + } + + // TODO: Replace direct backend orchestration with composed tenant Scores; the CLI should only resolve inputs, invoke Scores, and render progress. + let request = TenantCreateRequest { + tenant: definition, + auth: tenant_auth_config(cli, &config), + }; + let result = auth + .create_tenant_with_progress(request, |step: ProvisionStep| { + report_provision_step(&step, *step_by_step) + }) + .await; + let TenantCreateResult { + tenant, + project_id, + openbao_jwt_role, + } = result?; + if stored_resources.is_none() { + let _ = tokio::fs::remove_file(draft_path).await; + } + let credential_operation = format!( + "Create deployer RBAC and service-account credentials in namespace '{}', then store ClusterAccess at OpenBao path '{}/data/{}/ClusterAccess'", + tenant.namespace, cli.openbao_kv_mount, tenant.slug + ); + provision_application_tenant_on_context_with_progress( + PathBuf::from(kubeconfig), + kube_context, + TenantConfig { + id: tenant.id.clone().into(), + name: tenant.namespace.clone(), + resource_limits: ResourceLimits { + cpu_request_cores: tenant.resources.cpu_request_cores, + cpu_limit_cores: tenant.resources.cpu_limit_cores, + memory_request_gb: tenant.resources.memory_request_gb, + memory_limit_gb: tenant.resources.memory_limit_gb, + storage_total_gb: tenant.resources.storage_total_gb, + service_limit: tenant.resources.service_limit, + }, + ..TenantConfig::default() + }, + OpenBaoClusterAccess { + namespace: tenant.slug.parse().map_err(|error| { + AuthError::Invalid(format!("invalid OpenBao namespace: {error}")) + })?, + url: config.openbao_url.parse().map_err(|error| { + AuthError::Invalid(format!("invalid OpenBao URL: {error}")) + })?, + role: openbao_jwt_role.clone().parse().map_err(|error| { + AuthError::Invalid(format!("invalid OpenBao role: {error}")) + })?, + zitadel_url: config.zitadel_url.parse().map_err(|error| { + AuthError::Invalid(format!("invalid Zitadel URL: {error}")) + })?, + zitadel_audience: project_id.parse().map_err(|error| { + AuthError::Invalid(format!("invalid Zitadel audience: {error}")) + })?, + }, + config.openbao_token.clone(), + tenant.owner_usernames.clone(), + |step, has_more| { + report_application_step( + step, + (*step_by_step && has_more).then_some(credential_operation.as_str()), + ) + .map_err(|error| harmony_app::AppError::Deploy(error.to_string())) + }, + ) + .await + .map_err(|error| AuthError::Backend(error.to_string()))?; + Ok(Output::TenantCreate(TenantCreateOutput { + tenant, + context: context_name.to_string(), + zitadel_project: config.zitadel_project.clone(), + openbao_kv_mount: cli.openbao_kv_mount.clone(), + openbao_jwt_role: Some(openbao_jwt_role), + applied: true, + })) + } + Command::Tenant { + command: + TenantCommand::Deployer { + command: TenantDeployerCommand::Create(args), + }, + } => { + let request = DeployerCreateRequest { + tenant: args.tenant.clone(), + username: args.account.clone(), + display_name: args + .display_name + .clone() + .unwrap_or_else(|| format!("{} deployer", args.account)), + auth: tenant_auth_config(cli, &config), + }; + auth.plan_deployer(&request).await?; + if !args.apply { + return Ok(Output::DeployerCreate(DeployerCreateOutput { + tenant: args.tenant.clone(), + account: args.account.clone(), + credentials: args.credentials.clone(), + applied: false, + })); + } + let result = auth + .create_deployer_with_progress(request, |step| { + tracing::info!("{}", step.message()); + }) + .await?; + write_deployer_credentials(&auth, cli, &args.credentials, &result).await?; + Ok(Output::DeployerCreate(DeployerCreateOutput { + tenant: result.tenant, + account: result.username, + credentials: args.credentials.clone(), + applied: true, + })) + } Command::Tenant { command: TenantCommand::Show { tenant, project }, } => { @@ -187,23 +672,225 @@ async fn run(cli: &Cli) -> Result { } } -fn credential(name: &str) -> Result { - env::var(name) - .ok() - .filter(|value| !value.is_empty()) - .ok_or_else(|| AuthError::Invalid(format!("{name} is required"))) +fn selected_context(cli: &Cli) -> Result { + cli.context + .as_deref() + .ok_or_else(|| AuthError::Invalid("--context or HARMONY_CONTEXT is required".into()))? + .parse::() + .map_err(|error| AuthError::Invalid(error.to_string())) } -fn connection_value(value: Option<&String>, name: &str) -> Result { - value - .filter(|value| !value.is_empty()) +fn context_client(context: &ContextName) -> Result<(ConfigClient, PathBuf), AuthError> { + let directory = context_directory(context)?; + let path = directory.join(format!("{}.json", HarmonyAuthContext::KEY)); + Ok(( + ConfigClient::new(vec![Arc::new(LocalFileSource::new(directory))]), + path, + )) +} + +fn context_directory(context: &ContextName) -> Result { + Ok(harmony_config::default_config_dir() + .ok_or_else(|| AuthError::Invalid("Harmony config directory is unavailable".into()))? + .join("contexts") + .join(context.as_ref())) +} + +async fn configure_context( + source: &LocalFileSource, + prompter: &dyn FieldPrompter, + context: &ContextName, +) -> Result { + let mut values = match source + .get(HarmonyAuthContext::CLASS, HarmonyAuthContext::KEY) + .await + .map_err(|error| AuthError::Invalid(error.to_string()))? + { + Some(Value::Object(values)) => values, + Some(_) => { + return Err(AuthError::Invalid(format!( + "context '{context}' profile must be a JSON object" + ))); + } + None => serde_json::Map::new(), + }; + let schema = schemars::schema_for!(HarmonyAuthContext); + let fields = schema + .schema + .object + .as_deref() + .expect("HarmonyAuthContext is an object schema") + .properties + .keys() .cloned() - .ok_or_else(|| AuthError::Invalid(format!("{name} is required"))) + .collect::>(); + for field in fields { + if values + .get(&field) + .and_then(Value::as_str) + .is_some_and(|value| validate_context_field(&field, value).is_ok()) + { + continue; + } + let value = prompter + .prompt( + HarmonyAuthContext::KEY, + &field, + FieldType::String, + HarmonyAuthContext::SECRET_FIELDS.contains(&field.as_str()), + ) + .map_err(|error| AuthError::Invalid(error.to_string()))?; + let value = value.as_str().ok_or_else(|| { + AuthError::Invalid(format!("context field '{field}' must be a string")) + })?; + validate_context_field(&field, value)?; + values.insert(field, Value::String(value.to_string())); + source + .set( + HarmonyAuthContext::CLASS, + HarmonyAuthContext::KEY, + &Value::Object(values.clone()), + ) + .await + .map_err(|error| AuthError::Invalid(error.to_string()))?; + } + let config: HarmonyAuthContext = serde_json::from_value(Value::Object(values)) + .map_err(|error| AuthError::Invalid(format!("loading context '{context}': {error}")))?; + config.validate(context)?; + Ok(config) +} + +fn validate_context_field(field: &str, value: &str) -> Result<(), AuthError> { + if value.trim().is_empty() { + return Err(AuthError::Invalid(format!( + "context field '{field}' cannot be empty" + ))); + } + if matches!(field, "zitadel_url" | "openbao_url") { + value + .parse::() + .map_err(|error| AuthError::Invalid(format!("{field}: {error}")))?; + } + Ok(()) +} + +async fn load_context(context: &ContextName) -> Result { + let (client, path) = context_client(context)?; + let config = client + .get::() + .await + .map_err(|error| match error { + harmony_config::ConfigError::NotFound { .. } => AuthError::Invalid(format!( + "context '{context}' is not configured; run `harmony-auth context configure --context {context}` (profile: {})", + path.display() + )), + error => AuthError::Invalid(format!("loading context '{context}': {error}")), + })?; + config.validate(context)?; + Ok(config) +} + +impl HarmonyAuthContext { + fn validate(&self, context: &ContextName) -> Result<(), AuthError> { + let mut invalid = [ + ("zitadel_url", &self.zitadel_url), + ("zitadel_pat", &self.zitadel_pat), + ("zitadel_project", &self.zitadel_project), + ("openbao_url", &self.openbao_url), + ("openbao_token", &self.openbao_token), + ("kubeconfig", &self.kubeconfig), + ("kube_context", &self.kube_context), + ] + .into_iter() + .filter(|(_, value)| value.trim().is_empty()) + .map(|(name, _)| format!("{name} is empty")) + .collect::>(); + for (name, value) in [ + ("zitadel_url", &self.zitadel_url), + ("openbao_url", &self.openbao_url), + ] { + if !value.trim().is_empty() + && let Err(error) = value.parse::() + { + invalid.push(format!("{name}: {error}")); + } + } + if invalid.is_empty() { + Ok(()) + } else { + Err(AuthError::Invalid(format!( + "context '{context}' has invalid fields: {}", + invalid.join("; ") + ))) + } + } +} + +fn tenant_auth_config(cli: &Cli, context: &HarmonyAuthContext) -> TenantAuthConfig { + TenantAuthConfig { + project: context.zitadel_project.clone(), + zitadel_org_id: cli.zitadel_org_id.clone(), + groups_action: cli.groups_action.clone(), + openbao_kv_mount: cli.openbao_kv_mount.clone(), + openbao_jwt_mount: cli.openbao_jwt_mount.clone(), + openbao_jwt_role: cli.openbao_jwt_role.clone(), + } +} + +async fn write_deployer_credentials( + auth: &BackendAuth, + cli: &Cli, + path: &PathBuf, + result: &DeployerCreateResult, +) -> Result<(), AuthError> { + let mut file = match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + { + Ok(file) => file, + Err(error) => { + let rollback = auth + .delete_machine_identity(cli.zitadel_org_id.clone(), &result.user_id) + .await; + return Err(AuthError::Invalid(match rollback { + Ok(()) => format!( + "cannot create credentials file '{}'; new deployer account deleted: {error}", + path.display() + ), + Err(rollback) => format!( + "cannot create credentials file '{}' and new deployer account deletion failed ({rollback}): {error}", + path.display() + ), + })); + } + }; + if let Err(error) = file + .write_all(result.key_json.as_bytes()) + .and_then(|()| file.sync_all()) + { + let rollback = auth + .delete_machine_identity(cli.zitadel_org_id.clone(), &result.user_id) + .await; + drop(file); + let _ = std::fs::remove_file(path); + return Err(AuthError::Invalid(match rollback { + Ok(()) => format!( + "writing deployer credentials failed; new deployer account deleted: {error}" + ), + Err(rollback) => format!( + "writing deployer credentials failed and new deployer account deletion failed ({rollback}): {error}" + ), + })); + } + Ok(()) } impl Command { fn name(&self) -> &'static str { match self { + Self::Context { .. } => "context.configure", Self::Connection { .. } => "connection.check", Self::Identity { command: IdentityCommand::List { .. }, @@ -214,6 +901,12 @@ impl Command { Self::Tenant { command: TenantCommand::List, } => "tenant.list", + Self::Tenant { + command: TenantCommand::Create(_), + } => "tenant.create", + Self::Tenant { + command: TenantCommand::Deployer { .. }, + } => "tenant.deployer.create", Self::Tenant { command: TenantCommand::Show { .. }, } => "tenant.show", @@ -246,22 +939,28 @@ impl Output { fn command(&self) -> &'static str { match self { + Self::ContextConfigured(_) => "context.configure", Self::Connection(_) => "connection.check", Self::IdentityList(_) => "identity.list", Self::IdentityShow(_, _) => "identity.show", Self::TenantList(_) => "tenant.list", + Self::TenantCreate(_) => "tenant.create", + Self::DeployerCreate(_) => "tenant.deployer.create", Self::TenantShow(_, _) => "tenant.show", } } fn value(&self) -> Value { match self { + Self::ContextConfigured(result) => json!(result), Self::Connection(status) => json!(status), Self::IdentityList(identities) => json!({ "identities": identities }), Self::IdentityShow(row, _) => { json!({ "identity": row.identity, "access": row.access }) } Self::TenantList(tenants) => json!({ "tenants": tenants }), + Self::TenantCreate(result) => json!(result), + Self::DeployerCreate(result) => json!(result), Self::TenantShow(scope, identities) => json!({ "tenant": scope.tenant, "project": scope.project, @@ -272,6 +971,10 @@ impl Output { fn print_human(&self) { match self { + Self::ContextConfigured(result) => { + println!("Context '{}' configured", result.context); + println!(" Profile: {}", result.path.display()); + } Self::Connection(status) => { println!("Zitadel {}", connection_label(status.zitadel.connected)); println!("OpenBao {}", connection_label(status.openbao.connected)); @@ -297,6 +1000,88 @@ impl Output { ); } } + Self::TenantCreate(result) => { + println!( + "Tenant {} ({})", + result.tenant.slug, result.tenant.namespace + ); + let resources = &result.tenant.resources; + println!( + " CPU: {} requested, {} limit", + resources.cpu_request_cores, resources.cpu_limit_cores + ); + println!( + " Memory: {} GiB requested, {} GiB limit", + resources.memory_request_gb, resources.memory_limit_gb + ); + println!(" Storage: {} GiB", resources.storage_total_gb); + println!(" Services: {}", resources.service_limit); + for owner in &result.tenant.owner_usernames { + println!(" Owner: {owner}"); + } + println!( + " {}", + if result.applied { + "Tenant access, capacity, network isolation, and deployment credentials applied" + } else { + "Plan only; pass --apply to create the tenant" + } + ); + if result.applied { + let tenant = &result.tenant.slug; + let role = result + .openbao_jwt_role + .as_deref() + .expect("applied tenant has an OpenBao role"); + println!(" Zitadel project: {}", result.zitadel_project); + println!(" Zitadel roles: {tenant}:owner, {tenant}:deployer, {tenant}:viewer"); + println!( + " OpenBao policies: harmony-{tenant}-owner, harmony-{tenant}-deployer" + ); + println!(" Tenant state: harmony_auth/data/tenants/{tenant}"); + println!( + " Cluster access: {}/data/{tenant}/ClusterAccess", + result.openbao_kv_mount + ); + println!(" OpenBao JWT role: {role}"); + println!(); + println!("CI setup:"); + println!(" Run:"); + println!(" harmony-auth tenant deployer create {tenant} {tenant}-ci \\"); + println!(" --context {} \\", result.context); + println!(" --credentials ./{tenant}-ci.json \\"); + println!(" --apply"); + println!( + " Set HARMONY_ZITADEL_KEY_JSON to the contents of ./{tenant}-ci.json." + ); + println!(" Configure the deploy context to use OpenBao JWT role '{role}'."); + println!( + " Access: read '{}/data/{tenant}/*'; deploy within namespace '{}'.", + result.openbao_kv_mount, result.tenant.namespace + ); + } + } + Self::DeployerCreate(result) => { + println!( + "CI deployer {} for tenant {}", + result.account, result.tenant + ); + println!(" Credentials: {}", result.credentials.display()); + println!( + " {}", + if result.applied { + "Account and tenant deployer access applied" + } else { + "Plan only; pass --apply to create the account" + } + ); + if result.applied { + println!( + " CI secret: HARMONY_ZITADEL_KEY_JSON (contents of {})", + result.credentials.display() + ); + } + } Self::TenantShow(scope, rows) => { println!("{}", scope.label()); if rows.is_empty() { @@ -315,6 +1100,129 @@ impl Output { } } +fn tenant_resource_source( + context: &ContextName, + tenant: &str, +) -> Result<(LocalFileSource, PathBuf), AuthError> { + let tenant = Scope::new(tenant, None)?.tenant; + let directory = context_directory(context)?.join("tenants").join(tenant); + let path = directory.join(format!("{TENANT_RESOURCES_DRAFT_KEY}.json")); + Ok((LocalFileSource::new(directory), path)) +} + +async fn load_resource_draft( + source: &LocalFileSource, +) -> Result, AuthError> { + match source + .get(ConfigClass::Standard, TENANT_RESOURCES_DRAFT_KEY) + .await + .map_err(|error| AuthError::Invalid(error.to_string()))? + { + Some(Value::Object(values)) => Ok(values), + Some(_) => Err(AuthError::Invalid( + "saved tenant resources must be a JSON object".into(), + )), + None => Ok(serde_json::Map::new()), + } +} + +async fn resource_value( + provided: Option, + default: T, + field: &str, + label: &str, + interactive: bool, + draft: &mut TenantResourceDraft<'_>, +) -> Result +where + T: Clone + std::fmt::Display + std::str::FromStr + Serialize + DeserializeOwned, + ::Err: std::fmt::Display, +{ + let value = if let Some(value) = provided { + value + } else if let Some(value) = draft.values.get(field) { + serde_json::from_value(value.clone()).map_err(|error| { + AuthError::Invalid(format!( + "saved tenant resource '{field}' is invalid: {error}" + )) + })? + } else if interactive { + inquire::CustomType::::new(label) + .with_default(default) + .prompt() + .map_err(|error| AuthError::Invalid(format!("prompting for {label}: {error}")))? + } else { + default + }; + if let Some(source) = draft.source { + draft.values.insert( + field.into(), + serde_json::to_value(&value).map_err(|error| { + AuthError::Invalid(format!("saving tenant resource '{field}': {error}")) + })?, + ); + source + .set( + ConfigClass::Standard, + TENANT_RESOURCES_DRAFT_KEY, + &Value::Object(draft.values.clone()), + ) + .await + .map_err(|error| AuthError::Invalid(error.to_string()))?; + } + Ok(value) +} + +fn required_value( + value: Option, + label: &str, + interactive: bool, +) -> Result { + if let Some(value) = value.filter(|value| !value.trim().is_empty()) { + return Ok(value); + } + if !interactive { + return Err(AuthError::Invalid(format!("{label} is required"))); + } + inquire::Text::new(label) + .prompt() + .map_err(|error| AuthError::Invalid(format!("prompting for {label}: {error}"))) +} + +fn report_provision_step(step: &ProvisionStep, step_by_step: bool) -> Result<(), AuthError> { + tracing::info!("{}", step.message()); + if step_by_step && let Some(next_operation) = step.next_operation() { + confirm_next_operation(next_operation)?; + } + Ok(()) +} + +fn report_application_step( + step: &harmony_app::StepOutcome, + next_operation: Option<&str>, +) -> Result<(), AuthError> { + tracing::info!("{}: {}", step.name, step.message); + if let Some(next_operation) = next_operation { + confirm_next_operation(next_operation)?; + } + Ok(()) +} + +fn confirm_next_operation(next_operation: &str) -> Result<(), AuthError> { + match inquire::Confirm::new(&format!("Proceed with: {next_operation}?")) + .with_default(true) + .prompt() + { + Ok(true) => Ok(()), + Ok(false) => Err(AuthError::Invalid( + "step-by-step provisioning stopped; rerun the command to continue".into(), + )), + Err(error) => Err(AuthError::Invalid(format!( + "reading step approval: {error}" + ))), + } +} + fn print_identity_list_item(row: &IdentityWithAccess) { println!( "{} {} {} {}", @@ -470,6 +1378,44 @@ fn error_kind(error: &AuthError) -> &'static str { #[cfg(test)] mod tests { use super::*; + use clap::CommandFactory; + + struct InterruptedContextPrompter; + + impl FieldPrompter for InterruptedContextPrompter { + fn prompt( + &self, + _key: &str, + field: &str, + _ty: FieldType, + _is_secret: bool, + ) -> Result { + match field { + "kube_context" => Ok(json!("prod-admin")), + "kubeconfig" => Ok(json!("/tmp/prod.kubeconfig")), + "openbao_token" => Ok(json!("token")), + "openbao_url" => Err(harmony_config::ConfigError::PromptError( + "login cancelled".into(), + )), + field => panic!("unexpected prompt for {field}"), + } + } + } + + struct ProjectPrompter; + + impl FieldPrompter for ProjectPrompter { + fn prompt( + &self, + _key: &str, + field: &str, + _ty: FieldType, + _is_secret: bool, + ) -> Result { + assert_eq!(field, "zitadel_project"); + Ok(json!("example-project")) + } + } #[test] fn parses_identity_first_command_tree_without_connection_values() { @@ -478,4 +1424,225 @@ mod tests { assert_eq!(cli.command.name(), "identity.list"); } + + #[test] + fn parses_context_configuration() { + let cli = + Cli::try_parse_from(["harmony-auth", "context", "configure", "--context", "prod"]) + .unwrap(); + + assert_eq!(cli.command.name(), "context.configure"); + assert_eq!(selected_context(&cli).unwrap().as_ref(), "prod"); + } + + #[test] + fn context_is_required_once_for_every_command() { + let cli = Cli::try_parse_from(["harmony-auth", "connection", "check"]).unwrap(); + + assert_eq!( + selected_context(&cli).unwrap_err().to_string(), + "invalid request: --context or HARMONY_CONTEXT is required" + ); + } + + #[test] + fn context_validation_reports_all_missing_fields() { + let context: ContextName = "prod".parse().unwrap(); + let error = HarmonyAuthContext { + zitadel_url: String::new(), + zitadel_pat: String::new(), + zitadel_project: String::new(), + openbao_url: String::new(), + openbao_token: String::new(), + kubeconfig: String::new(), + kube_context: String::new(), + } + .validate(&context) + .unwrap_err() + .to_string(); + + for field in [ + "zitadel_url", + "zitadel_pat", + "zitadel_project", + "openbao_url", + "openbao_token", + "kubeconfig", + "kube_context", + ] { + assert!(error.contains(field), "missing {field} in {error}"); + } + } + + #[test] + fn context_profile_marks_credentials_as_secret() { + assert_eq!( + HarmonyAuthContext::CLASS, + harmony_config::ConfigClass::Secret + ); + assert_eq!( + HarmonyAuthContext::SECRET_FIELDS, + &["zitadel_pat", "openbao_token"] + ); + } + + #[tokio::test] + async fn interrupted_context_configuration_keeps_completed_settings() { + let directory = tempfile::tempdir().unwrap(); + let source = LocalFileSource::new(directory.path().to_path_buf()); + let context: ContextName = "prod".parse().unwrap(); + + assert!( + configure_context(&source, &InterruptedContextPrompter, &context) + .await + .is_err() + ); + let saved = source + .get(HarmonyAuthContext::CLASS, HarmonyAuthContext::KEY) + .await + .unwrap() + .unwrap(); + + assert_eq!(saved["kube_context"], "prod-admin"); + assert_eq!(saved["kubeconfig"], "/tmp/prod.kubeconfig"); + assert_eq!(saved["openbao_token"], "token"); + assert!(saved.get("openbao_url").is_none()); + } + + #[tokio::test] + async fn existing_context_prompts_only_for_new_project_field() { + let directory = tempfile::tempdir().unwrap(); + let source = LocalFileSource::new(directory.path().to_path_buf()); + source + .set( + HarmonyAuthContext::CLASS, + HarmonyAuthContext::KEY, + &json!({ + "zitadel_url": "https://sso.example.com", + "zitadel_pat": "pat", + "openbao_url": "https://secrets.example.com", + "openbao_token": "token", + "kubeconfig": "/tmp/prod.kubeconfig", + "kube_context": "prod-admin" + }), + ) + .await + .unwrap(); + let context: ContextName = "prod".parse().unwrap(); + + let configured = configure_context(&source, &ProjectPrompter, &context) + .await + .unwrap(); + + assert_eq!(configured.zitadel_project, "example-project"); + } + + #[tokio::test] + async fn tenant_resource_draft_survives_between_prompts() { + let directory = tempfile::tempdir().unwrap(); + let source = LocalFileSource::new(directory.path().to_path_buf()); + let mut draft = TenantResourceDraft { + values: serde_json::Map::new(), + source: Some(&source), + }; + + resource_value( + Some(2.5_f32), + 4.0, + "cpu_request_cores", + "CPU request cores", + false, + &mut draft, + ) + .await + .unwrap(); + let mut resumed = TenantResourceDraft { + values: load_resource_draft(&source).await.unwrap(), + source: Some(&source), + }; + + assert_eq!( + resource_value( + None, + 4.0, + "cpu_request_cores", + "CPU request cores", + false, + &mut resumed, + ) + .await + .unwrap(), + 2.5 + ); + } + + #[test] + fn parses_tenant_create() { + let cli = Cli::try_parse_from([ + "harmony-auth", + "tenant", + "create", + "acme", + "--kubeconfig", + "/tmp/admin.kubeconfig", + "--kube-context", + "production-admin", + "--owner", + "alice@example.com", + ]) + .unwrap(); + + assert_eq!(cli.command.name(), "tenant.create"); + } + + #[test] + fn step_by_step_requires_apply() { + let parsed = Cli::try_parse_from([ + "harmony-auth", + "tenant", + "create", + "acme", + "--kubeconfig", + "/tmp/admin.kubeconfig", + "--kube-context", + "production-admin", + "--step-by-step", + ]); + + assert!(parsed.is_err()); + } + + #[test] + fn parses_separate_deployer_create_command() { + let cli = Cli::try_parse_from([ + "harmony-auth", + "tenant", + "deployer", + "create", + "acme", + "acme-ci", + "--credentials", + "/tmp/acme-ci.json", + ]) + .unwrap(); + + assert_eq!(cli.command.name(), "tenant.deployer.create"); + } + + #[test] + fn tenant_create_help_uses_the_tenant_admin_model() { + let mut command = Cli::command(); + let help = command + .find_subcommand_mut("tenant") + .unwrap() + .find_subcommand_mut("create") + .unwrap() + .render_long_help() + .to_string(); + + assert!(help.contains("Create an isolated tenant")); + assert!(help.contains("--owner")); + assert!(help.contains("tenant deployer create")); + assert!(!help.contains("--openbao-jwt-role")); + } } diff --git a/harmony_config/src/source/local_file.rs b/harmony_config/src/source/local_file.rs index c9b8fc81..1f3a7648 100644 --- a/harmony_config/src/source/local_file.rs +++ b/harmony_config/src/source/local_file.rs @@ -1,6 +1,8 @@ use async_trait::async_trait; use std::path::PathBuf; use tokio::fs; +#[cfg(unix)] +use tokio::io::AsyncWriteExt; use crate::{ConfigClass, ConfigError, ConfigSource}; @@ -52,7 +54,7 @@ impl ConfigSource for LocalFileSource { async fn set( &self, - _class: ConfigClass, + class: ConfigClass, key: &str, value: &serde_json::Value, ) -> Result<(), ConfigError> { @@ -65,6 +67,31 @@ impl ConfigSource for LocalFileSource { source: e, })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mode = if class == ConfigClass::Secret { + 0o600 + } else { + 0o666 + }; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(mode) + .open(&path) + .await?; + if class == ConfigClass::Secret { + file.set_permissions(std::fs::Permissions::from_mode(mode)) + .await?; + } + file.write_all(contents.as_bytes()).await?; + file.flush().await?; + } + + #[cfg(not(unix))] fs::write(&path, contents).await?; Ok(()) -- 2.39.5 From dcd5200e95a0d177641234118f8f940ac6fc366d Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 16:26:56 -0400 Subject: [PATCH 09/34] feat: provision tenant registry credentials --- docs/guides/harmony-auth-cli.md | 30 ++++--- fleet/harmony-fleet-deploy/src/app.rs | 1 + harmony_app/src/context.rs | 1 + harmony_app/src/tenant.rs | 1 + harmony_auth/src/backend.rs | 16 ++-- harmony_auth_cli/src/main.rs | 120 +++++++++++++++++++++++++- harmony_config/src/lib.rs | 8 +- 7 files changed, 150 insertions(+), 27 deletions(-) diff --git a/docs/guides/harmony-auth-cli.md b/docs/guides/harmony-auth-cli.md index 3edf963d..ed9d0a06 100644 --- a/docs/guides/harmony-auth-cli.md +++ b/docs/guides/harmony-auth-cli.md @@ -243,9 +243,9 @@ manual migration. ### Create a tenant `tenant create` configures owner access, secret access, resource limits, -network isolation, and namespace-scoped deployment credentials. It runs -`TenantScore` and `TenantCredentialScore` against an explicit administrator -kube context. +network isolation, Harbor push credentials, and namespace-scoped deployment +credentials. It runs `TenantScore` and `TenantCredentialScore` against an +explicit administrator kube context. ```sh harmony-auth tenant create acme \ @@ -260,6 +260,14 @@ the saved limits. Applied values are stored in the authoritative tenant definition under `harmony_auth/data/tenants/`. Use flags such as `--cpu-limit-cores` for unattended use. +Applied runs request a Harbor username and masked robot secret or access token +when the tenant has no stored credentials. For unattended use, set +`HARBOR_USERNAME` and `HARBOR_TOKEN`; the token has no command-line flag because +process arguments are not secret-safe. Supplying either value replaces the +stored credentials and requires both. Harmony stores the typed credentials at +`/data//RegistryCredentials`. Harbor SSO will replace +these stored push credentials when registry federation is available. + The selected Harmony context supplies the default kubeconfig path and kube context. Command flags override those defaults for the current invocation; local paths are not stored in the tenant definition. Owner usernames are bound @@ -301,14 +309,14 @@ harmony-auth tenant create acme \ ``` The command pauses after baseline validation, tenant permissions, owner access, -secret access, stored tenant state, and between the Kubernetes tenant and -credential Scores. Each prompt names the operation it will run next, including -the target Zitadel project, OpenBao path, namespace, and kube context where -applicable. All completed operations are logged at `INFO`, including detail -between checkpoints. The administrator can test from another terminal before -approving the next operation. Declining stops safely; rerunning the same command -continues through idempotent operations. Interactive mode cannot be combined -with `--json` and requires a terminal. +secret access, stored tenant state, Harbor credential storage, and between the +Kubernetes tenant and credential Scores. Each prompt names the operation it +will run next, including the target Zitadel project, OpenBao path, namespace, +and kube context where applicable. All completed operations are logged at +`INFO`, including detail between checkpoints. The administrator can test from +another terminal before approving the next operation. Declining stops safely; +rerunning the same command continues through idempotent operations. Interactive +mode cannot be combined with `--json` and requires a terminal. ### Create a CI deployer diff --git a/fleet/harmony-fleet-deploy/src/app.rs b/fleet/harmony-fleet-deploy/src/app.rs index 967a4f18..1b985206 100644 --- a/fleet/harmony-fleet-deploy/src/app.rs +++ b/fleet/harmony-fleet-deploy/src/app.rs @@ -279,6 +279,7 @@ impl HarmonyApp for FleetTenantProvisionApp { store.namespace.as_ref(), Some(store.url.to_string()), None, + None, Some(store.zitadel_url.to_string()), Some(store.zitadel_audience.to_string()), Some(store.role.to_string()), diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index ec1d3a11..04a30943 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -430,6 +430,7 @@ async fn build_config_sources( access.namespace.as_ref(), Some(access.url.to_string()), None, + None, Some(access.zitadel_url.to_string()), Some(access.zitadel_audience.to_string()), Some(access.role.to_string()), diff --git a/harmony_app/src/tenant.rs b/harmony_app/src/tenant.rs index 2ec7908f..e5ffdb3a 100644 --- a/harmony_app/src/tenant.rs +++ b/harmony_app/src/tenant.rs @@ -67,6 +67,7 @@ async fn application_tenant_scores( credential_store.namespace.as_ref(), Some(credential_store.url.to_string()), openbao_token, + None, Some(credential_store.zitadel_url.to_string()), Some(credential_store.zitadel_audience.to_string()), Some(credential_store.role.to_string()), diff --git a/harmony_auth/src/backend.rs b/harmony_auth/src/backend.rs index aa63a8cc..a04a9f62 100644 --- a/harmony_auth/src/backend.rs +++ b/harmony_auth/src/backend.rs @@ -345,17 +345,11 @@ impl BackendAuth { .error_for_status() .map_err(backend)?; } - completed(ProvisionStep::checkpoint( - format!( - "Tenant definition {} at OpenBao path 'harmony_auth/data/tenants/{}'", - if changed { "stored" } else { "already matches" }, - request.tenant.slug - ), - format!( - "Provision Kubernetes resources for namespace '{}'", - request.tenant.namespace - ), - ))?; + completed(ProvisionStep::detail(format!( + "Tenant definition {} at OpenBao path 'harmony_auth/data/tenants/{}'", + if changed { "stored" } else { "already matches" }, + request.tenant.slug + )))?; Ok(TenantCreateResult { tenant: request.tenant, diff --git a/harmony_auth_cli/src/main.rs b/harmony_auth_cli/src/main.rs index ced6b0b7..8fd8459f 100644 --- a/harmony_auth_cli/src/main.rs +++ b/harmony_auth_cli/src/main.rs @@ -5,7 +5,7 @@ use std::{ use clap::{Args, Parser, Subcommand, ValueEnum}; use harmony_app::{ - OpenBaoClusterAccess, ResourceLimits, TenantConfig, + OpenBaoClusterAccess, RegistryCredentials, ResourceLimits, TenantConfig, provision_application_tenant_on_context_with_progress, }; use harmony_auth::{ @@ -15,8 +15,8 @@ use harmony_auth::{ TenantDefinition, TenantResources, TenantSummary, }; use harmony_config::{ - Config, ConfigClass, ConfigClient, ConfigSource, FieldPrompter, FieldType, InquirePrompter, - LocalFileSource, + Config, ConfigClass, ConfigClient, ConfigError, ConfigSource, FieldPrompter, FieldType, + InquirePrompter, LocalFileSource, }; use harmony_k8s::K8sClient; use harmony_types::context::{ContextName, HttpUrl}; @@ -213,6 +213,9 @@ struct TenantCreateArgs { /// owners. #[arg(long)] owner: Vec, + /// Harbor username used to push tenant images. + #[arg(long, env = "HARBOR_USERNAME")] + registry_username: Option, /// Apply the plan. Without this flag no tenant resources are changed. #[arg(long)] apply: bool, @@ -413,6 +416,7 @@ async fn run(cli: &Cli) -> Result { storage_total_gb, service_limit, owner, + registry_username, apply, step_by_step, } = args.as_ref(); @@ -549,6 +553,48 @@ async fn run(cli: &Cli) -> Result { })); } + let registry_source = harmony_config::openbao_source( + &definition.slug, + Some(config.openbao_url.clone()), + Some(config.openbao_token.clone()), + Some(cli.openbao_kv_mount.clone()), + None, + None, + None, + ) + .await + .ok_or_else(|| AuthError::Backend("tenant registry store is unavailable".into()))?; + let registry_client = ConfigClient::new(vec![registry_source]); + let registry_username = registry_username + .clone() + .filter(|value| !value.trim().is_empty()); + let registry_token = std::env::var("HARBOR_TOKEN") + .ok() + .filter(|value| !value.trim().is_empty()); + // TODO: Replace stored Harbor credentials with Harbor SSO once registry federation is available. + let registry_credentials_exist = + if registry_username.is_none() && registry_token.is_none() { + match registry_client.get::().await { + Ok(_) => true, + Err(ConfigError::NotFound { .. }) => false, + Err(error) => { + return Err(AuthError::Backend(format!( + "loading Harbor credentials: {error}" + ))); + } + } + } else { + false + }; + let registry_credentials = if registry_credentials_exist { + None + } else { + Some(RegistryCredentials { + username: required_value(registry_username, "Harbor username", interactive)?, + token: required_secret(registry_token, "Harbor token", interactive)?, + }) + }; + // TODO: Replace direct backend orchestration with composed tenant Scores; the CLI should only resolve inputs, invoke Scores, and render progress. let request = TenantCreateRequest { tenant: definition, @@ -567,10 +613,39 @@ async fn run(cli: &Cli) -> Result { if stored_resources.is_none() { let _ = tokio::fs::remove_file(draft_path).await; } + let registry_path = format!( + "{}/data/{}/RegistryCredentials", + cli.openbao_kv_mount, tenant.slug + ); + if let Some(registry_credentials) = registry_credentials { + if *step_by_step { + confirm_next_operation(&format!( + "Store Harbor credentials at OpenBao path '{registry_path}'" + ))?; + } + registry_client + .set(®istry_credentials) + .await + .map_err(|error| { + AuthError::Backend(format!("storing Harbor credentials: {error}")) + })?; + tracing::info!("Stored Harbor credentials at OpenBao path '{registry_path}'"); + } else { + tracing::info!( + "Harbor credentials already exist at OpenBao path '{registry_path}'" + ); + } + let credential_operation = format!( "Create deployer RBAC and service-account credentials in namespace '{}', then store ClusterAccess at OpenBao path '{}/data/{}/ClusterAccess'", tenant.namespace, cli.openbao_kv_mount, tenant.slug ); + if *step_by_step { + confirm_next_operation(&format!( + "Provision Kubernetes resources for namespace '{}' using kube context '{}'", + tenant.namespace, kube_context + ))?; + } provision_application_tenant_on_context_with_progress( PathBuf::from(kubeconfig), kube_context, @@ -1043,6 +1118,10 @@ impl Output { " Cluster access: {}/data/{tenant}/ClusterAccess", result.openbao_kv_mount ); + println!( + " Registry credentials: {}/data/{tenant}/RegistryCredentials", + result.openbao_kv_mount + ); println!(" OpenBao JWT role: {role}"); println!(); println!("CI setup:"); @@ -1189,6 +1268,26 @@ fn required_value( .map_err(|error| AuthError::Invalid(format!("prompting for {label}: {error}"))) } +fn required_secret( + value: Option, + label: &str, + interactive: bool, +) -> Result { + if let Some(value) = value.filter(|value| !value.trim().is_empty()) { + return Ok(value); + } + if !interactive { + return Err(AuthError::Invalid(format!( + "{label} is required; set HARBOR_TOKEN or use an interactive terminal" + ))); + } + inquire::Password::new(label) + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .without_confirmation() + .prompt() + .map_err(|error| AuthError::Invalid(format!("prompting for {label}: {error}"))) +} + fn report_provision_step(step: &ProvisionStep, step_by_step: bool) -> Result<(), AuthError> { tracing::info!("{}", step.message()); if step_by_step && let Some(next_operation) = step.next_operation() { @@ -1595,6 +1694,21 @@ mod tests { assert_eq!(cli.command.name(), "tenant.create"); } + #[test] + fn harbor_token_has_no_command_line_flag() { + assert!( + Cli::try_parse_from([ + "harmony-auth", + "tenant", + "create", + "acme", + "--registry-token", + "secret", + ]) + .is_err() + ); + } + #[test] fn step_by_step_requires_apply() { let parsed = Cli::try_parse_from([ diff --git a/harmony_config/src/lib.rs b/harmony_config/src/lib.rs index a909d2ba..81e86989 100644 --- a/harmony_config/src/lib.rs +++ b/harmony_config/src/lib.rs @@ -232,7 +232,7 @@ impl ConfigClient { /// Build an OpenBao-backed `StoreSource` purely from env — the default chain. async fn openbao_from_env(namespace: &str) -> Option> { - openbao_source(namespace, None, None, None, None, None).await + openbao_source(namespace, None, None, None, None, None, None).await } /// Build an OpenBao-backed `StoreSource`. Explicit arguments override their env @@ -244,6 +244,7 @@ pub async fn openbao_source( namespace: &str, openbao_url: Option, openbao_token: Option, + openbao_kv_mount: Option, zitadel_sso_url: Option, zitadel_audience: Option, openbao_jwt_role: Option, @@ -286,7 +287,9 @@ pub async fn openbao_source( } }; - let kv_mount = env("OPENBAO_KV_MOUNT").unwrap_or_else(|| "secret".to_string()); + let kv_mount = openbao_kv_mount + .or_else(|| env("OPENBAO_KV_MOUNT")) + .unwrap_or_else(|| "secret".to_string()); let skip_tls = env("OPENBAO_SKIP_TLS").as_deref() == Some("true"); let inline_machine_identity = token.is_none() && zitadel_jwt_bearer.as_ref().is_some_and(|config| { @@ -1427,6 +1430,7 @@ mod tests { None, None, None, + None, ) .await; -- 2.39.5 From 1c1cf1f96b6f7bd453020caa9f094d96e7ea9d4a Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 19:30:41 -0400 Subject: [PATCH 10/34] fix: isolate generated deployment state --- harmony/src/modules/zitadel/mod.rs | 173 +++++++++++---- harmony/src/modules/zitadel/setup.rs | 106 +++++++-- harmony_app/src/context.rs | 56 ++--- harmony_auth/src/lib.rs | 20 +- harmony_config/src/lib.rs | 273 ++++++++++++++++++++++-- harmony_config/src/source/local_file.rs | 5 +- harmony_config/src/source/store.rs | 13 +- harmony_secret/src/lib.rs | 2 +- harmony_secret/src/openbao_policy.rs | 25 +++ 9 files changed, 553 insertions(+), 120 deletions(-) diff --git a/harmony/src/modules/zitadel/mod.rs b/harmony/src/modules/zitadel/mod.rs index c373266c..0ef2910a 100644 --- a/harmony/src/modules/zitadel/mod.rs +++ b/harmony/src/modules/zitadel/mod.rs @@ -32,7 +32,7 @@ use std::collections::BTreeMap; use std::str::FromStr; use async_trait::async_trait; -use harmony_config::Config; +use harmony_config::{Config, ConfigError, StateClient}; use harmony_macros::hurl; use harmony_types::id::Id; use log::{debug, error, info, trace, warn}; @@ -263,6 +263,35 @@ async fn read_namespace_scc_uid_start( .map_err(|e| format!("parsing uid-range start '{start}': {e}")) } +async fn read_masterkey( + k8s: &harmony_k8s::K8sClient, + namespace: &str, +) -> Result, InterpretError> { + let secret = k8s + .get_resource::(MASTERKEY_SECRET_NAME, Some(namespace)) + .await + .map_err(|error| { + InterpretError::new(format!("Failed to read Zitadel masterkey Secret: {error}")) + })?; + match secret { + Some(secret) => { + let bytes = secret + .data + .as_ref() + .and_then(|data| data.get("masterkey")) + .ok_or_else(|| { + InterpretError::new( + "Existing Zitadel masterkey Secret has no masterkey".to_string(), + ) + })?; + Ok(Some(String::from_utf8(bytes.0.clone()).map_err( + |error| InterpretError::new(format!("Zitadel masterkey is not UTF-8: {error}")), + )?)) + } + None => Ok(None), + } +} + impl Default for ZitadelScore { fn default() -> Self { Self { @@ -320,7 +349,13 @@ impl Score for ZitadelScore { #[doc(hidden)] fn create_interpret(&self) -> Box> { - Box::new(ZitadelInterpret { + Box::new(self.interpret(None)) + } +} + +impl ZitadelScore { + fn interpret(&self, state_client: Option) -> ZitadelInterpret { + ZitadelInterpret { host: self.host.clone(), zitadel_version: self.zitadel_version.clone(), external_secure: self.external_secure, @@ -330,7 +365,34 @@ impl Score for ZitadelScore { password_change_required: self.password_change_required, database: self.database.clone(), node_port: self.node_port, - }) + state_client, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ConfiguredZitadelScore { + score: ZitadelScore, + #[serde(skip)] + state_client: StateClient, +} + +impl ZitadelScore { + pub fn with_state_client(self, state_client: StateClient) -> ConfiguredZitadelScore { + ConfiguredZitadelScore { + score: self, + state_client, + } + } +} + +impl Score for ConfiguredZitadelScore { + fn name(&self) -> String { + "ZitadelScore".to_string() + } + + fn create_interpret(&self) -> Box> { + Box::new(self.score.interpret(Some(self.state_client.clone()))) } } @@ -347,6 +409,23 @@ struct ZitadelInterpret { password_change_required: bool, database: Option, node_port: Option, + state_client: Option, +} + +impl ZitadelInterpret { + async fn get_state(&self) -> Result { + match &self.state_client { + Some(client) => client.get().await, + None => harmony_config::get().await, + } + } + + async fn set_state(&self, value: &T) -> Result<(), ConfigError> { + match &self.state_client { + Some(client) => client.set(value).await, + None => harmony_config::set(value).await, + } + } } #[async_trait] @@ -449,19 +528,24 @@ impl Interpret for ZitadelInterpret { // emit a fresh random — misleading the operator). harmony_config // namespaces by install context (config-resolved), so two // installs in the same context share credentials. - let admin = match harmony_config::get::().await { + let admin = match self.get_state::().await { Ok(a) => a, - Err(e) => { - debug!("[Zitadel] No persisted admin credentials yet ({e}); generating"); + Err(ConfigError::NotFound { .. }) => { + debug!("[Zitadel] No persisted admin credentials yet; generating"); let a = ZitadelAdmin { username: "admin".to_string(), password: generate_secure_password(16), }; - harmony_config::set(&a).await.map_err(|err| { + self.set_state(&a).await.map_err(|err| { InterpretError::new(format!("Failed to persist Zitadel admin password: {err}")) })?; a } + Err(error) => { + return Err(InterpretError::new(format!( + "Failed to load Zitadel admin password: {error}" + ))); + } }; let admin_username = admin.username.clone(); let admin_password = admin.password.clone(); @@ -501,38 +585,27 @@ impl Interpret for ZitadelInterpret { // 3. a freshly generated one. // Then mirror the resolved value into harmony_config so a deleted/ // recreated namespace reuses it rather than minting a new (broken) key. - let existing_masterkey = k8s_client - .get_resource::(MASTERKEY_SECRET_NAME, Some(&self.namespace)) - .await - .ok() - .flatten() - .and_then(|s| s.data?.get("masterkey").cloned()) - .and_then(|bs| String::from_utf8(bs.0).ok()); + let existing_masterkey = read_masterkey(&k8s_client, &self.namespace).await?; - let masterkey = match existing_masterkey { - Some(k) => k, - None => match harmony_config::get::().await { - Ok(m) => m.masterkey, - Err(e) => { - debug!("[Zitadel] No persisted masterkey yet ({e}); generating"); - rng() - .sample_iter(&rand::distr::Alphanumeric) - .take(32) - .map(char::from) - .collect::() - } - }, + let persisted_masterkey = match self.get_state::().await { + Ok(value) => Some(value.masterkey), + Err(ConfigError::NotFound { .. }) => None, + Err(error) => { + return Err(InterpretError::new(format!( + "Failed to load Zitadel masterkey: {error}" + ))); + } }; - - if harmony_config::get::().await.is_err() { - harmony_config::set(&ZitadelMasterkey { - masterkey: masterkey.clone(), - }) - .await - .map_err(|e| { - InterpretError::new(format!("Failed to persist Zitadel masterkey: {e}")) - })?; - } + let masterkey = existing_masterkey + .or_else(|| persisted_masterkey.clone()) + .unwrap_or_else(|| { + debug!("[Zitadel] No persisted masterkey yet; generating"); + rng() + .sample_iter(&rand::distr::Alphanumeric) + .take(32) + .map(char::from) + .collect::() + }); debug!( "[Zitadel] Created masterkey secret '{}' in namespace '{}'", @@ -540,7 +613,10 @@ impl Interpret for ZitadelInterpret { ); let mut masterkey_data: BTreeMap = BTreeMap::new(); - masterkey_data.insert("masterkey".to_string(), ByteString(masterkey.into())); + masterkey_data.insert( + "masterkey".to_string(), + ByteString(masterkey.clone().into()), + ); let masterkey_secret = K8sSecret { metadata: ObjectMeta { @@ -552,7 +628,7 @@ impl Interpret for ZitadelInterpret { ..K8sSecret::default() }; - match k8s_client + let authoritative_masterkey = match k8s_client .create(&masterkey_secret, Some(&self.namespace)) .await { @@ -561,12 +637,21 @@ impl Interpret for ZitadelInterpret { "[Zitadel] Masterkey secret '{}' created", MASTERKEY_SECRET_NAME ); + masterkey } Err(KubeError::Api(ErrorResponse { code: 409, .. })) => { info!( "[Zitadel] Masterkey secret '{}' already exists, leaving it untouched", MASTERKEY_SECRET_NAME ); + read_masterkey(&k8s_client, &self.namespace) + .await? + .ok_or_else(|| { + InterpretError::new( + "Zitadel masterkey Secret disappeared after create conflict" + .to_string(), + ) + })? } Err(other) => { let msg = format!( @@ -578,6 +663,16 @@ impl Interpret for ZitadelInterpret { } }; + if persisted_masterkey.as_deref() != Some(authoritative_masterkey.as_str()) { + self.set_state(&ZitadelMasterkey { + masterkey: authoritative_masterkey, + }) + .await + .map_err(|error| { + InterpretError::new(format!("Failed to persist Zitadel masterkey: {error}")) + })?; + } + debug!( "[Zitadel] Masterkey secret '{}' created successfully", MASTERKEY_SECRET_NAME diff --git a/harmony/src/modules/zitadel/setup.rs b/harmony/src/modules/zitadel/setup.rs index 934e7e9b..ef15cbfe 100644 --- a/harmony/src/modules/zitadel/setup.rs +++ b/harmony/src/modules/zitadel/setup.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; use async_trait::async_trait; -use harmony_config::{Config, ConfigError}; +use harmony_config::{Config, ConfigClient, ConfigError, StateClient}; use log::{debug, info, warn}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -471,6 +472,18 @@ pub struct ZitadelContractSetupScore { } impl ZitadelContractSetupScore { + pub fn with_config_clients( + self, + config_client: Arc, + state_client: StateClient, + ) -> ConfiguredZitadelContractSetupScore { + ConfiguredZitadelContractSetupScore { + score: self, + config_client, + state_client, + } + } + pub fn project_output(&self, project: &ZitadelProjectRef) -> ZitadelProjectOutputRef { assert!( self.contract @@ -514,6 +527,15 @@ impl ZitadelContractSetupScore { } } +#[derive(Debug, Clone, Serialize)] +pub struct ConfiguredZitadelContractSetupScore { + score: ZitadelContractSetupScore, + #[serde(skip)] + config_client: Arc, + #[serde(skip)] + state_client: StateClient, +} + /// Function name doubles as the Action name — Zitadel requires the /// script's entry function to match. pub const GROUPS_CLAIM_ACTION_NAME: &str = "harmonyGroupsClaim"; @@ -582,8 +604,12 @@ impl ZitadelClientConfig { Self::load_path(Self::cache_path()) } - async fn load_for_host(host: &str) -> Result { - match harmony_config::get::().await { + async fn load_for_host(host: &str, state_client: Option<&StateClient>) -> Result { + let result = match state_client { + Some(client) => client.get::().await, + None => harmony_config::get::().await, + }; + match result { Ok(config) if config.host.as_deref() == Some(host) => Ok(config), Ok(_) | Err(ConfigError::NotFound { .. }) => Ok(Self::default()), Err(ConfigError::NoSources) => { @@ -599,9 +625,17 @@ impl ZitadelClientConfig { .and_then(|s| serde_json::from_str(&s).ok()) } - async fn save_for_host(&mut self, host: &str) -> Result<(), String> { + async fn save_for_host( + &mut self, + host: &str, + state_client: Option<&StateClient>, + ) -> Result<(), String> { self.host = Some(host.to_string()); - match harmony_config::set(self).await { + let result = match state_client { + Some(client) => client.set(self).await, + None => harmony_config::set(self).await, + }; + match result { Ok(()) => Ok(()), Err(ConfigError::NoSources) => { self.save_path(Self::cache_path_for_host(host))?; @@ -689,6 +723,8 @@ impl Score for ZitadelSetupScore { Box::new(ZitadelSetupInterpret { score: self.clone(), contract: None, + config_client: None, + state_client: None, }) } } @@ -702,6 +738,23 @@ impl Score for ZitadelContractSetupScore { Box::new(ZitadelSetupInterpret { score: self.setup.clone(), contract: Some(self.contract.clone()), + config_client: None, + state_client: None, + }) + } +} + +impl Score for ConfiguredZitadelContractSetupScore { + fn name(&self) -> String { + "ZitadelContractSetupScore".to_string() + } + + fn create_interpret(&self) -> Box> { + Box::new(ZitadelSetupInterpret { + score: self.score.setup.clone(), + contract: Some(self.score.contract.clone()), + config_client: Some(self.config_client.clone()), + state_client: Some(self.state_client.clone()), }) } } @@ -714,6 +767,8 @@ impl Score for ZitadelContractSetupScore { struct ZitadelSetupInterpret { score: ZitadelSetupScore, contract: Option, + config_client: Option>, + state_client: Option, } #[derive(Deserialize)] @@ -798,6 +853,13 @@ struct MachineSecretResponse { } impl ZitadelSetupInterpret { + async fn get_config(&self) -> Result { + match &self.config_client { + Some(client) => client.get().await, + None => harmony_config::get().await, + } + } + fn management_client(&self, pat: &str) -> Result { let client = ManagementClient::new( self.api_url(""), @@ -2058,7 +2120,7 @@ impl ZitadelSetupInterpret { info!("[ZitadelSetup] Machine key created for '{}'", user.username); config.machine_keys.insert(user.username.clone(), key_json); config - .save_for_host(&self.score.host) + .save_for_host(&self.score.host, self.state_client.as_ref()) .await .map_err(InterpretError::new)?; } @@ -2230,13 +2292,14 @@ impl ZitadelSetupInterpret { { Some(id) => id, None => { - let secrets = harmony_config::get::() - .await - .map_err(|error| { - InterpretError::new(format!( - "resolve Zitadel bootstrap secrets for '{email}': {error}" - )) - })?; + let secrets = + self.get_config::() + .await + .map_err(|error| { + InterpretError::new(format!( + "resolve Zitadel bootstrap secrets for '{email}': {error}" + )) + })?; let bootstrap_password = secrets .resolve(&human.bootstrap_password) .ok_or_else(|| { @@ -2379,7 +2442,7 @@ impl ZitadelSetupInterpret { .machine_secrets .insert(username.to_string(), parsed.client_secret); config - .save_for_host(&self.score.host) + .save_for_host(&self.score.host, self.state_client.as_ref()) .await .map_err(InterpretError::new)?; info!("[ZitadelSetup] Client secret minted for '{username}'"); @@ -2503,6 +2566,8 @@ pub async fn mint_device_credentials( let interp = ZitadelSetupInterpret { score: connection.clone(), contract: None, + config_client: None, + state_client: None, }; let client = interp.http_client().map_err(InterpretError::new)?; @@ -2635,6 +2700,8 @@ impl Interpret for ZitadelSetupInterpret { std::borrow::Cow::Owned(ZitadelSetupInterpret { score, contract: self.contract.clone(), + config_client: self.config_client.clone(), + state_client: self.state_client.clone(), }) } else { _pf = None; @@ -2654,9 +2721,10 @@ impl Interpret for ZitadelSetupInterpret { // dialing`. Both are now handled in `wait_until_ready`. me.wait_until_ready(&client, &pat).await?; - let mut config = ZitadelClientConfig::load_for_host(&me.score.host) - .await - .map_err(InterpretError::new)?; + let mut config = + ZitadelClientConfig::load_for_host(&me.score.host, me.state_client.as_ref()) + .await + .map_err(InterpretError::new)?; let mut details = Vec::new(); @@ -2817,7 +2885,7 @@ impl Interpret for ZitadelSetupInterpret { } config - .save_for_host(&me.score.host) + .save_for_host(&me.score.host, me.state_client.as_ref()) .await .map_err(InterpretError::new)?; @@ -3495,6 +3563,8 @@ mod tests { ZitadelSetupInterpret { score, contract: None, + config_client: None, + state_client: None, } } diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index 04a30943..2432e154 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use harmony::modules::tenant::ClusterAccess; use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology}; -use harmony_config::{ConfigClient, ConfigSource, LocalFileSource, PromptSource}; +use harmony_config::{ConfigClient, ConfigSource, LocalFileSource, StateClient}; use harmony_k8s::K8sClient; use harmony_types::context::{ ContextName, DomainName, HttpUrl, OciRegistry, OciRepository, OidcAudience, OpenBaoNamespace, @@ -129,6 +129,7 @@ pub struct AppContext { kubeconfig: Option, _kubeconfig_guard: Option, config_client: Arc, + state_client: StateClient, cluster_target: Option, } @@ -182,13 +183,15 @@ impl AppContext { Profile::from(&context.spec) ); debug!("Context '{name}' definition: {:?}", context.spec); - let config_sources = build_config_sources(&context.spec, local_config_dir.clone()) - .await - .map_err(|e| { - ContextError::Config(format!("building config sources for context '{name}': {e}")) - })?; - harmony_config::init(config_sources.clone()).await; - let config_client = Arc::new(ConfigClient::new(config_sources)); + let (config_client, state_client) = + build_config_clients(&context.spec, local_config_dir.clone()) + .await + .map_err(|e| { + ContextError::Config(format!( + "building config sources for context '{name}': {e}" + )) + })?; + harmony_config::init_client(config_client.clone()).await; let (guard, cluster_target) = match &context.spec { ContextSpec::Local(LocalContext::ManagedK3d) => { info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')"); @@ -226,17 +229,19 @@ impl AppContext { context.namespace ); - Ok(Self::new( + let mut context = Self::new( context, version.into(), local_config_dir, config_client, guard, cluster_target, - )) + ); + context.state_client = state_client; + Ok(context) } - fn new( + pub(crate) fn new( context: &Context, version: String, local_config_dir: Option, @@ -244,6 +249,7 @@ impl AppContext { guard: Option, cluster_target: Option, ) -> Self { + let state_client = StateClient::new(config_client.clone(), config_client.clone()); Self { context: context.clone(), version, @@ -251,6 +257,7 @@ impl AppContext { kubeconfig: guard.as_ref().map(|guard| guard.path().to_path_buf()), _kubeconfig_guard: guard, config_client, + state_client, cluster_target, } } @@ -311,6 +318,12 @@ impl AppContext { pub fn config_client(&self) -> &ConfigClient { &self.config_client } + pub(crate) fn config_client_arc(&self) -> Arc { + self.config_client.clone() + } + pub(crate) fn state_client(&self, scope: &str, migrate_legacy: bool) -> StateClient { + self.state_client.scoped(scope, migrate_legacy) + } pub fn k3d_cluster(&self) -> Option<&str> { match &self.context.spec { ContextSpec::Local(LocalContext::ManagedK3d) => Some(AUTOPROVISION_CLUSTER), @@ -417,16 +430,14 @@ fn kubeconfig_target(contents: &str) -> Result { Ok(format!("{cluster} via context {current} ({server})")) } -async fn build_config_sources( +async fn build_config_clients( spec: &ContextSpec, local_config_dir: Option, -) -> Result>, ContextError> { - let mut sources: Vec> = Vec::new(); - - match spec { +) -> Result<(Arc, StateClient), ContextError> { + let source: Arc = match spec { ContextSpec::Remote(remote) => { let access = &remote.access; - let source = harmony_config::openbao_source( + harmony_config::openbao_source( access.namespace.as_ref(), Some(access.url.to_string()), None, @@ -441,8 +452,7 @@ async fn build_config_sources( "reaching OpenBao for namespace '{}'", access.namespace )) - })?; - sources.push(source); + })? } ContextSpec::Local(_) => { let dir = local_config_dir @@ -450,12 +460,10 @@ async fn build_config_sources( .ok_or_else(|| { ContextError::Missing("local contexts need a config directory".to_string()) })?; - sources.push(Arc::new(LocalFileSource::new(dir))); + Arc::new(LocalFileSource::new(dir)) } - } - - sources.push(Arc::new(PromptSource::new())); - Ok(sources) + }; + Ok(harmony_config::clients_for_source(source)) } fn write_kubeconfig(contents: &[u8]) -> Result { diff --git a/harmony_auth/src/lib.rs b/harmony_auth/src/lib.rs index 55350322..5eaae89a 100644 --- a/harmony_auth/src/lib.rs +++ b/harmony_auth/src/lib.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use harmony_secret::render_tenant_policy; use harmony_types::k8s_name::K8sName; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -216,13 +217,10 @@ impl TenantCapability { pub fn openbao_policy(self, tenant: &str, mount: &str) -> String { let capabilities = match self { - Self::Owner => "[\"create\", \"delete\", \"patch\", \"read\", \"update\"]", - Self::Deployer | Self::Viewer => "[\"read\"]", + Self::Owner => &["create", "delete", "patch", "read", "update"][..], + Self::Deployer | Self::Viewer => &["read"][..], }; - format!( - "path \"{mount}/data/{tenant}/*\" {{ capabilities = {capabilities} }}\n\ - path \"{mount}/metadata/{tenant}/*\" {{ capabilities = [\"list\", \"read\"] }}" - ) + render_tenant_policy(mount, tenant, capabilities, self == Self::Deployer) } } @@ -616,11 +614,11 @@ mod tests { .openbao_policy("acme", "secret") .contains("\"update\"") ); - assert!( - !TenantCapability::Deployer - .openbao_policy("acme", "secret") - .contains("\"update\"") - ); + let deployer = TenantCapability::Deployer.openbao_policy("acme", "secret"); + assert!(deployer.contains("path \"secret/data/acme/*\" { capabilities = [\"read\"] }")); + assert!(deployer.contains( + "path \"secret/data/acme/harmony-state/*\" { capabilities = [\"create\", \"read\", \"update\"] }" + )); } #[test] diff --git a/harmony_config/src/lib.rs b/harmony_config/src/lib.rs index 81e86989..3e66bde4 100644 --- a/harmony_config/src/lib.rs +++ b/harmony_config/src/lib.rs @@ -130,6 +130,12 @@ pub struct ConfigClient { sources: Vec>, } +impl std::fmt::Debug for ConfigClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ConfigClient").finish_non_exhaustive() + } +} + impl ConfigClient { pub fn new(sources: Vec>) -> Self { Self { sources } @@ -151,6 +157,15 @@ impl ConfigClient { } pub async fn get(&self) -> Result { + self.get_inner(false).await + } + + async fn get_strict(&self) -> Result { + self.get_inner(true).await + } + + async fn get_inner(&self, preserve_invalid: bool) -> Result { + let mut invalid = None; for source in &self.sources { if let Some(value) = source.get(T::CLASS, T::KEY).await? { // A deser failure means the stored value is shaped for a @@ -159,16 +174,25 @@ impl ConfigClient { // later source — or a re-prompt — overwrites the stale entry. match serde_json::from_value::(value) { Ok(config) => return Ok(config), - Err(e) => warn!( - "Stale value for key {} in source; falling through ({e})", - T::KEY - ), + Err(source) => { + warn!( + "Stale value for key {} in source; falling through ({source})", + T::KEY + ); + invalid = Some(source); + } } } } - Err(ConfigError::NotFound { - key: T::KEY.to_string(), - }) + match (invalid, preserve_invalid) { + (Some(source), true) => Err(ConfigError::Deserialization { + key: T::KEY.to_string(), + source, + }), + _ => Err(ConfigError::NotFound { + key: T::KEY.to_string(), + }), + } } pub async fn get_or_prompt(&self) -> Result { @@ -230,6 +254,131 @@ impl ConfigClient { } } +struct ScopedSource { + prefix: String, + source: Arc, +} + +impl ScopedSource { + fn new(prefix: impl Into, source: Arc) -> Self { + Self { + prefix: prefix.into(), + source, + } + } + + fn key(&self, key: &str) -> String { + format!("{}/{key}", self.prefix) + } +} + +#[async_trait] +impl ConfigSource for ScopedSource { + async fn get( + &self, + class: ConfigClass, + key: &str, + ) -> Result, ConfigError> { + self.source.get(class, &self.key(key)).await + } + + async fn set( + &self, + class: ConfigClass, + key: &str, + value: &serde_json::Value, + ) -> Result<(), ConfigError> { + self.source.set(class, &self.key(key), value).await + } +} + +#[derive(Clone)] +pub struct StateClient { + state: Arc, + legacy: Option>, +} + +impl std::fmt::Debug for StateClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StateClient").finish_non_exhaustive() + } +} + +impl StateClient { + pub fn new(state: Arc, legacy: Arc) -> Self { + Self { + state, + legacy: Some(legacy), + } + } + + pub async fn get(&self) -> Result { + match self.state.get_strict::().await { + Err(ConfigError::NotFound { .. }) => { + let Some(legacy) = &self.legacy else { + return Err(ConfigError::NotFound { + key: T::KEY.to_string(), + }); + }; + let value = legacy.get::().await?; + self.state.set(&value).await?; + Ok(value) + } + result => result, + } + } + + pub async fn set(&self, value: &T) -> Result<(), ConfigError> { + self.state.set(value).await + } + + pub fn scoped(&self, scope: &str, migrate_legacy: bool) -> Self { + let scope = match scope { + "" => "%00".to_string(), + "." => "%2E".to_string(), + ".." => "%2E%2E".to_string(), + _ => scope + .bytes() + .map(|byte| match byte { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' => { + char::from(byte).to_string() + } + _ => format!("%{byte:02X}"), + }) + .collect(), + }; + Self { + state: Arc::new(ConfigClient::new( + self.state + .sources + .iter() + .map(|source| { + Arc::new(ScopedSource::new(&scope, source.clone())) as Arc + }) + .collect(), + )), + legacy: if migrate_legacy { + self.legacy.clone() + } else { + None + }, + } + } +} + +pub fn clients_for_source(source: Arc) -> (Arc, StateClient) { + let config = Arc::new(ConfigClient::new(vec![ + Arc::new(EnvSource), + source.clone(), + Arc::new(PromptSource::new()), + ])); + let state = Arc::new(ConfigClient::new(vec![Arc::new(ScopedSource::new( + harmony_secret::HARMONY_STATE_SUBPATH, + source, + ))])); + (config.clone(), StateClient::new(state, config)) +} + /// Build an OpenBao-backed `StoreSource` purely from env — the default chain. async fn openbao_from_env(namespace: &str) -> Option> { openbao_source(namespace, None, None, None, None, None, None).await @@ -344,8 +493,12 @@ pub async fn openbao_source( static CONFIG_CLIENT: Mutex>> = Mutex::const_new(None); pub async fn init(sources: Vec>) { + init_client(Arc::new(ConfigClient::new(sources))).await; +} + +pub async fn init_client(client: Arc) { let mut manager = CONFIG_CLIENT.lock().await; - *manager = Some(Arc::new(ConfigClient::new(sources))); + *manager = Some(client); } pub async fn get() -> Result { @@ -501,6 +654,99 @@ mod tests { } } + #[tokio::test] + async fn state_client_migrates_legacy_values_without_writing_legacy_source() { + let mut legacy_data = std::collections::HashMap::new(); + legacy_data.insert( + TestConfig::KEY.to_string(), + serde_json::json!({"name": "legacy", "count": 1}), + ); + let state = Arc::new(MockSource::new()); + let legacy = Arc::new(MockSource::with_data(legacy_data)); + let client = StateClient::new( + Arc::new(ConfigClient::new(vec![state.clone()])), + Arc::new(ConfigClient::new(vec![legacy.clone()])), + ); + + assert_eq!(client.get::().await.unwrap().name, "legacy"); + assert_eq!(state.set_call_count(), 1); + assert_eq!(legacy.set_call_count(), 0); + } + + #[tokio::test] + async fn context_state_is_namespaced_and_component_scoped() { + let source = Arc::new(MockSource::new()); + let (_, state) = clients_for_source(source.clone()); + + state + .scoped("identity/api", false) + .set(&TestConfig { + name: "scoped".into(), + count: 1, + }) + .await + .unwrap(); + + assert_eq!( + source.observed(), + vec![( + ConfigClass::Standard, + "harmony-state/identity%2Fapi/TestConfig".into(), + "set" + )] + ); + } + + #[tokio::test] + async fn scoped_state_migrates_legacy_only_when_enabled() { + let mut data = std::collections::HashMap::new(); + data.insert( + TestConfig::KEY.to_string(), + serde_json::json!({"name": "legacy", "count": 1}), + ); + let state = Arc::new(MockSource::new()); + let legacy = Arc::new(MockSource::with_data(data)); + let client = StateClient::new( + Arc::new(ConfigClient::new(vec![state])), + Arc::new(ConfigClient::new(vec![legacy])), + ); + let first = client.scoped("first", true); + let second = client.scoped("second", false); + + assert_eq!(first.get::().await.unwrap().name, "legacy"); + assert!(matches!( + second.get::().await, + Err(ConfigError::NotFound { .. }) + )); + } + + #[tokio::test] + async fn malformed_state_does_not_fall_back_to_legacy() { + let mut state_data = std::collections::HashMap::new(); + state_data.insert( + TestConfig::KEY.to_string(), + serde_json::json!({"name": "invalid", "count": "not-a-number"}), + ); + let mut legacy_data = std::collections::HashMap::new(); + legacy_data.insert( + TestConfig::KEY.to_string(), + serde_json::json!({"name": "legacy", "count": 1}), + ); + let legacy = Arc::new(MockSource::with_data(legacy_data)); + let client = StateClient::new( + Arc::new(ConfigClient::new(vec![Arc::new(MockSource::with_data( + state_data, + ))])), + Arc::new(ConfigClient::new(vec![legacy.clone()])), + ); + + assert!(matches!( + client.get::().await, + Err(ConfigError::Deserialization { .. }) + )); + assert_eq!(legacy.get_call_count(), 0); + } + /// A `FieldPrompter` double: returns canned answers and records which /// fields it was asked for, so a test can assert only the missing fields /// were prompted. @@ -1017,13 +1263,13 @@ mod tests { source .set( ConfigClass::Standard, - "TestConfig", + "state/TestConfig", &serde_json::to_value(&config).unwrap(), ) .await .unwrap(); - let file_path = dir.path().join("TestConfig.json"); + let file_path = dir.path().join("state/TestConfig.json"); let contents = std::fs::read_to_string(&file_path).unwrap(); let parsed: TestConfig = serde_json::from_str(&contents).unwrap(); @@ -1471,7 +1717,7 @@ mod tests { } #[tokio::test] - async fn test_store_source_error_falls_through_to_sqlite() { + async fn test_store_source_error_stops_the_chain() { use tempfile::NamedTempFile; let temp_file = NamedTempFile::new().unwrap(); @@ -1497,9 +1743,8 @@ mod tests { .await .unwrap(); - let result: TestConfig = manager.get().await.unwrap(); - assert_eq!(result.name, "from_sqlite"); - assert_eq!(result.count, 42); + let result: Result = manager.get().await; + assert!(matches!(result, Err(ConfigError::StoreError(_)))); } #[derive(Debug)] diff --git a/harmony_config/src/source/local_file.rs b/harmony_config/src/source/local_file.rs index 1f3a7648..f9d90deb 100644 --- a/harmony_config/src/source/local_file.rs +++ b/harmony_config/src/source/local_file.rs @@ -58,9 +58,10 @@ impl ConfigSource for LocalFileSource { key: &str, value: &serde_json::Value, ) -> Result<(), ConfigError> { - fs::create_dir_all(&self.base_path).await?; - let path = self.file_path_for(key); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } let contents = serde_json::to_string_pretty(value).map_err(|e| ConfigError::Serialization { key: key.to_string(), diff --git a/harmony_config/src/source/store.rs b/harmony_config/src/source/store.rs index a4ba4e48..66c48b56 100644 --- a/harmony_config/src/source/store.rs +++ b/harmony_config/src/source/store.rs @@ -1,8 +1,6 @@ +use crate::{ConfigClass, ConfigError, ConfigSource}; use async_trait::async_trait; use harmony_secret::SecretStore; -use log::warn; - -use crate::{ConfigClass, ConfigError, ConfigSource}; pub struct StoreSource { namespace: String, @@ -34,14 +32,7 @@ impl ConfigSource for StoreSource { Ok(Some(value)) } Err(harmony_secret::SecretStoreError::NotFound { .. }) => Ok(None), - // Log before swallowing: a down/misconfigured OpenBao must not look identical to "key absent". - Err(e) => { - warn!( - "StoreSource: get for key '{key}' failed ({e}); treating as \ - absent and falling through to the next source" - ); - Ok(None) - } + Err(e) => Err(ConfigError::StoreError(e)), } } diff --git a/harmony_secret/src/lib.rs b/harmony_secret/src/lib.rs index e5c94cff..1b6a5825 100644 --- a/harmony_secret/src/lib.rs +++ b/harmony_secret/src/lib.rs @@ -34,7 +34,7 @@ use tokio::sync::OnceCell; pub use deployment_grants::OpenBaoDeploymentSecretGrants; pub use harmony_secret_derive::Secret; -pub use openbao_policy::OpenBaoPolicyManager; +pub use openbao_policy::{HARMONY_STATE_SUBPATH, OpenBaoPolicyManager, render_tenant_policy}; // The Secret trait remains the same. // pub trait Secret: Serialize + DeserializeOwned + Sized { diff --git a/harmony_secret/src/openbao_policy.rs b/harmony_secret/src/openbao_policy.rs index 66a71196..47165482 100644 --- a/harmony_secret/src/openbao_policy.rs +++ b/harmony_secret/src/openbao_policy.rs @@ -5,6 +5,31 @@ use reqwest::StatusCode; use serde_json::json; use tokio::sync::OnceCell; +pub const HARMONY_STATE_SUBPATH: &str = "harmony-state"; + +pub fn render_tenant_policy( + mount: &str, + tenant: &str, + capabilities: &[&str], + writable_state: bool, +) -> String { + let capabilities = capabilities + .iter() + .map(|capability| format!("\"{capability}\"")) + .collect::>() + .join(", "); + let mut policy = format!( + "path \"{mount}/data/{tenant}/*\" {{ capabilities = [{capabilities}] }}\n\ + path \"{mount}/metadata/{tenant}/*\" {{ capabilities = [\"list\", \"read\"] }}" + ); + if writable_state { + policy.push_str(&format!( + "\npath \"{mount}/data/{tenant}/{HARMONY_STATE_SUBPATH}/*\" {{ capabilities = [\"create\", \"read\", \"update\"] }}" + )); + } + policy +} + /// Reconciles an OpenBao ACL policy with external identity groups and JWT aliases. pub struct OpenBaoPolicyManager { client: reqwest::Client, -- 2.39.5 From 7e2398d095ddf0f3f16c113acfababc1fac1df46 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 19:30:51 -0400 Subject: [PATCH 11/34] feat: reconcile registry pull credentials --- docs/guides/harmony-auth-cli.md | 36 +++- harmony/src/modules/registry_pull_secret.rs | 75 +++++++- harmony_app/src/application/k8s_anywhere.rs | 137 +++++++++++-- harmony_app/src/lib.rs | 2 +- harmony_app/src/publish.rs | 12 ++ harmony_auth_cli/src/main.rs | 201 ++++++++++++++------ 6 files changed, 375 insertions(+), 88 deletions(-) diff --git a/docs/guides/harmony-auth-cli.md b/docs/guides/harmony-auth-cli.md index ed9d0a06..4b458981 100644 --- a/docs/guides/harmony-auth-cli.md +++ b/docs/guides/harmony-auth-cli.md @@ -260,13 +260,17 @@ the saved limits. Applied values are stored in the authoritative tenant definition under `harmony_auth/data/tenants/`. Use flags such as `--cpu-limit-cores` for unattended use. -Applied runs request a Harbor username and masked robot secret or access token -when the tenant has no stored credentials. For unattended use, set -`HARBOR_USERNAME` and `HARBOR_TOKEN`; the token has no command-line flag because -process arguments are not secret-safe. Supplying either value replaces the -stored credentials and requires both. Harmony stores the typed credentials at -`/data//RegistryCredentials`. Harbor SSO will replace -these stored push credentials when registry federation is available. +Interactive applied runs request any missing Harbor push and pull credentials. +For unattended use, set `HARBOR_USERNAME`, `HARBOR_TOKEN`, +`HARBOR_PULL_USERNAME`, and `HARBOR_PULL_TOKEN`; tokens have no command-line +flags because process arguments are not secret-safe. Supplying either half of +one credential pair replaces that pair and requires both values. Harmony stores them at +`/data//RegistryCredentials` and +`/data//RegistryPullCredentials`. K8sAnywhere +`Application` deploys use the pull credentials to reconcile the context's +Kubernetes image pull Secret. Harbor must enforce the robot's pull-only scope. +Harbor SSO will replace the stored push credentials when registry federation is +available. The selected Harmony context supplies the default kubeconfig path and kube context. Command flags override those defaults for the current invocation; @@ -309,9 +313,9 @@ harmony-auth tenant create acme \ ``` The command pauses after baseline validation, tenant permissions, owner access, -secret access, stored tenant state, Harbor credential storage, and between the -Kubernetes tenant and credential Scores. Each prompt names the operation it -will run next, including the target Zitadel project, OpenBao path, namespace, +secret access, stored tenant state, each Harbor credential pair, and between +the Kubernetes tenant and credential Scores. Each prompt names the operation +it will run next, including the target Zitadel project, OpenBao path, namespace, and kube context where applicable. All completed operations are logged at `INFO`, including detail between checkpoints. The administrator can test from another terminal before approving the next operation. Declining stops safely; @@ -344,6 +348,18 @@ command fails without changing access if the account already exists. If writing the credentials fails after account creation, it deletes the new account; the error reports if that cleanup also fails. +The deployer can read tenant inputs under +`/data//*`. Harmony-generated durable state is kept +separately under +`/data//harmony-state/*`, where the deployer can +create, read, and update values. It cannot write input credentials or other +tenant secrets. + +Before deploying an updated Harmony application into an existing tenant, rerun +`tenant create --apply` with the updated `harmony-auth` binary. Policy +reconciliation must grant the state subpath before the application migrates any +legacy generated values from the tenant root. + ### List tenants ```sh diff --git a/harmony/src/modules/registry_pull_secret.rs b/harmony/src/modules/registry_pull_secret.rs index 4abba3b5..5864cad4 100644 --- a/harmony/src/modules/registry_pull_secret.rs +++ b/harmony/src/modules/registry_pull_secret.rs @@ -20,31 +20,46 @@ use std::collections::BTreeMap; +use async_trait::async_trait; use base64::Engine; use k8s_openapi::ByteString; use k8s_openapi::api::core::v1::Secret as K8sSecret; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use serde::Serialize; -use crate::interpret::Interpret; -use crate::modules::k8s::resource::K8sResourceScore; +use crate::data::Version; +use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; +use crate::inventory::Inventory; use crate::score::Score; use crate::topology::{K8sclient, Topology}; +use harmony_types::id::Id; /// Creates a `kubernetes.io/dockerconfigjson` Secret in `namespace` from /// pull-only registry credentials. Idempotent — a namespaced Secret apply. -#[derive(Debug, Clone, Serialize)] +#[derive(Clone, Serialize)] pub struct RegistryPullSecretScore { pub namespace: String, /// Secret name — the same string goes in each pod's `imagePullSecrets`. pub name: String, /// Registry host the creds authenticate to, e.g. `hub.nationtech.io`. pub registry: String, + #[serde(skip_serializing)] pub username: String, /// Pull-only robot token. Serialized into the Secret, never logged. + #[serde(skip_serializing)] pub token: String, } +impl std::fmt::Debug for RegistryPullSecretScore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegistryPullSecretScore") + .field("namespace", &self.namespace) + .field("name", &self.name) + .field("registry", &self.registry) + .finish_non_exhaustive() + } +} + impl RegistryPullSecretScore { /// Build the `.dockerconfigjson` Secret. The inner `auth` field is /// `base64(user:token)` per Docker's config format; the kubelet reads it to @@ -85,7 +100,51 @@ impl Score for RegistryPullSecretScore { } fn create_interpret(&self) -> Box> { - K8sResourceScore::single(self.secret(), Some(self.namespace.clone())).create_interpret() + Box::new(RegistryPullSecretInterpret { + score: self.clone(), + }) + } +} + +#[derive(Debug, Clone)] +struct RegistryPullSecretInterpret { + score: RegistryPullSecretScore, +} + +#[async_trait] +impl Interpret for RegistryPullSecretInterpret { + async fn execute( + &self, + _inventory: &Inventory, + topology: &T, + ) -> Result { + topology + .k8s_client() + .await + .map_err(|error| InterpretError::new(format!("get Kubernetes client: {error}")))? + .apply_redacted(&self.score.secret(), Some(&self.score.namespace)) + .await + .map_err(|error| InterpretError::new(format!("apply registry pull Secret: {error}")))?; + Ok(Outcome::success(format!( + "applied registry pull Secret {}/{}", + self.score.namespace, self.score.name + ))) + } + + fn get_name(&self) -> InterpretName { + InterpretName::K8sResource + } + + fn get_version(&self) -> Version { + todo!() + } + + fn get_status(&self) -> InterpretStatus { + todo!() + } + + fn get_children(&self) -> Vec { + vec![] } } @@ -122,4 +181,12 @@ mod tests { let expected_auth = base64::engine::general_purpose::STANDARD.encode("robot$pull:s3cr3t"); assert_eq!(entry["auth"], expected_auth); } + + #[test] + fn debug_and_score_serialization_omit_credentials() { + let score = sample(); + + assert!(!format!("{score:?}").contains("s3cr3t")); + assert!(!serde_json::to_string(&score).unwrap().contains("s3cr3t")); + } } diff --git a/harmony_app/src/application/k8s_anywhere.rs b/harmony_app/src/application/k8s_anywhere.rs index 6f9d88fd..58ef6211 100644 --- a/harmony_app/src/application/k8s_anywhere.rs +++ b/harmony_app/src/application/k8s_anywhere.rs @@ -7,9 +7,11 @@ use harmony::interpret::{Interpret, InterpretError, InterpretName, InterpretStat use harmony::inventory::Inventory; use harmony::modules::k8s::resource::K8sResourceScore; use harmony::modules::postgresql::K8sPostgreSQLScore; +use harmony::modules::registry_pull_secret::RegistryPullSecretScore; use harmony::modules::zitadel::{ZitadelContract, ZitadelScore, ZitadelSetupScore}; use harmony::score::Score; use harmony::topology::{K8sAnywhereTopology, K8sclient}; +use harmony_config::ConfigError; use harmony_types::id::Id; use k8s_openapi::api::apps::v1::{ Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment, @@ -30,7 +32,7 @@ use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use serde::Serialize; use crate::{ - AppContext, AppError, AppIdentity, HarmonyApp, ImageRefs, ImageSpec, + AppContext, AppError, AppIdentity, HarmonyApp, ImageRefs, ImageSpec, RegistryPullCredentials, application::{ Application, Cpu, FileRef, HealthCheck, ImageSource, ManagedResource, ManagedTls, Memory, Protocol, PublicEndpointRef, RolloutStrategy, Route, Service, ValueRef, @@ -137,6 +139,33 @@ impl HarmonyApp for Application { let mut scores: Vec>> = Vec::new(); let mut bindings = ProviderBindings::default(); let endpoints = resolve_endpoints(self, ctx); + let migrate_zitadel_legacy_state = self + .resources + .iter() + .filter(|resource| matches!(resource, ManagedResource::Zitadel(_))) + .count() + == 1; + + if let Some(name) = ctx.image_pull_secret() { + match ctx.config_client().get::().await { + Ok(credentials) => scores.push(Box::new(RegistryPullSecretScore { + namespace: ctx.namespace().to_string(), + name: name.to_string(), + registry: ctx + .registry() + .expect("only remote contexts have image pull secrets") + .to_string(), + username: credentials.username, + token: credentials.token, + })), + Err(ConfigError::NotFound { .. }) => {} + Err(error) => { + return Err(AppError::Deploy(format!( + "loading RegistryPullCredentials: {error}" + ))); + } + } + } for resource in &self.resources { match resource { @@ -172,7 +201,8 @@ impl HarmonyApp for Application { bindings .zitadels .insert(zitadel.name.clone(), provider.issuer()); - scores.push(Box::new(deployment)); + let state = ctx.state_client(&zitadel.name, migrate_zitadel_legacy_state); + scores.push(Box::new(deployment.with_state_client(state.clone()))); let contract = lower_contract(zitadel, &endpoints)?; let mut setup = ZitadelSetupScore::for_provider( @@ -185,7 +215,9 @@ impl HarmonyApp for Application { } let setup = setup.contract(contract); bind_contract_outputs(&mut bindings, &zitadel.name, &setup); - scores.push(Box::new(setup)); + scores.push(Box::new( + setup.with_config_clients(ctx.config_client_arc(), state), + )); } } } @@ -1120,6 +1152,35 @@ mod tests { ZitadelApplicationRef, ZitadelContract, ZitadelOidcApplicationDeclaration, ZitadelProjectDeclaration, ZitadelProjectRef, }; + use harmony_config::{ConfigClass, ConfigSource}; + use std::sync::Arc; + + struct PullCredentialSource; + + #[async_trait] + impl ConfigSource for PullCredentialSource { + async fn get( + &self, + class: ConfigClass, + key: &str, + ) -> Result, ConfigError> { + assert_eq!(class, ConfigClass::Secret); + assert_eq!(key, "RegistryPullCredentials"); + Ok(Some(serde_json::json!({ + "username": "robot$pull", + "token": "secret" + }))) + } + + async fn set( + &self, + _class: ConfigClass, + _key: &str, + _value: &serde_json::Value, + ) -> Result<(), ConfigError> { + unreachable!() + } + } fn fixture() -> Application { let image = Image::new("api-image", "example/api:1"); @@ -1154,6 +1215,26 @@ mod tests { )]) } + fn remote_context(image_pull_secret: Option<&str>) -> Context { + Context { + name: "prod".parse().unwrap(), + namespace: "sample".parse().unwrap(), + spec: ContextSpec::Remote(RemoteContext { + registry: "registry.example.com".parse().unwrap(), + repository: "apps".parse().unwrap(), + domain: "example.com".parse().unwrap(), + image_pull_secret: image_pull_secret.map(|name| name.parse().unwrap()), + access: OpenBaoClusterAccess { + namespace: "sample".parse().unwrap(), + url: "https://bao.example.com".parse().unwrap(), + role: "deployer".parse().unwrap(), + zitadel_url: "https://auth.example.com".parse().unwrap(), + zitadel_audience: "harmony".parse().unwrap(), + }, + }), + } + } + fn lower_fixture( app: &Application, images: &BTreeMap, @@ -1288,6 +1369,38 @@ mod tests { ); } + #[tokio::test] + async fn pull_credentials_add_secret_score_before_the_application() { + let context = remote_context(Some("registry-auth")); + let ctx = AppContext::new( + &context, + "1.0.0".into(), + None, + Arc::new(harmony_config::ConfigClient::new(vec![Arc::new( + PullCredentialSource, + )])), + None, + None, + ); + + let scores = fixture().scores(&ctx, &ImageRefs::default()).await.unwrap(); + let names: Vec<_> = scores.iter().map(|score| score.name()).collect(); + + assert_eq!(names[0], "RegistryPullSecretScore(sample/registry-auth)"); + assert_eq!(names.last().unwrap(), "K8sAnywhereApplicationScore(sample)"); + } + + #[tokio::test] + async fn missing_pull_credentials_preserve_a_manually_managed_secret() { + let context = remote_context(Some("registry-auth")); + let ctx = AppContext::load_metadata(&context, "1.0.0", None); + + let scores = fixture().scores(&ctx, &ImageRefs::default()).await.unwrap(); + let names: Vec<_> = scores.iter().map(|score| score.name()).collect(); + + assert_eq!(names, ["K8sAnywhereApplicationScore(sample)"]); + } + #[test] fn semantic_values_lower_to_typed_provider_references() { let mut app = fixture(); @@ -1515,23 +1628,7 @@ mod tests { "web", crate::application::ImageRef::new("web"), )); - let context = Context { - name: "prod".parse().unwrap(), - namespace: "sample".parse().unwrap(), - spec: ContextSpec::Remote(RemoteContext { - registry: "registry.example.com".parse().unwrap(), - repository: "apps".parse().unwrap(), - domain: "example.com".parse().unwrap(), - image_pull_secret: None, - access: OpenBaoClusterAccess { - namespace: "sample".parse().unwrap(), - url: "https://bao.example.com".parse().unwrap(), - role: "deployer".parse().unwrap(), - zitadel_url: "https://auth.example.com".parse().unwrap(), - zitadel_audience: "harmony".parse().unwrap(), - }, - }), - }; + let context = remote_context(None); let ctx = AppContext::load_metadata(&context, "1.0.0", None); let scores = app.scores(&ctx, &ImageRefs::default()).await.unwrap(); let names: Vec<_> = scores.iter().map(|score| score.name()).collect(); diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index 69956c7b..7f43d9c8 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -56,7 +56,7 @@ pub use harmony::topology::tenant::{ResourceLimits, TenantConfig, TenantNetworkP pub use profile::Profile; pub use publish::{ ImagePublisher, ImageRefs, ImageSpec, PublicationTopology, RegistryCredentials, - is_digest_pinned, + RegistryPullCredentials, is_digest_pinned, }; pub use score::{ComposeAppScore, PublicEndpoint}; pub use tenant::{ diff --git a/harmony_app/src/publish.rs b/harmony_app/src/publish.rs index 112012be..e9e21d08 100644 --- a/harmony_app/src/publish.rs +++ b/harmony_app/src/publish.rs @@ -21,6 +21,13 @@ pub struct RegistryCredentials { pub token: String, } +#[derive(Serialize, Deserialize, JsonSchema, Config)] +#[config(secret)] +pub struct RegistryPullCredentials { + pub username: String, + pub token: String, +} + #[derive(Debug, Clone)] pub struct ImageSpec { pub name: String, @@ -618,4 +625,9 @@ mod tests { assert_eq!(credentials.username, "publisher"); assert_eq!(credentials.token, "secret"); } + + #[test] + fn registry_pull_credentials_are_secret_config() { + assert_eq!(RegistryPullCredentials::CLASS, ConfigClass::Secret); + } } diff --git a/harmony_auth_cli/src/main.rs b/harmony_auth_cli/src/main.rs index 8fd8459f..94606f25 100644 --- a/harmony_auth_cli/src/main.rs +++ b/harmony_auth_cli/src/main.rs @@ -5,8 +5,8 @@ use std::{ use clap::{Args, Parser, Subcommand, ValueEnum}; use harmony_app::{ - OpenBaoClusterAccess, RegistryCredentials, ResourceLimits, TenantConfig, - provision_application_tenant_on_context_with_progress, + OpenBaoClusterAccess, RegistryCredentials, RegistryPullCredentials, ResourceLimits, + TenantConfig, provision_application_tenant_on_context_with_progress, }; use harmony_auth::{ AuthError, AuthService, BackendAuth, ConnectionStatus, DeployerCreateRequest, @@ -216,6 +216,9 @@ struct TenantCreateArgs { /// Harbor username used to push tenant images. #[arg(long, env = "HARBOR_USERNAME")] registry_username: Option, + /// Pull-only Harbor username used by tenant workloads. + #[arg(long, env = "HARBOR_PULL_USERNAME")] + registry_pull_username: Option, /// Apply the plan. Without this flag no tenant resources are changed. #[arg(long)] apply: bool, @@ -268,9 +271,26 @@ struct TenantCreateOutput { zitadel_project: String, openbao_kv_mount: String, openbao_jwt_role: Option, + registry_pull_managed: bool, applied: bool, } +enum CredentialChange { + Existing, + Store(T), + Unmanaged, +} + +impl CredentialChange { + fn map(self, f: impl FnOnce(T) -> U) -> CredentialChange { + match self { + Self::Existing => CredentialChange::Existing, + Self::Store(value) => CredentialChange::Store(f(value)), + Self::Unmanaged => CredentialChange::Unmanaged, + } + } +} + #[derive(Serialize)] struct DeployerCreateOutput { tenant: String, @@ -417,6 +437,7 @@ async fn run(cli: &Cli) -> Result { service_limit, owner, registry_username, + registry_pull_username, apply, step_by_step, } = args.as_ref(); @@ -549,6 +570,7 @@ async fn run(cli: &Cli) -> Result { zitadel_project: config.zitadel_project.clone(), openbao_kv_mount: cli.openbao_kv_mount.clone(), openbao_jwt_role: None, + registry_pull_managed: false, applied: false, })); } @@ -565,35 +587,30 @@ async fn run(cli: &Cli) -> Result { .await .ok_or_else(|| AuthError::Backend("tenant registry store is unavailable".into()))?; let registry_client = ConfigClient::new(vec![registry_source]); - let registry_username = registry_username - .clone() - .filter(|value| !value.trim().is_empty()); - let registry_token = std::env::var("HARBOR_TOKEN") - .ok() - .filter(|value| !value.trim().is_empty()); // TODO: Replace stored Harbor credentials with Harbor SSO once registry federation is available. - let registry_credentials_exist = - if registry_username.is_none() && registry_token.is_none() { - match registry_client.get::().await { - Ok(_) => true, - Err(ConfigError::NotFound { .. }) => false, - Err(error) => { - return Err(AuthError::Backend(format!( - "loading Harbor credentials: {error}" - ))); - } - } - } else { - false - }; - let registry_credentials = if registry_credentials_exist { - None - } else { - Some(RegistryCredentials { - username: required_value(registry_username, "Harbor username", interactive)?, - token: required_secret(registry_token, "Harbor token", interactive)?, - }) - }; + let registry_credentials = pending_registry_credentials::( + ®istry_client, + registry_username.clone(), + "Harbor username", + "Harbor token", + "HARBOR_TOKEN", + interactive, + true, + ) + .await? + .map(|(username, token)| RegistryCredentials { username, token }); + let registry_pull_credentials = + pending_registry_credentials::( + ®istry_client, + registry_pull_username.clone(), + "Harbor pull-only username", + "Harbor pull-only token", + "HARBOR_PULL_TOKEN", + interactive, + false, + ) + .await? + .map(|(username, token)| RegistryPullCredentials { username, token }); // TODO: Replace direct backend orchestration with composed tenant Scores; the CLI should only resolve inputs, invoke Scores, and render progress. let request = TenantCreateRequest { @@ -613,28 +630,24 @@ async fn run(cli: &Cli) -> Result { if stored_resources.is_none() { let _ = tokio::fs::remove_file(draft_path).await; } - let registry_path = format!( - "{}/data/{}/RegistryCredentials", - cli.openbao_kv_mount, tenant.slug - ); - if let Some(registry_credentials) = registry_credentials { - if *step_by_step { - confirm_next_operation(&format!( - "Store Harbor credentials at OpenBao path '{registry_path}'" - ))?; - } - registry_client - .set(®istry_credentials) - .await - .map_err(|error| { - AuthError::Backend(format!("storing Harbor credentials: {error}")) - })?; - tracing::info!("Stored Harbor credentials at OpenBao path '{registry_path}'"); - } else { - tracing::info!( - "Harbor credentials already exist at OpenBao path '{registry_path}'" - ); - } + store_registry_credentials( + ®istry_client, + registry_credentials, + &cli.openbao_kv_mount, + &tenant.slug, + "Harbor push credentials", + *step_by_step, + ) + .await?; + let registry_pull_managed = store_registry_credentials( + ®istry_client, + registry_pull_credentials, + &cli.openbao_kv_mount, + &tenant.slug, + "Harbor pull-only credentials", + *step_by_step, + ) + .await?; let credential_operation = format!( "Create deployer RBAC and service-account credentials in namespace '{}', then store ClusterAccess at OpenBao path '{}/data/{}/ClusterAccess'", @@ -697,6 +710,7 @@ async fn run(cli: &Cli) -> Result { zitadel_project: config.zitadel_project.clone(), openbao_kv_mount: cli.openbao_kv_mount.clone(), openbao_jwt_role: Some(openbao_jwt_role), + registry_pull_managed, applied: true, })) } @@ -1122,6 +1136,12 @@ impl Output { " Registry credentials: {}/data/{tenant}/RegistryCredentials", result.openbao_kv_mount ); + if result.registry_pull_managed { + println!( + " Registry pull credentials: {}/data/{tenant}/RegistryPullCredentials", + result.openbao_kv_mount + ); + } println!(" OpenBao JWT role: {role}"); println!(); println!("CI setup:"); @@ -1268,9 +1288,73 @@ fn required_value( .map_err(|error| AuthError::Invalid(format!("prompting for {label}: {error}"))) } +async fn pending_registry_credentials( + client: &ConfigClient, + username: Option, + username_label: &str, + token_label: &str, + token_env: &str, + interactive: bool, + required: bool, +) -> Result, AuthError> { + let username = username.filter(|value| !value.trim().is_empty()); + let token = std::env::var(token_env) + .ok() + .filter(|value| !value.trim().is_empty()); + if username.is_none() && token.is_none() { + match client.get::().await { + Ok(_) => return Ok(CredentialChange::Existing), + Err(ConfigError::NotFound { .. }) if !required && !interactive => { + return Ok(CredentialChange::Unmanaged); + } + Err(ConfigError::NotFound { .. }) => {} + Err(error) => { + return Err(AuthError::Backend(format!("loading {}: {error}", T::KEY))); + } + } + } + Ok(CredentialChange::Store(( + required_value(username, username_label, interactive)?, + required_secret(token, token_label, token_env, interactive)?, + ))) +} + +async fn store_registry_credentials( + client: &ConfigClient, + credentials: CredentialChange, + mount: &str, + tenant: &str, + label: &str, + step_by_step: bool, +) -> Result { + let path = format!("{mount}/data/{tenant}/{}", T::KEY); + match credentials { + CredentialChange::Store(credentials) => { + if step_by_step { + confirm_next_operation(&format!("Store {label} at OpenBao path '{path}'"))?; + } + client + .set(&credentials) + .await + .map_err(|error| AuthError::Backend(format!("storing {label}: {error}")))?; + tracing::info!("Stored {label} at OpenBao path '{path}'"); + Ok(true) + } + CredentialChange::Existing => { + tracing::info!("{label} already exist at OpenBao path '{path}'"); + Ok(true) + } + CredentialChange::Unmanaged => { + tracing::info!("No {label} configured; leaving the Kubernetes pull Secret unmanaged"); + Ok(false) + } + } +} + fn required_secret( value: Option, label: &str, + env: &str, interactive: bool, ) -> Result { if let Some(value) = value.filter(|value| !value.trim().is_empty()) { @@ -1278,7 +1362,7 @@ fn required_secret( } if !interactive { return Err(AuthError::Invalid(format!( - "{label} is required; set HARBOR_TOKEN or use an interactive terminal" + "{label} is required; set {env} or use an interactive terminal" ))); } inquire::Password::new(label) @@ -1707,6 +1791,17 @@ mod tests { ]) .is_err() ); + assert!( + Cli::try_parse_from([ + "harmony-auth", + "tenant", + "create", + "acme", + "--registry-pull-token", + "secret", + ]) + .is_err() + ); } #[test] -- 2.39.5 From 85ec08e9e3b672b25f323eaef73c72cc7b8c1483 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 20:13:25 -0400 Subject: [PATCH 12/34] fix: fetch pinned Zitadel chart directly --- harmony/src/modules/zitadel/mod.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/harmony/src/modules/zitadel/mod.rs b/harmony/src/modules/zitadel/mod.rs index 0ef2910a..471681b6 100644 --- a/harmony/src/modules/zitadel/mod.rs +++ b/harmony/src/modules/zitadel/mod.rs @@ -33,7 +33,6 @@ use std::str::FromStr; use async_trait::async_trait; use harmony_config::{Config, ConfigError, StateClient}; -use harmony_macros::hurl; use harmony_types::id::Id; use log::{debug, error, info, trace, warn}; use non_blank_string_rs::NonBlankString; @@ -44,7 +43,7 @@ use crate::{ data::Version, interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, inventory::Inventory, - modules::helm::chart::{HelmChartScore, HelmRepository}, + modules::helm::chart::HelmChartScore, modules::{k8s::resource::K8sResourceScore, postgresql::capability::PostgreSQLRootAccountRef}, score::Score, topology::{HelmCommand, K8sclient, Topology}, @@ -1083,20 +1082,25 @@ login: // --- Step 6: Deploy Helm chart ------------------------------------ + let chart_version = "9.27.1"; + let chart_url = format!( + "https://github.com/zitadel/zitadel-charts/releases/download/zitadel-{chart_version}/zitadel-{chart_version}.tgz" + ); + info!( - "[Zitadel] Deploying Helm chart 'zitadel/zitadel' as release 'zitadel' in namespace '{}'", + "[Zitadel] Deploying Helm chart {chart_url} as release 'zitadel' in namespace '{}'", self.namespace ); let result = HelmChartScore { namespace: Some(NonBlankString::from_str(&self.namespace).unwrap()), release_name: NonBlankString::from_str("zitadel").unwrap(), - chart_name: NonBlankString::from_str("zitadel/zitadel").unwrap(), + chart_name: NonBlankString::from_str(&chart_url).unwrap(), // Pinned: newer charts ship a login UI that expects a newer // server; with v4.12.x the login pod crashloops on // Token.Invalid. 9.27.1 is the last chart matching this // server line. - chart_version: Some(NonBlankString::from_str("9.27.1").unwrap()), + chart_version: None, values_overrides: None, values_yaml: Some(values_yaml), // The namespace is ensured up front via `ensure_namespace`; don't let @@ -1111,11 +1115,7 @@ login: // explicit action.) install_only: true, force_conflicts: false, - repository: Some(HelmRepository::new( - "zitadel".to_string(), - hurl!("https://charts.zitadel.com"), - true, - )), + repository: None, } .interpret(inventory, topology) .await; -- 2.39.5 From 0a787fc257df816f577c05a1daf8000477866ec4 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 21:02:26 -0400 Subject: [PATCH 13/34] fix: use official Zitadel OCI chart --- harmony/src/modules/zitadel/mod.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/harmony/src/modules/zitadel/mod.rs b/harmony/src/modules/zitadel/mod.rs index 471681b6..6310730c 100644 --- a/harmony/src/modules/zitadel/mod.rs +++ b/harmony/src/modules/zitadel/mod.rs @@ -1082,25 +1082,22 @@ login: // --- Step 6: Deploy Helm chart ------------------------------------ - let chart_version = "9.27.1"; - let chart_url = format!( - "https://github.com/zitadel/zitadel-charts/releases/download/zitadel-{chart_version}/zitadel-{chart_version}.tgz" - ); - + let chart_name = + NonBlankString::from_str("oci://ghcr.io/zitadel/zitadel-charts/zitadel").unwrap(); info!( - "[Zitadel] Deploying Helm chart {chart_url} as release 'zitadel' in namespace '{}'", + "[Zitadel] Deploying Helm chart '{chart_name}' as release 'zitadel' in namespace '{}'", self.namespace ); let result = HelmChartScore { namespace: Some(NonBlankString::from_str(&self.namespace).unwrap()), release_name: NonBlankString::from_str("zitadel").unwrap(), - chart_name: NonBlankString::from_str(&chart_url).unwrap(), + chart_name, // Pinned: newer charts ship a login UI that expects a newer // server; with v4.12.x the login pod crashloops on // Token.Invalid. 9.27.1 is the last chart matching this // server line. - chart_version: None, + chart_version: Some(NonBlankString::from_str("9.27.1").unwrap()), values_overrides: None, values_yaml: Some(values_yaml), // The namespace is ensured up front via `ensure_namespace`; don't let -- 2.39.5 From 95edba0364b5b8e1791036ad2d253202dd43cb3d Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 23:14:35 -0400 Subject: [PATCH 14/34] fix: pull Zitadel chart from Harbor okd-cb1 cannot reach GitHub chart CDNs; use the public Harbor OCI mirror. --- harmony/src/modules/zitadel/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harmony/src/modules/zitadel/mod.rs b/harmony/src/modules/zitadel/mod.rs index 6310730c..62c311a1 100644 --- a/harmony/src/modules/zitadel/mod.rs +++ b/harmony/src/modules/zitadel/mod.rs @@ -1083,7 +1083,7 @@ login: // --- Step 6: Deploy Helm chart ------------------------------------ let chart_name = - NonBlankString::from_str("oci://ghcr.io/zitadel/zitadel-charts/zitadel").unwrap(); + NonBlankString::from_str("oci://hub.nationtech.io/harmony/zitadel").unwrap(); info!( "[Zitadel] Deploying Helm chart '{chart_name}' as release 'zitadel' in namespace '{}'", self.namespace -- 2.39.5 From 9f2edff2a6833de083396f01599ca5d93def33a5 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Fri, 31 Jul 2026 23:44:52 -0400 Subject: [PATCH 15/34] fix: skip Helm values schema network fetch Zitadel's chart schema $refs raw.githubusercontent.com; okd-cb1 cannot reach it. Scores own values correctness. --- harmony/src/modules/helm/chart.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/harmony/src/modules/helm/chart.rs b/harmony/src/modules/helm/chart.rs index cc2bc551..4e92b3bf 100644 --- a/harmony/src/modules/helm/chart.rs +++ b/harmony/src/modules/helm/chart.rs @@ -261,6 +261,10 @@ impl Interpret for HelmChartInterpret { &self.score.chart_name, "--namespace", &ns, + // Charts (e.g. Zitadel) $ref remote k8s JSON schemas; that + // fetch is not a trust boundary we own and fails offline / + // on restricted egress. Scores own values correctness. + "--skip-schema-validation", ]); if self.score.create_namespace { -- 2.39.5 From 294b7a73a9eb865c1b60bd5c6fbd1240f9723e36 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 00:23:06 -0400 Subject: [PATCH 16/34] fix: store Zitadel bootstrap secrets in harmony-state Deployer-writable path; generate missing bootstrap passwords on first human create. Note tenant NetworkPolicy ingress TODO for cert-manager. --- harmony/src/modules/zitadel/contract.rs | 6 +- harmony/src/modules/zitadel/setup.rs | 96 ++++++++++++++++++------- harmony_auth_cli/src/main.rs | 6 ++ 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/harmony/src/modules/zitadel/contract.rs b/harmony/src/modules/zitadel/contract.rs index 1e87f1f6..79a65a58 100644 --- a/harmony/src/modules/zitadel/contract.rs +++ b/harmony/src/modules/zitadel/contract.rs @@ -120,9 +120,9 @@ impl ZitadelPrincipalRef { } } -/// Named bootstrap values resolved through Harmony's configured secret source -/// (OpenBao in remote deployments). Contract declarations serialize only a -/// [`ZitadelBootstrapSecretRef`], never the password itself. +/// Named bootstrap values stored under deployer-writable `harmony-state/`. +/// Missing keys are generated on first human create. Contract declarations +/// serialize only a [`ZitadelBootstrapSecretRef`], never the password itself. #[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Config)] #[config(secret)] pub struct ZitadelBootstrapSecrets { diff --git a/harmony/src/modules/zitadel/setup.rs b/harmony/src/modules/zitadel/setup.rs index ef15cbfe..9e47d6a0 100644 --- a/harmony/src/modules/zitadel/setup.rs +++ b/harmony/src/modules/zitadel/setup.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use async_trait::async_trait; use harmony_config::{Config, ConfigClient, ConfigError, StateClient}; use log::{debug, info, warn}; +use rand::Rng; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -853,13 +854,6 @@ struct MachineSecretResponse { } impl ZitadelSetupInterpret { - async fn get_config(&self) -> Result { - match &self.config_client { - Some(client) => client.get().await, - None => harmony_config::get().await, - } - } - fn management_client(&self, pat: &str) -> Result { let client = ManagementClient::new( self.api_url(""), @@ -2276,7 +2270,8 @@ impl ZitadelSetupInterpret { /// Contract bootstrap passwords are create-only. Existing humans are /// deliberately left untouched so a later apply cannot rotate a password - /// that a person has already changed. + /// that a person has already changed. Stored under deployer-writable + /// `harmony-state/` (not tenant-root inputs). async fn ensure_contract_human( &self, client: &reqwest::Client, @@ -2292,23 +2287,8 @@ impl ZitadelSetupInterpret { { Some(id) => id, None => { - let secrets = - self.get_config::() - .await - .map_err(|error| { - InterpretError::new(format!( - "resolve Zitadel bootstrap secrets for '{email}': {error}" - )) - })?; - let bootstrap_password = secrets - .resolve(&human.bootstrap_password) - .ok_or_else(|| { - InterpretError::new(format!( - "bootstrap secret '{}' referenced by human '{email}' was not found in ZitadelBootstrapSecrets", - human.bootstrap_password.name() - )) - })?; - self.create_contract_human(client, pat, human, bootstrap_password) + let bootstrap_password = self.bootstrap_password_for(human).await?; + self.create_contract_human(client, pat, human, &bootstrap_password) .await .map_err(InterpretError::new)? } @@ -2318,6 +2298,51 @@ impl ZitadelSetupInterpret { Ok(()) } + async fn bootstrap_password_for( + &self, + human: &ZitadelHumanDeclaration, + ) -> Result { + let key = human.bootstrap_password.name(); + let email = human.human.name(); + let mut secrets = match self.get_state::().await { + Ok(secrets) => secrets, + Err(ConfigError::NotFound { .. }) => ZitadelBootstrapSecrets::new(), + Err(error) => { + return Err(InterpretError::new(format!( + "resolve Zitadel bootstrap secrets for '{email}': {error}" + ))); + } + }; + if let Some(password) = secrets.resolve(&human.bootstrap_password) { + return Ok(password.to_string()); + } + let password = generate_bootstrap_password(24); + secrets = secrets.insert(human.bootstrap_password.clone(), password.clone()); + self.set_state(&secrets).await.map_err(|error| { + InterpretError::new(format!( + "persist Zitadel bootstrap secrets for '{email}' ({key}): {error}" + )) + })?; + info!( + "[ZitadelSetup] Generated bootstrap password for '{email}' (key '{key}'); change at first sign-in" + ); + Ok(password) + } + + async fn get_state(&self) -> Result { + match &self.state_client { + Some(client) => client.get().await, + None => harmony_config::get().await, + } + } + + async fn set_state(&self, value: &T) -> Result<(), ConfigError> { + match &self.state_client { + Some(client) => client.set(value).await, + None => harmony_config::set(value).await, + } + } + /// Ensure a human user exists (create or reset its password) and holds its /// `grant_roles` on `project_name`. async fn ensure_human_user( @@ -2534,6 +2559,27 @@ impl ZitadelSetupInterpret { } } +fn generate_bootstrap_password(length: usize) -> String { + const ALPHA: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + const DIGITS: &[u8] = b"23456789"; + const SYMBOLS: &[u8] = b"!@#$%^&*-_=+"; + let mut rng = rand::rng(); + let mut chars: Vec = Vec::with_capacity(length.max(4)); + chars.push(ALPHA[rng.random_range(0..26)]); + chars.push(ALPHA[rng.random_range(26..ALPHA.len())]); + chars.push(DIGITS[rng.random_range(0..DIGITS.len())]); + chars.push(SYMBOLS[rng.random_range(0..SYMBOLS.len())]); + let pool: Vec = [ALPHA, DIGITS, SYMBOLS].concat(); + while chars.len() < length { + chars.push(pool[rng.random_range(0..pool.len())]); + } + for i in (1..chars.len()).rev() { + let j = rng.random_range(0..=i); + chars.swap(i, j); + } + String::from_utf8(chars).expect("ascii password") +} + /// Result of [`mint_device_credentials`]. pub struct MintedDeviceCredentials { pub project_id: String, diff --git a/harmony_auth_cli/src/main.rs b/harmony_auth_cli/src/main.rs index 94606f25..2740c445 100644 --- a/harmony_auth_cli/src/main.rs +++ b/harmony_auth_cli/src/main.rs @@ -659,6 +659,12 @@ async fn run(cli: &Cli) -> Result { tenant.namespace, kube_context ))?; } + // TODO(tenant-network-policy): default NetworkPolicy ingress is + // pod-local only. cert-manager HTTP-01 and public Ingress need + // ingress from the cluster edge (practically 0.0.0.0/0, or the + // ingress-controller namespace). Without that, Certificate + // issuance hangs. Wire AllowInternetIngress (or equivalent) + // into TenantNetworkPolicy defaults / tenant create flags. provision_application_tenant_on_context_with_progress( PathBuf::from(kubeconfig), kube_context, -- 2.39.5 From a8b47ccdd2ecb17d7a476b675b51fdcdf3cc339d Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 00:41:06 -0400 Subject: [PATCH 17/34] fix: treat Zitadel grant no-op as success COMMAND-Rs8fy ("User grant has not been changed") is the grant-path equivalent of COMMAND-1m88i; both are idempotent re-applies. --- harmony/src/modules/zitadel/setup.rs | 12 ++++++++--- harmony_zitadel_auth/src/management.rs | 29 +++++++++++++++++++------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/harmony/src/modules/zitadel/setup.rs b/harmony/src/modules/zitadel/setup.rs index 9e47d6a0..8423793c 100644 --- a/harmony/src/modules/zitadel/setup.rs +++ b/harmony/src/modules/zitadel/setup.rs @@ -48,7 +48,11 @@ const ZITADEL_NAMESPACE: &str = "zitadel"; /// update endpoints; we treat it as idempotent success so reconciling /// declarative state is a no-op when the state already holds. fn is_zitadel_no_changes(body: &str) -> bool { - body.contains("\"code\":9") && (body.contains("COMMAND-1m88i") || body.contains("No changes")) + body.contains("\"code\":9") + && (body.contains("COMMAND-1m88i") + || body.contains("COMMAND-Rs8fy") + || body.contains("No changes") + || body.contains("has not been changed")) } fn is_grpc_transport_unavailable(status: reqwest::StatusCode, body: &str) -> bool { @@ -3773,13 +3777,15 @@ mod tests { // config matches the stored value byte-for-byte. let body = r#"{"code":9, "message":"No changes (COMMAND-1m88i)", "details":[{"@type":"type.googleapis.com/zitadel.v1.ErrorDetail", "id":"COMMAND-1m88i", "message":"No changes"}]}"#; assert!(is_zitadel_no_changes(body)); + let grant = r#"{"code":9, "message":"User grant has not been changed (COMMAND-Rs8fy)", "details":[{"@type":"type.googleapis.com/zitadel.v1.ErrorDetail", "id":"COMMAND-Rs8fy", "message":"User grant has not been changed"}]}"#; + assert!(is_zitadel_no_changes(grant)); } #[test] fn zitadel_no_changes_rejects_unrelated_code_9() { // gRPC code 9 (FAILED_PRECONDITION) covers many things; only - // the COMMAND-1m88i / "No changes" specific signature should - // be treated as idempotent success. + // the no-change command signatures should be treated as + // idempotent success. let body = r#"{"code":9,"message":"resource exhausted","details":[{"id":"OTHER-xyz"}]}"#; assert!(!is_zitadel_no_changes(body)); } diff --git a/harmony_zitadel_auth/src/management.rs b/harmony_zitadel_auth/src/management.rs index 010e7bf7..2fc97262 100644 --- a/harmony_zitadel_auth/src/management.rs +++ b/harmony_zitadel_auth/src/management.rs @@ -363,12 +363,20 @@ impl ManagementClient { } if roles.len() != original_role_count { let path = format!("/management/v1/users/{user_id}/grants/{}", grant.id); - self.response( - "user grant update", - self.request(Method::PUT, &path) - .json(&json!({ "roleKeys": roles })), - ) - .await?; + let response = self + .request(Method::PUT, &path) + .json(&json!({ "roleKeys": roles })) + .send() + .await?; + let status = response.status(); + let body = response.text().await?; + if !status.is_success() && !is_no_changes(&body) { + return Err(ManagementError::Api { + operation: "user grant update", + status, + body, + }); + } } return Ok(grant.id); } @@ -530,7 +538,14 @@ struct MachineKeyResponse { } fn is_no_changes(body: &str) -> bool { - body.contains("\"code\":9") && (body.contains("COMMAND-1m88i") || body.contains("No changes")) + // Zitadel returns gRPC 9 FAILED_PRECONDITION with several command + // ids when a PUT matches stored state (OIDC config: COMMAND-1m88i; + // user grants: COMMAND-Rs8fy). Treat all as idempotent success. + body.contains("\"code\":9") + && (body.contains("COMMAND-1m88i") + || body.contains("COMMAND-Rs8fy") + || body.contains("No changes") + || body.contains("has not been changed")) } #[derive(Deserialize)] -- 2.39.5 From dde3be22c7a02615c818b8b682d53ac4e6b60c37 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 01:04:09 -0400 Subject: [PATCH 18/34] feat: pin ManagedPostgres version onto CNPG image --- harmony/src/modules/postgresql/capability.rs | 2 ++ harmony/src/modules/postgresql/failover.rs | 2 ++ harmony/src/modules/postgresql/score_k8s.rs | 36 ++++++++++++++++++++ harmony_app/src/application/k8s_anywhere.rs | 1 + harmony_app/src/application/resources.rs | 8 +++++ 5 files changed, 49 insertions(+) diff --git a/harmony/src/modules/postgresql/capability.rs b/harmony/src/modules/postgresql/capability.rs index f55f5241..b8dbb610 100644 --- a/harmony/src/modules/postgresql/capability.rs +++ b/harmony/src/modules/postgresql/capability.rs @@ -63,6 +63,7 @@ pub struct PostgreSQLConfig { pub cluster_name: String, pub instances: u32, pub storage_size: StorageSize, + pub version: Option, pub role: PostgreSQLClusterRole, /// **Note :** on OpenShfit based clusters, the namespace `default` has security /// settings incompatible with the default CNPG behavior. @@ -90,6 +91,7 @@ impl Default for PostgreSQLConfig { role: PostgreSQLClusterRole::Primary, namespace: "harmony".to_string(), wait_for_ready: true, + version: None, } } } diff --git a/harmony/src/modules/postgresql/failover.rs b/harmony/src/modules/postgresql/failover.rs index 66dd120f..40ab4881 100644 --- a/harmony/src/modules/postgresql/failover.rs +++ b/harmony/src/modules/postgresql/failover.rs @@ -30,6 +30,7 @@ impl PostgreSQL for FailoverTopology { role: PostgreSQLClusterRole::Primary, namespace: config.namespace.clone(), wait_for_ready: config.wait_for_ready, + version: config.version.clone(), }; info!( @@ -146,6 +147,7 @@ impl PostgreSQL for FailoverTopology { role: PostgreSQLClusterRole::Replica(replica_cluster_config), namespace: config.namespace.clone(), wait_for_ready: config.wait_for_ready, + version: config.version.clone(), }; info!( diff --git a/harmony/src/modules/postgresql/score_k8s.rs b/harmony/src/modules/postgresql/score_k8s.rs index e607fb6e..9c0d43c1 100644 --- a/harmony/src/modules/postgresql/score_k8s.rs +++ b/harmony/src/modules/postgresql/score_k8s.rs @@ -66,6 +66,11 @@ impl K8sPostgreSQLScore { self } + pub fn version(mut self, version: impl Into) -> Self { + self.config.version = Some(version.into()); + self + } + pub fn root_account_ref(&self) -> PostgreSQLRootAccountRef { PostgreSQLRootAccountRef { host: format!( @@ -298,6 +303,7 @@ impl Interpret for K8sPostgr let spec = ClusterSpec { instances: self.config.instances, + image_name: cnpg_image_name(self.config.version.as_deref()), storage: Storage { size: self.config.storage_size.to_string(), }, @@ -387,6 +393,7 @@ impl Interpret for K8sPostgr let spec = ClusterSpec { instances: self.config.instances, + image_name: cnpg_image_name(self.config.version.as_deref()), storage: Storage { size: self.config.storage_size.to_string(), }, @@ -453,6 +460,22 @@ impl Interpret for K8sPostgr } } +/// Map a declared PG version to CNPG `spec.imageName`. +/// - `None` → operator default image +/// - `"16"` / `"16.4"` → `ghcr.io/cloudnative-pg/postgresql:` +/// - value containing `/` → used as a full image reference +fn cnpg_image_name(version: Option<&str>) -> Option { + let version = version?.trim(); + if version.is_empty() { + return None; + } + if version.contains('/') { + Some(version.to_string()) + } else { + Some(format!("ghcr.io/cloudnative-pg/postgresql:{version}")) + } +} + #[cfg(test)] mod tests { use super::*; @@ -474,4 +497,17 @@ mod tests { } ); } + + #[test] + fn cnpg_image_name_maps_tags_and_full_refs() { + assert_eq!(cnpg_image_name(None), None); + assert_eq!( + cnpg_image_name(Some("16")).as_deref(), + Some("ghcr.io/cloudnative-pg/postgresql:16") + ); + assert_eq!( + cnpg_image_name(Some("ghcr.io/example/pg:18")).as_deref(), + Some("ghcr.io/example/pg:18") + ); + } } diff --git a/harmony_app/src/application/k8s_anywhere.rs b/harmony_app/src/application/k8s_anywhere.rs index 58ef6211..7969f582 100644 --- a/harmony_app/src/application/k8s_anywhere.rs +++ b/harmony_app/src/application/k8s_anywhere.rs @@ -173,6 +173,7 @@ impl HarmonyApp for Application { let mut score = K8sPostgreSQLScore::new(ctx.namespace()).cluster_name(&database.name); score.config.instances = database.instances; + score.config.version = database.version.clone(); bindings.databases.insert( database.name.clone(), application_database_binding(&database.name), diff --git a/harmony_app/src/application/resources.rs b/harmony_app/src/application/resources.rs index dedfee97..453bf881 100644 --- a/harmony_app/src/application/resources.rs +++ b/harmony_app/src/application/resources.rs @@ -16,6 +16,7 @@ pub enum ManagedResource { pub struct ManagedPostgres { pub name: String, pub instances: u32, + pub version: Option, } impl ManagedPostgres { @@ -23,12 +24,19 @@ impl ManagedPostgres { Self { name: name.into(), instances: 1, + version: None, } } pub fn instances(mut self, instances: u32) -> Self { self.instances = instances; self } + /// PostgreSQL major/minor tag (e.g. `"16"` / `"16.4"`) or a full + /// container image. Mapped to CNPG `spec.imageName`. + pub fn version(mut self, version: impl Into) -> Self { + self.version = Some(version.into()); + self + } pub fn reference(&self) -> DatabaseRef { DatabaseRef(self.name.clone()) } -- 2.39.5 From 4dc86f84d9343c32110c4e0a16b801ad34ee71db Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 01:36:50 -0400 Subject: [PATCH 19/34] fix: poll deployment readiness instead of fragile watches --- harmony-k8s/src/resources.rs | 47 ++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/harmony-k8s/src/resources.rs b/harmony-k8s/src/resources.rs index 2fc01e00..ec876d53 100644 --- a/harmony-k8s/src/resources.rs +++ b/harmony-k8s/src/resources.rs @@ -10,8 +10,6 @@ use kube::{ Error, Resource, api::{Api, DynamicObject, GroupVersionKind, ListParams, ObjectList}, core::ErrorResponse, - runtime::conditions, - runtime::wait::await_condition, }; use log::{debug, info}; use serde::de::DeserializeOwned; @@ -276,15 +274,25 @@ impl K8sClient { namespace: Option<&str>, timeout: Option, ) -> Result<(), String> { - let api: Api = match namespace { - Some(ns) => Api::namespaced(self.client.clone(), ns), - None => Api::default_namespaced(self.client.clone()), - }; let timeout = timeout.unwrap_or(Duration::from_secs(120)); - let establish = await_condition(api, name, conditions::is_deployment_completed()); - match tokio::time::timeout(timeout, establish).await { - Ok(Ok(_)) => Ok(()), - Ok(Err(error)) => Err(format!("Failed waiting for deployment {name}: {error}")), + match tokio::time::timeout(timeout, async { + loop { + match self.get_resource::(name, namespace).await { + Ok(Some(deployment)) if deployment_rollout_complete(&deployment) => { + return Ok(()); + } + Ok(_) => {} + // Transient API blips (watch/stream drops) — keep polling. + Err(error) => { + debug!("waiting for deployment {name}: {error}"); + } + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }) + .await + { + Ok(result) => result, Err(_) => Err(format!( "Timed out after {}s waiting for deployment {name}", timeout.as_secs() @@ -522,3 +530,22 @@ impl K8sClient { } } } + +/// Same criteria as `kubectl rollout status` / kube-rs `is_deployment_completed`. +fn deployment_rollout_complete(deployment: &Deployment) -> bool { + let desired = deployment + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(1); + let Some(status) = deployment.status.as_ref() else { + return false; + }; + let observed = status.observed_generation.unwrap_or(0); + let generation = deployment.metadata.generation.unwrap_or(0); + observed >= generation + && status.updated_replicas.unwrap_or(0) >= desired + && status.ready_replicas.unwrap_or(0) >= desired + && status.available_replicas.unwrap_or(0) >= desired + && status.replicas.unwrap_or(0) <= desired +} -- 2.39.5 From 40c3f92bc6e727c492c522aabc3485464982c112 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 08:01:37 -0400 Subject: [PATCH 20/34] feat: ManagedBucket via ObjectBucketClaim credentials --- harmony/src/modules/storage/mod.rs | 3 + harmony/src/modules/storage/object_bucket.rs | 314 +++++++++++++++++++ harmony_app/src/application/k8s_anywhere.rs | 39 +++ harmony_app/src/application/mod.rs | 3 +- harmony_app/src/application/model.rs | 14 +- harmony_app/src/application/resources.rs | 64 ++++ harmony_app/src/application/validation.rs | 25 ++ harmony_app/src/lib.rs | 10 +- 8 files changed, 465 insertions(+), 7 deletions(-) create mode 100644 harmony/src/modules/storage/object_bucket.rs diff --git a/harmony/src/modules/storage/mod.rs b/harmony/src/modules/storage/mod.rs index ee3e235e..1fb6b555 100644 --- a/harmony/src/modules/storage/mod.rs +++ b/harmony/src/modules/storage/mod.rs @@ -1 +1,4 @@ pub mod ceph; +pub mod object_bucket; + +pub use object_bucket::ObjectBucketScore; diff --git a/harmony/src/modules/storage/object_bucket.rs b/harmony/src/modules/storage/object_bucket.rs new file mode 100644 index 00000000..310ff173 --- /dev/null +++ b/harmony/src/modules/storage/object_bucket.rs @@ -0,0 +1,314 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use async_trait::async_trait; +use k8s_openapi::ByteString; +use k8s_openapi::api::core::v1::{ConfigMap, Secret}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::CustomResource; +use log::{debug, info}; +use serde::{Deserialize, Serialize}; + +use crate::data::Version; +use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; +use crate::inventory::Inventory; +use crate::modules::k8s::resource::K8sResourceScore; +use crate::score::Score; +use crate::topology::{K8sclient, Topology}; +use harmony_types::id::Id; + +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, Default)] +#[kube( + group = "objectbucket.io", + version = "v1alpha1", + kind = "ObjectBucketClaim", + plural = "objectbucketclaims", + namespaced = true, + schema = "disabled" +)] +#[serde(rename_all = "camelCase")] +pub struct ObjectBucketClaimSpec { + #[serde(skip_serializing_if = "Option::is_none")] + pub bucket_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_bucket_name: Option, + pub storage_class_name: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub additional_config: BTreeMap, +} + +/// Provisions a Rook/lib-bucket-provisioner ObjectBucketClaim and synthesizes an +/// app-facing credentials Secret (`{name}-app`) with endpoint/bucket/keys. +#[derive(Debug, Clone, Serialize)] +pub struct ObjectBucketScore { + pub name: String, + pub namespace: String, + pub storage_class: String, + pub max_size: String, +} + +impl ObjectBucketScore { + pub fn new(namespace: impl Into, name: impl Into) -> Self { + Self { + name: name.into(), + namespace: namespace.into(), + storage_class: "ceph-bucket".into(), + max_size: "10G".into(), + } + } + + pub fn storage_class(mut self, storage_class: impl Into) -> Self { + self.storage_class = storage_class.into(); + self + } + + pub fn max_size(mut self, max_size: impl Into) -> Self { + self.max_size = max_size.into(); + self + } + + pub fn app_secret_name(&self) -> String { + format!("{}-app", self.name) + } + + fn claim(&self) -> ObjectBucketClaim { + let mut additional_config = BTreeMap::new(); + additional_config.insert("maxSize".into(), self.max_size.clone()); + ObjectBucketClaim { + metadata: ObjectMeta { + name: Some(self.name.clone()), + namespace: Some(self.namespace.clone()), + ..ObjectMeta::default() + }, + spec: ObjectBucketClaimSpec { + // Fixed name so re-applies stay idempotent. + bucket_name: Some(self.name.clone()), + generate_bucket_name: None, + storage_class_name: self.storage_class.clone(), + additional_config, + }, + } + } +} + +impl Default for ObjectBucketClaim { + fn default() -> Self { + Self { + metadata: ObjectMeta::default(), + spec: ObjectBucketClaimSpec::default(), + } + } +} + +impl Score for ObjectBucketScore { + fn create_interpret(&self) -> Box> { + Box::new(ObjectBucketInterpret { + score: self.clone(), + }) + } + + fn name(&self) -> String { + format!("ObjectBucketScore({}/{})", self.namespace, self.name) + } +} + +#[derive(Debug, Clone)] +struct ObjectBucketInterpret { + score: ObjectBucketScore, +} + +#[async_trait] +impl Interpret for ObjectBucketInterpret { + async fn execute( + &self, + inventory: &Inventory, + topology: &T, + ) -> Result { + let client = topology + .k8s_client() + .await + .map_err(|e| InterpretError::new(format!("get k8s client: {e}")))?; + client + .ensure_namespace(&self.score.namespace) + .await + .map_err(|e| InterpretError::new(format!("ensure namespace: {e}")))?; + + K8sResourceScore::single(self.score.claim(), Some(self.score.namespace.clone())) + .create_interpret() + .execute(inventory, topology) + .await?; + + let (config, provisioner_secret) = wait_for_claim_outputs( + client.as_ref(), + &self.score.namespace, + &self.score.name, + Duration::from_secs(180), + ) + .await?; + + let app_secret = synthesize_app_secret( + &self.score.namespace, + &self.score.app_secret_name(), + &config, + &provisioner_secret, + )?; + client + .apply_redacted(&app_secret, Some(&self.score.namespace)) + .await + .map_err(|e| InterpretError::new(format!("apply bucket credentials Secret: {e}")))?; + + Ok(Outcome::success(format!( + "object bucket '{}/{}' ready", + self.score.namespace, self.score.name + ))) + } + + fn get_name(&self) -> InterpretName { + InterpretName::Custom("ObjectBucketInterpret") + } + + fn get_version(&self) -> Version { + todo!() + } + + fn get_status(&self) -> InterpretStatus { + todo!() + } + + fn get_children(&self) -> Vec { + todo!() + } +} + +async fn wait_for_claim_outputs( + client: &harmony_k8s::K8sClient, + namespace: &str, + name: &str, + timeout: Duration, +) -> Result<(ConfigMap, Secret), InterpretError> { + let start = std::time::Instant::now(); + info!( + "Waiting for ObjectBucketClaim '{namespace}/{name}' credentials (up to {}s)...", + timeout.as_secs() + ); + loop { + let cm = client + .get_resource::(name, Some(namespace)) + .await + .map_err(|e| InterpretError::new(format!("get OBC ConfigMap: {e}")))?; + let secret = client + .get_resource::(name, Some(namespace)) + .await + .map_err(|e| InterpretError::new(format!("get OBC Secret: {e}")))?; + if let (Some(cm), Some(secret)) = (cm, secret) + && config_has_bucket(&cm) + && secret_has_keys(&secret) + { + info!("ObjectBucketClaim '{namespace}/{name}' credentials ready"); + return Ok((cm, secret)); + } + debug!("ObjectBucketClaim '{namespace}/{name}' credentials not ready yet"); + if start.elapsed() > timeout { + return Err(InterpretError::new(format!( + "timed out waiting for ObjectBucketClaim '{namespace}/{name}' credentials after {}s", + timeout.as_secs() + ))); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + +fn config_has_bucket(cm: &ConfigMap) -> bool { + cm.data + .as_ref() + .is_some_and(|data| data.contains_key("BUCKET_NAME") && data.contains_key("BUCKET_HOST")) +} + +fn secret_has_keys(secret: &Secret) -> bool { + secret.data.as_ref().is_some_and(|data| { + data.contains_key("AWS_ACCESS_KEY_ID") && data.contains_key("AWS_SECRET_ACCESS_KEY") + }) +} + +fn synthesize_app_secret( + namespace: &str, + name: &str, + config: &ConfigMap, + provisioner: &Secret, +) -> Result { + let data = config + .data + .as_ref() + .ok_or_else(|| InterpretError::new("OBC ConfigMap has no data".to_string()))?; + let host = data + .get("BUCKET_HOST") + .ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_HOST".to_string()))?; + let port = data.get("BUCKET_PORT").map(String::as_str).unwrap_or("80"); + let bucket = data + .get("BUCKET_NAME") + .ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_NAME".to_string()))?; + let region = data + .get("BUCKET_REGION") + .map(String::as_str) + .unwrap_or("us-east-1"); + let scheme = if port == "443" { "https" } else { "http" }; + let endpoint = if port == "80" || port == "443" { + format!("{scheme}://{host}") + } else { + format!("{scheme}://{host}:{port}") + }; + + let keys = provisioner + .data + .as_ref() + .ok_or_else(|| InterpretError::new("OBC Secret has no data".to_string()))?; + let access = keys + .get("AWS_ACCESS_KEY_ID") + .ok_or_else(|| InterpretError::new("OBC Secret missing AWS_ACCESS_KEY_ID".to_string()))? + .clone(); + let secret = keys + .get("AWS_SECRET_ACCESS_KEY") + .ok_or_else(|| InterpretError::new("OBC Secret missing AWS_SECRET_ACCESS_KEY".to_string()))? + .clone(); + + Ok(Secret { + metadata: ObjectMeta { + name: Some(name.to_string()), + namespace: Some(namespace.to_string()), + ..ObjectMeta::default() + }, + type_: Some("Opaque".into()), + data: Some(BTreeMap::from([ + ("endpoint".into(), ByteString(endpoint.into_bytes())), + ("bucket".into(), ByteString(bucket.as_bytes().to_vec())), + ("region".into(), ByteString(region.as_bytes().to_vec())), + // Ceph RGW expects path-style addressing. + ("path-style".into(), ByteString(b"true".to_vec())), + ("access-key".into(), access), + ("secret-key".into(), secret), + ])), + ..Default::default() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claim_pins_bucket_name_and_max_size() { + let score = ObjectBucketScore::new("ns", "recipe1-files").max_size("10G"); + let claim = score.claim(); + assert_eq!(claim.spec.bucket_name.as_deref(), Some("recipe1-files")); + assert_eq!(claim.spec.storage_class_name, "ceph-bucket"); + assert_eq!( + claim + .spec + .additional_config + .get("maxSize") + .map(String::as_str), + Some("10G") + ); + assert_eq!(score.app_secret_name(), "recipe1-files-app"); + } +} diff --git a/harmony_app/src/application/k8s_anywhere.rs b/harmony_app/src/application/k8s_anywhere.rs index 7969f582..76f7f170 100644 --- a/harmony_app/src/application/k8s_anywhere.rs +++ b/harmony_app/src/application/k8s_anywhere.rs @@ -8,6 +8,7 @@ use harmony::inventory::Inventory; use harmony::modules::k8s::resource::K8sResourceScore; use harmony::modules::postgresql::K8sPostgreSQLScore; use harmony::modules::registry_pull_secret::RegistryPullSecretScore; +use harmony::modules::storage::ObjectBucketScore; use harmony::modules::zitadel::{ZitadelContract, ZitadelScore, ZitadelSetupScore}; use harmony::score::Score; use harmony::topology::{K8sAnywhereTopology, K8sclient}; @@ -180,6 +181,18 @@ impl HarmonyApp for Application { ); scores.push(Box::new(score)); } + ManagedResource::Bucket(bucket) => { + let score = ObjectBucketScore::new(ctx.namespace(), &bucket.name) + .storage_class(&bucket.storage_class) + .max_size(&bucket.max_size); + bindings.buckets.insert( + bucket.name.clone(), + BucketBinding { + secret: score.app_secret_name(), + }, + ); + scores.push(Box::new(score)); + } ManagedResource::Zitadel(zitadel) => { let database = K8sPostgreSQLScore::new(ctx.namespace()) .cluster_name(format!("{}-db", zitadel.name)); @@ -377,9 +390,15 @@ struct KeyBinding { key: String, } +#[derive(Debug, Clone)] +struct BucketBinding { + secret: String, +} + #[derive(Debug, Clone, Default)] struct ProviderBindings { databases: BTreeMap, + buckets: BTreeMap, zitadels: BTreeMap, projects: BTreeMap<(String, String), KeyBinding>, applications: BTreeMap<(String, String, String), KeyBinding>, @@ -504,6 +523,26 @@ fn deployment( }; variable.value_from = Some(secret_value(&binding.secret, key)); } + ValueRef::BucketEndpoint(reference) + | ValueRef::BucketName(reference) + | ValueRef::BucketAccessKey(reference) + | ValueRef::BucketSecretKey(reference) + | ValueRef::BucketRegion(reference) + | ValueRef::BucketPathStyle(reference) => { + let binding = bindings.buckets.get(reference.name()).ok_or_else(|| { + AppError::InvalidComposition(format!("unknown bucket '{}'", reference.name())) + })?; + let key = match value { + ValueRef::BucketEndpoint(_) => "endpoint", + ValueRef::BucketName(_) => "bucket", + ValueRef::BucketAccessKey(_) => "access-key", + ValueRef::BucketSecretKey(_) => "secret-key", + ValueRef::BucketRegion(_) => "region", + ValueRef::BucketPathStyle(_) => "path-style", + _ => unreachable!(), + }; + variable.value_from = Some(secret_value(&binding.secret, key)); + } ValueRef::ZitadelIssuer(reference) | ValueRef::ZitadelManagementUrl(reference) => { variable.value = Some( bindings diff --git a/harmony_app/src/application/mod.rs b/harmony_app/src/application/mod.rs index a21ce5f0..efe74cab 100644 --- a/harmony_app/src/application/mod.rs +++ b/harmony_app/src/application/mod.rs @@ -14,6 +14,7 @@ pub use model::{ ValueRef, }; pub use resources::{ - DatabaseRef, ManagedPostgres, ManagedResource, ManagedZitadel, OidcRedirect, ZitadelRef, + BucketRef, DatabaseRef, ManagedBucket, ManagedPostgres, ManagedResource, ManagedZitadel, + OidcRedirect, ZitadelRef, }; pub use validation::ApplicationValidationError; diff --git a/harmony_app/src/application/model.rs b/harmony_app/src/application/model.rs index 72c8e21a..5d6e500f 100644 --- a/harmony_app/src/application/model.rs +++ b/harmony_app/src/application/model.rs @@ -3,7 +3,7 @@ use std::time::Duration; use serde::Serialize; -use super::{ApplicationValidationError, DatabaseRef, ManagedResource, ZitadelRef}; +use super::{ApplicationValidationError, BucketRef, DatabaseRef, ManagedResource, ZitadelRef}; use harmony::modules::zitadel::{ZitadelApplicationRef, ZitadelMachineRef, ZitadelProjectRef}; /// A topology-neutral application declaration. K8sAnywhere is currently its first adapter. @@ -310,6 +310,12 @@ pub enum ValueRef { DatabaseJdbcUrl(DatabaseRef), DatabaseUsername(DatabaseRef), DatabasePassword(DatabaseRef), + BucketEndpoint(BucketRef), + BucketName(BucketRef), + BucketAccessKey(BucketRef), + BucketSecretKey(BucketRef), + BucketRegion(BucketRef), + BucketPathStyle(BucketRef), ZitadelIssuer(ZitadelRef), ZitadelManagementUrl(ZitadelRef), OidcProjectId { @@ -499,6 +505,12 @@ impl From for ManagedResource { } } +impl From for ManagedResource { + fn from(value: super::ManagedBucket) -> Self { + Self::Bucket(value) + } +} + impl From for ManagedResource { fn from(value: super::ManagedZitadel) -> Self { Self::Zitadel(value) diff --git a/harmony_app/src/application/resources.rs b/harmony_app/src/application/resources.rs index 453bf881..6b84b740 100644 --- a/harmony_app/src/application/resources.rs +++ b/harmony_app/src/application/resources.rs @@ -9,9 +9,73 @@ use super::{FileRef, PublicEndpointRef, ValueRef}; #[derive(Debug, Clone, Serialize)] pub enum ManagedResource { Postgres(ManagedPostgres), + Bucket(ManagedBucket), Zitadel(ManagedZitadel), } +/// S3-compatible object bucket (Rook ObjectBucketClaim / ceph-bucket by default). +#[derive(Debug, Clone, Serialize)] +pub struct ManagedBucket { + pub name: String, + pub storage_class: String, + /// Rook `additionalConfig.maxSize` (e.g. `"10G"`). + pub max_size: String, +} + +impl ManagedBucket { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + storage_class: "ceph-bucket".into(), + max_size: "10G".into(), + } + } + + pub fn storage_class(mut self, storage_class: impl Into) -> Self { + self.storage_class = storage_class.into(); + self + } + + pub fn max_size(mut self, max_size: impl Into) -> Self { + self.max_size = max_size.into(); + self + } + + pub fn reference(&self) -> BucketRef { + BucketRef(self.name.clone()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct BucketRef(pub(crate) String); + +impl BucketRef { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + pub fn name(&self) -> &str { + &self.0 + } + pub fn endpoint(&self) -> ValueRef { + ValueRef::BucketEndpoint(self.clone()) + } + pub fn bucket(&self) -> ValueRef { + ValueRef::BucketName(self.clone()) + } + pub fn access_key(&self) -> ValueRef { + ValueRef::BucketAccessKey(self.clone()) + } + pub fn secret_key(&self) -> ValueRef { + ValueRef::BucketSecretKey(self.clone()) + } + pub fn region(&self) -> ValueRef { + ValueRef::BucketRegion(self.clone()) + } + pub fn path_style(&self) -> ValueRef { + ValueRef::BucketPathStyle(self.clone()) + } +} + #[derive(Debug, Clone, Serialize)] pub struct ManagedPostgres { pub name: String, diff --git a/harmony_app/src/application/validation.rs b/harmony_app/src/application/validation.rs index 59f90a05..b9ca69e2 100644 --- a/harmony_app/src/application/validation.rs +++ b/harmony_app/src/application/validation.rs @@ -87,6 +87,7 @@ pub(crate) fn validate(app: &Application) -> Result<(), ApplicationValidationErr }); } let mut databases = BTreeSet::new(); + let mut buckets = BTreeSet::new(); let mut zitadels = BTreeMap::new(); for resource in &app.resources { match resource { @@ -99,6 +100,17 @@ pub(crate) fn validate(app: &Application) -> Result<(), ApplicationValidationErr }); } } + ManagedResource::Bucket(bucket) => { + non_empty(&bucket.name, "bucket name")?; + non_empty(&bucket.storage_class, "bucket storage class")?; + non_empty(&bucket.max_size, "bucket max size")?; + if !buckets.insert(bucket.name.as_str()) { + return Err(ApplicationValidationError::Duplicate { + kind: "bucket", + name: bucket.name.clone(), + }); + } + } ManagedResource::Zitadel(zitadel) => { non_empty(&zitadel.name, "Zitadel name")?; if zitadels.contains_key(zitadel.name.as_str()) { @@ -260,6 +272,19 @@ pub(crate) fn validate(app: &Application) -> Result<(), ApplicationValidationErr }); } } + ValueRef::BucketEndpoint(reference) + | ValueRef::BucketName(reference) + | ValueRef::BucketAccessKey(reference) + | ValueRef::BucketSecretKey(reference) + | ValueRef::BucketRegion(reference) + | ValueRef::BucketPathStyle(reference) => { + if !buckets.contains(reference.name()) { + return Err(ApplicationValidationError::UnknownResource { + kind: "bucket", + name: reference.name().to_string(), + }); + } + } ValueRef::ZitadelIssuer(reference) | ValueRef::ZitadelManagementUrl(reference) => { validate_zitadel(&zitadels, reference.name())?; } diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index 7f43d9c8..2e7e4534 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -36,11 +36,11 @@ pub use app::{ logs, ship, ship_with_options, status, }; pub use application::{ - Application, ApplicationValidationError, Command, Cpu, DatabaseRef, FileRef, HealthCheck, - Image, ImageBuild, ImageRef, ImageSource, LogicalEndpoint, ManagedPostgres, ManagedResource, - ManagedTls, ManagedZitadel, Memory, OidcRedirect, Port, PortRef, Protocol, PublicEndpointRef, - ReadinessIntent, ResourceIntent, RolloutIntent, RolloutStrategy, Route, Service, ServiceRef, - ValueRef, ZitadelRef, zitadel, + Application, ApplicationValidationError, BucketRef, Command, Cpu, DatabaseRef, FileRef, + HealthCheck, Image, ImageBuild, ImageRef, ImageSource, LogicalEndpoint, ManagedBucket, + ManagedPostgres, ManagedResource, ManagedTls, ManagedZitadel, Memory, OidcRedirect, Port, + PortRef, Protocol, PublicEndpointRef, ReadinessIntent, ResourceIntent, RolloutIntent, + RolloutStrategy, Route, Service, ServiceRef, ValueRef, ZitadelRef, zitadel, }; pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth}; pub use chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image}; -- 2.39.5 From c3c04fd6f97beb7209d9499e425c37d4be7d5331 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 08:39:22 -0400 Subject: [PATCH 21/34] feat: grant tenants ObjectBucketClaim RBAC --- harmony_app/src/tenant.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/harmony_app/src/tenant.rs b/harmony_app/src/tenant.rs index e5ffdb3a..c8c24e76 100644 --- a/harmony_app/src/tenant.rs +++ b/harmony_app/src/tenant.rs @@ -187,6 +187,7 @@ fn application_deployer_rules() -> Vec { verbs(), ), rule("postgresql.cnpg.io", &["clusters"], verbs()), + rule("objectbucket.io", &["objectbucketclaims"], verbs()), ] } @@ -204,12 +205,16 @@ mod tests { use super::*; #[test] - fn application_deployer_can_manage_cnpg_without_fleet_permissions() { + fn application_deployer_can_manage_cnpg_and_obc_without_fleet_permissions() { let rules = application_deployer_rules(); assert!(rules.iter().any(|rule| { rule.api_groups.as_deref() == Some(&["postgresql.cnpg.io".to_string()]) && rule.resources.as_deref() == Some(&["clusters".to_string()]) })); + assert!(rules.iter().any(|rule| { + rule.api_groups.as_deref() == Some(&["objectbucket.io".to_string()]) + && rule.resources.as_deref() == Some(&["objectbucketclaims".to_string()]) + })); assert!(!rules.iter().any(|rule| { rule.api_groups .as_ref() -- 2.39.5 From 61260612cb974948502987f874f9097760c43158 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 08:51:29 -0400 Subject: [PATCH 22/34] fix: default empty OBC region to us-east-1 --- harmony/src/modules/storage/object_bucket.rs | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/harmony/src/modules/storage/object_bucket.rs b/harmony/src/modules/storage/object_bucket.rs index 310ff173..ace07400 100644 --- a/harmony/src/modules/storage/object_bucket.rs +++ b/harmony/src/modules/storage/object_bucket.rs @@ -247,9 +247,11 @@ fn synthesize_app_secret( let bucket = data .get("BUCKET_NAME") .ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_NAME".to_string()))?; + // Rook often leaves BUCKET_REGION empty; S3 SDKs / SmallRye still need a non-empty value. let region = data .get("BUCKET_REGION") .map(String::as_str) + .filter(|value| !value.trim().is_empty()) .unwrap_or("us-east-1"); let scheme = if port == "443" { "https" } else { "http" }; let endpoint = if port == "80" || port == "443" { @@ -311,4 +313,27 @@ mod tests { ); assert_eq!(score.app_secret_name(), "recipe1-files-app"); } + + #[test] + fn empty_bucket_region_defaults_to_us_east_1() { + let cm = ConfigMap { + data: Some(BTreeMap::from([ + ("BUCKET_HOST".into(), "rgw.svc".into()), + ("BUCKET_PORT".into(), "80".into()), + ("BUCKET_NAME".into(), "files".into()), + ("BUCKET_REGION".into(), "".into()), + ])), + ..Default::default() + }; + let provisioner = Secret { + data: Some(BTreeMap::from([ + ("AWS_ACCESS_KEY_ID".into(), ByteString(b"ak".to_vec())), + ("AWS_SECRET_ACCESS_KEY".into(), ByteString(b"sk".to_vec())), + ])), + ..Default::default() + }; + let secret = synthesize_app_secret("ns", "files-app", &cm, &provisioner).unwrap(); + let region = String::from_utf8(secret.data.unwrap().remove("region").unwrap().0).unwrap(); + assert_eq!(region, "us-east-1"); + } } -- 2.39.5 From 7881197aedb565c448bf676ae6b1aa9768317225 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 08:52:14 -0400 Subject: [PATCH 23/34] feat: Add objectbuckets permissions to harmony app tenant --- harmony_app/src/tenant.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harmony_app/src/tenant.rs b/harmony_app/src/tenant.rs index c8c24e76..e4a22f64 100644 --- a/harmony_app/src/tenant.rs +++ b/harmony_app/src/tenant.rs @@ -187,7 +187,7 @@ fn application_deployer_rules() -> Vec { verbs(), ), rule("postgresql.cnpg.io", &["clusters"], verbs()), - rule("objectbucket.io", &["objectbucketclaims"], verbs()), + rule("objectbucket.io", &["objectbucketclaims", "objectbuckets"], verbs()), ] } -- 2.39.5 From 8c74fa66ceb86d6f765b4da009e6af20445113eb Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 08:54:23 -0400 Subject: [PATCH 24/34] fix: use place-neutral default OBC region --- harmony/src/modules/storage/object_bucket.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/harmony/src/modules/storage/object_bucket.rs b/harmony/src/modules/storage/object_bucket.rs index ace07400..282d0034 100644 --- a/harmony/src/modules/storage/object_bucket.rs +++ b/harmony/src/modules/storage/object_bucket.rs @@ -247,12 +247,13 @@ fn synthesize_app_secret( let bucket = data .get("BUCKET_NAME") .ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_NAME".to_string()))?; - // Rook often leaves BUCKET_REGION empty; S3 SDKs / SmallRye still need a non-empty value. + // Rook often leaves BUCKET_REGION empty; S3 SDKs / SmallRye still need a non-empty + // value. Prefer a place-neutral token over a fake AWS region name. let region = data .get("BUCKET_REGION") .map(String::as_str) .filter(|value| !value.trim().is_empty()) - .unwrap_or("us-east-1"); + .unwrap_or("default"); let scheme = if port == "443" { "https" } else { "http" }; let endpoint = if port == "80" || port == "443" { format!("{scheme}://{host}") @@ -315,7 +316,7 @@ mod tests { } #[test] - fn empty_bucket_region_defaults_to_us_east_1() { + fn empty_bucket_region_defaults_to_default() { let cm = ConfigMap { data: Some(BTreeMap::from([ ("BUCKET_HOST".into(), "rgw.svc".into()), @@ -334,6 +335,6 @@ mod tests { }; let secret = synthesize_app_secret("ns", "files-app", &cm, &provisioner).unwrap(); let region = String::from_utf8(secret.data.unwrap().remove("region").unwrap().0).unwrap(); - assert_eq!(region, "us-east-1"); + assert_eq!(region, "default"); } } -- 2.39.5 From 54f7d6187bff2efef427d780f04f99656692fdb4 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 11:56:31 -0400 Subject: [PATCH 25/34] feat: public object storage endpoint override and bucket CORS --- examples/fleet_typed_deploy/src/lib.rs | 1 + examples/harmony_apply_deployment/src/main.rs | 1 + fleet/harmony-fleet-deploy/src/app.rs | 1 + harmony/Cargo.toml | 2 + harmony/src/modules/storage/object_bucket.rs | 229 +++++++++++++++--- harmony_app/src/application/k8s_anywhere.rs | 30 ++- harmony_app/src/application/resources.rs | 17 ++ harmony_app/src/application/validation.rs | 11 + harmony_app/src/context.rs | 12 + 9 files changed, 272 insertions(+), 32 deletions(-) diff --git a/examples/fleet_typed_deploy/src/lib.rs b/examples/fleet_typed_deploy/src/lib.rs index f9b45940..3e109669 100644 --- a/examples/fleet_typed_deploy/src/lib.rs +++ b/examples/fleet_typed_deploy/src/lib.rs @@ -13,6 +13,7 @@ pub fn fleet_context() -> anyhow::Result { repository: oci_repository!("customer/fleet"), domain: domain!("fleet.example.com"), image_pull_secret: None, + object_storage_endpoint: None, access: tenant_access("fleet-deployer")?, }), }) diff --git a/examples/harmony_apply_deployment/src/main.rs b/examples/harmony_apply_deployment/src/main.rs index 2fbc5f29..c01ab13a 100644 --- a/examples/harmony_apply_deployment/src/main.rs +++ b/examples/harmony_apply_deployment/src/main.rs @@ -127,6 +127,7 @@ async fn main() -> anyhow::Result<()> { repository: "apps".parse()?, domain: "example.com".parse()?, image_pull_secret: None, + object_storage_endpoint: None, access: OpenBaoClusterAccess { namespace: "platform/example-app".parse()?, url: "https://secrets.example.com".parse()?, diff --git a/fleet/harmony-fleet-deploy/src/app.rs b/fleet/harmony-fleet-deploy/src/app.rs index 1b985206..4440d074 100644 --- a/fleet/harmony-fleet-deploy/src/app.rs +++ b/fleet/harmony-fleet-deploy/src/app.rs @@ -452,6 +452,7 @@ mod tests { repository: "harmony".parse().unwrap(), domain: "fleet.example.com".parse().unwrap(), image_pull_secret: None, + object_storage_endpoint: None, access: OpenBaoClusterAccess { namespace: "customer/fleet".parse().unwrap(), url: "https://secrets.example.com".parse().unwrap(), diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 97d5d73f..60a47692 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -90,6 +90,8 @@ harmony_inventory_agent = { path = "../harmony_inventory_agent" } harmony_secret_derive = { path = "../harmony_secret_derive" } harmony_secret = { path = "../harmony_secret" } harmony_zitadel_auth = { path = "../harmony_zitadel_auth" } +aws-config = "1" +aws-sdk-s3 = "1" askama.workspace = true sha2 = "0.10" sqlx.workspace = true diff --git a/harmony/src/modules/storage/object_bucket.rs b/harmony/src/modules/storage/object_bucket.rs index 282d0034..cf24d3f2 100644 --- a/harmony/src/modules/storage/object_bucket.rs +++ b/harmony/src/modules/storage/object_bucket.rs @@ -2,6 +2,10 @@ use std::collections::BTreeMap; use std::time::Duration; use async_trait::async_trait; +use aws_config::BehaviorVersion; +use aws_sdk_s3::Client as S3Client; +use aws_sdk_s3::config::{Credentials, Region}; +use aws_sdk_s3::types::{CorsConfiguration, CorsRule}; use k8s_openapi::ByteString; use k8s_openapi::api::core::v1::{ConfigMap, Secret}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; @@ -45,6 +49,10 @@ pub struct ObjectBucketScore { pub namespace: String, pub storage_class: String, pub max_size: String, + /// When set, written into the app Secret instead of the cluster-internal RGW URL. + pub endpoint_override: Option, + /// Full origins (e.g. `https://app.example.com`) applied via S3 PutBucketCors. + pub cors_origins: Vec, } impl ObjectBucketScore { @@ -54,6 +62,8 @@ impl ObjectBucketScore { namespace: namespace.into(), storage_class: "ceph-bucket".into(), max_size: "10G".into(), + endpoint_override: None, + cors_origins: Vec::new(), } } @@ -67,6 +77,16 @@ impl ObjectBucketScore { self } + pub fn endpoint_override(mut self, endpoint: impl Into) -> Self { + self.endpoint_override = Some(endpoint.into()); + self + } + + pub fn cors_origins(mut self, origins: impl IntoIterator>) -> Self { + self.cors_origins = origins.into_iter().map(Into::into).collect(); + self + } + pub fn app_secret_name(&self) -> String { format!("{}-app", self.name) } @@ -81,7 +101,6 @@ impl ObjectBucketScore { ..ObjectMeta::default() }, spec: ObjectBucketClaimSpec { - // Fixed name so re-applies stay idempotent. bucket_name: Some(self.name.clone()), generate_bucket_name: None, storage_class_name: self.storage_class.clone(), @@ -146,12 +165,21 @@ impl Interpret for ObjectBucketInterpret { ) .await?; - let app_secret = synthesize_app_secret( - &self.score.namespace, - &self.score.app_secret_name(), + let credentials = bucket_credentials( &config, &provisioner_secret, + self.score.endpoint_override.as_deref(), )?; + + if !self.score.cors_origins.is_empty() { + apply_bucket_cors(&credentials, &self.score.cors_origins).await?; + } + + let app_secret = app_secret( + &self.score.namespace, + &self.score.app_secret_name(), + &credentials, + ); client .apply_redacted(&app_secret, Some(&self.score.namespace)) .await @@ -180,6 +208,14 @@ impl Interpret for ObjectBucketInterpret { } } +struct BucketCredentials { + endpoint: String, + bucket: String, + region: String, + access_key: String, + secret_key: String, +} + async fn wait_for_claim_outputs( client: &harmony_k8s::K8sClient, namespace: &str, @@ -230,12 +266,11 @@ fn secret_has_keys(secret: &Secret) -> bool { }) } -fn synthesize_app_secret( - namespace: &str, - name: &str, +fn bucket_credentials( config: &ConfigMap, provisioner: &Secret, -) -> Result { + endpoint_override: Option<&str>, +) -> Result { let data = config .data .as_ref() @@ -246,35 +281,57 @@ fn synthesize_app_secret( let port = data.get("BUCKET_PORT").map(String::as_str).unwrap_or("80"); let bucket = data .get("BUCKET_NAME") - .ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_NAME".to_string()))?; + .ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_NAME".to_string()))? + .clone(); // Rook often leaves BUCKET_REGION empty; S3 SDKs / SmallRye still need a non-empty // value. Prefer a place-neutral token over a fake AWS region name. let region = data .get("BUCKET_REGION") .map(String::as_str) .filter(|value| !value.trim().is_empty()) - .unwrap_or("default"); + .unwrap_or("default") + .to_string(); let scheme = if port == "443" { "https" } else { "http" }; - let endpoint = if port == "80" || port == "443" { + let internal = if port == "80" || port == "443" { format!("{scheme}://{host}") } else { format!("{scheme}://{host}:{port}") }; + let endpoint = endpoint_override + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or(internal); let keys = provisioner .data .as_ref() .ok_or_else(|| InterpretError::new("OBC Secret has no data".to_string()))?; - let access = keys - .get("AWS_ACCESS_KEY_ID") - .ok_or_else(|| InterpretError::new("OBC Secret missing AWS_ACCESS_KEY_ID".to_string()))? - .clone(); - let secret = keys - .get("AWS_SECRET_ACCESS_KEY") - .ok_or_else(|| InterpretError::new("OBC Secret missing AWS_SECRET_ACCESS_KEY".to_string()))? - .clone(); + let access_key = decode_secret_key(keys, "AWS_ACCESS_KEY_ID")?; + let secret_key = decode_secret_key(keys, "AWS_SECRET_ACCESS_KEY")?; - Ok(Secret { + Ok(BucketCredentials { + endpoint, + bucket, + region, + access_key, + secret_key, + }) +} + +fn decode_secret_key( + keys: &BTreeMap, + name: &str, +) -> Result { + let raw = keys + .get(name) + .ok_or_else(|| InterpretError::new(format!("OBC Secret missing {name}")))?; + String::from_utf8(raw.0.clone()) + .map_err(|e| InterpretError::new(format!("OBC Secret {name} is not utf8: {e}"))) +} + +fn app_secret(namespace: &str, name: &str, credentials: &BucketCredentials) -> Secret { + Secret { metadata: ObjectMeta { name: Some(name.to_string()), namespace: Some(namespace.to_string()), @@ -282,16 +339,105 @@ fn synthesize_app_secret( }, type_: Some("Opaque".into()), data: Some(BTreeMap::from([ - ("endpoint".into(), ByteString(endpoint.into_bytes())), - ("bucket".into(), ByteString(bucket.as_bytes().to_vec())), - ("region".into(), ByteString(region.as_bytes().to_vec())), - // Ceph RGW expects path-style addressing. + ( + "endpoint".into(), + ByteString(credentials.endpoint.as_bytes().to_vec()), + ), + ( + "bucket".into(), + ByteString(credentials.bucket.as_bytes().to_vec()), + ), + ( + "region".into(), + ByteString(credentials.region.as_bytes().to_vec()), + ), ("path-style".into(), ByteString(b"true".to_vec())), - ("access-key".into(), access), - ("secret-key".into(), secret), + ( + "access-key".into(), + ByteString(credentials.access_key.as_bytes().to_vec()), + ), + ( + "secret-key".into(), + ByteString(credentials.secret_key.as_bytes().to_vec()), + ), ])), ..Default::default() - }) + } +} + +async fn apply_bucket_cors( + credentials: &BucketCredentials, + origins: &[String], +) -> Result<(), InterpretError> { + let origins: Vec = origins + .iter() + .map(|o| o.trim().to_string()) + .filter(|o| !o.is_empty()) + .collect(); + if origins.is_empty() { + return Ok(()); + } + + info!( + "Applying CORS on bucket '{}' at {} for origins {:?}", + credentials.bucket, credentials.endpoint, origins + ); + + let conf = aws_sdk_s3::config::Builder::from( + &aws_config::defaults(BehaviorVersion::latest()) + .region(Region::new(credentials.region.clone())) + .credentials_provider(Credentials::new( + &credentials.access_key, + &credentials.secret_key, + None, + None, + "harmony-object-bucket", + )) + .endpoint_url(&credentials.endpoint) + .load() + .await, + ) + .force_path_style(true) + .build(); + let client = S3Client::from_conf(conf); + + let rule = CorsRule::builder() + .set_allowed_origins(Some(origins)) + .set_allowed_methods(Some( + ["GET", "PUT", "POST", "DELETE", "HEAD"] + .into_iter() + .map(str::to_string) + .collect(), + )) + .set_allowed_headers(Some(vec!["*".into()])) + .set_expose_headers(Some(vec![ + "ETag".into(), + "x-amz-request-id".into(), + "x-amz-id-2".into(), + ])) + .max_age_seconds(3600) + .build() + .map_err(|e| InterpretError::new(format!("build CORS rule: {e}")))?; + + client + .put_bucket_cors() + .bucket(&credentials.bucket) + .cors_configuration( + CorsConfiguration::builder() + .cors_rules(rule) + .build() + .map_err(|e| InterpretError::new(format!("build CORS configuration: {e}")))?, + ) + .send() + .await + .map_err(|e| { + InterpretError::new(format!( + "put bucket CORS on '{}' via {}: {e}", + credentials.bucket, credentials.endpoint + )) + })?; + + Ok(()) } #[cfg(test)] @@ -333,8 +479,31 @@ mod tests { ])), ..Default::default() }; - let secret = synthesize_app_secret("ns", "files-app", &cm, &provisioner).unwrap(); - let region = String::from_utf8(secret.data.unwrap().remove("region").unwrap().0).unwrap(); - assert_eq!(region, "default"); + let credentials = bucket_credentials(&cm, &provisioner, None).unwrap(); + assert_eq!(credentials.region, "default"); + assert_eq!(credentials.endpoint, "http://rgw.svc"); + } + + #[test] + fn endpoint_override_replaces_internal_rgw_url() { + let cm = ConfigMap { + data: Some(BTreeMap::from([ + ("BUCKET_HOST".into(), "rgw.svc".into()), + ("BUCKET_PORT".into(), "25080".into()), + ("BUCKET_NAME".into(), "files".into()), + ])), + ..Default::default() + }; + let provisioner = Secret { + data: Some(BTreeMap::from([ + ("AWS_ACCESS_KEY_ID".into(), ByteString(b"ak".to_vec())), + ("AWS_SECRET_ACCESS_KEY".into(), ByteString(b"sk".to_vec())), + ])), + ..Default::default() + }; + let credentials = + bucket_credentials(&cm, &provisioner, Some("https://s3.cb1.nationtech.io")).unwrap(); + assert_eq!(credentials.endpoint, "https://s3.cb1.nationtech.io"); + assert_eq!(credentials.bucket, "files"); } } diff --git a/harmony_app/src/application/k8s_anywhere.rs b/harmony_app/src/application/k8s_anywhere.rs index 76f7f170..7a504cd3 100644 --- a/harmony_app/src/application/k8s_anywhere.rs +++ b/harmony_app/src/application/k8s_anywhere.rs @@ -182,9 +182,34 @@ impl HarmonyApp for Application { scores.push(Box::new(score)); } ManagedResource::Bucket(bucket) => { - let score = ObjectBucketScore::new(ctx.namespace(), &bucket.name) + let endpoint = bucket + .endpoint + .clone() + .or_else(|| ctx.object_storage_endpoint().map(str::to_string)); + let cors_origins = bucket + .cors + .iter() + .map(|endpoint| { + let resolved = endpoints.get(endpoint.name()).ok_or_else(|| { + AppError::InvalidComposition(format!( + "unknown public endpoint '{}' for bucket CORS", + endpoint.name() + )) + })?; + let scheme = match resolved.tls { + ManagedTls::Managed => "https", + ManagedTls::Disabled => "http", + }; + Ok(format!("{scheme}://{}", resolved.host)) + }) + .collect::, AppError>>()?; + let mut score = ObjectBucketScore::new(ctx.namespace(), &bucket.name) .storage_class(&bucket.storage_class) - .max_size(&bucket.max_size); + .max_size(&bucket.max_size) + .cors_origins(cors_origins); + if let Some(endpoint) = endpoint { + score = score.endpoint_override(endpoint); + } bindings.buckets.insert( bucket.name.clone(), BucketBinding { @@ -1264,6 +1289,7 @@ mod tests { repository: "apps".parse().unwrap(), domain: "example.com".parse().unwrap(), image_pull_secret: image_pull_secret.map(|name| name.parse().unwrap()), + object_storage_endpoint: None, access: OpenBaoClusterAccess { namespace: "sample".parse().unwrap(), url: "https://bao.example.com".parse().unwrap(), diff --git a/harmony_app/src/application/resources.rs b/harmony_app/src/application/resources.rs index 6b84b740..9eb40e82 100644 --- a/harmony_app/src/application/resources.rs +++ b/harmony_app/src/application/resources.rs @@ -20,6 +20,11 @@ pub struct ManagedBucket { pub storage_class: String, /// Rook `additionalConfig.maxSize` (e.g. `"10G"`). pub max_size: String, + /// Public/browser endpoint override (e.g. context `object_storage_endpoint`). + /// When set, app credentials use this URL instead of cluster-internal RGW DNS. + pub endpoint: Option, + /// Public app endpoints whose origins are allowed by bucket CORS. + pub cors: Vec, } impl ManagedBucket { @@ -28,6 +33,8 @@ impl ManagedBucket { name: name.into(), storage_class: "ceph-bucket".into(), max_size: "10G".into(), + endpoint: None, + cors: Vec::new(), } } @@ -41,6 +48,16 @@ impl ManagedBucket { self } + pub fn endpoint(mut self, endpoint: impl Into) -> Self { + self.endpoint = Some(endpoint.into()); + self + } + + pub fn cors(mut self, endpoint: PublicEndpointRef) -> Self { + self.cors.push(endpoint); + self + } + pub fn reference(&self) -> BucketRef { BucketRef(self.name.clone()) } diff --git a/harmony_app/src/application/validation.rs b/harmony_app/src/application/validation.rs index b9ca69e2..129f41ad 100644 --- a/harmony_app/src/application/validation.rs +++ b/harmony_app/src/application/validation.rs @@ -104,6 +104,17 @@ pub(crate) fn validate(app: &Application) -> Result<(), ApplicationValidationErr non_empty(&bucket.name, "bucket name")?; non_empty(&bucket.storage_class, "bucket storage class")?; non_empty(&bucket.max_size, "bucket max size")?; + if let Some(endpoint) = &bucket.endpoint { + non_empty(endpoint, "bucket endpoint")?; + } + for endpoint in &bucket.cors { + if !endpoints.contains(endpoint.name()) { + return Err(ApplicationValidationError::UnknownResource { + kind: "public endpoint", + name: endpoint.name().to_string(), + }); + } + } if !buckets.insert(bucket.name.as_str()) { return Err(ApplicationValidationError::Duplicate { kind: "bucket", diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index 2432e154..c549c11c 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -47,6 +47,9 @@ pub struct RemoteContext { pub repository: OciRepository, pub domain: DomainName, pub image_pull_secret: Option, + /// Public S3-compatible endpoint for app/browser clients (e.g. `https://s3.cb1.nationtech.io`). + /// When set, ManagedBucket credentials use this instead of the cluster-internal RGW DNS. + pub object_storage_endpoint: Option, pub access: OpenBaoClusterAccess, } @@ -289,6 +292,14 @@ impl AppContext { ContextSpec::Remote(remote) => Some(remote.domain.as_ref()), } } + pub fn object_storage_endpoint(&self) -> Option<&str> { + match &self.context.spec { + ContextSpec::Local(_) => None, + ContextSpec::Remote(remote) => { + remote.object_storage_endpoint.as_ref().map(|u| u.as_ref()) + } + } + } pub fn service_host(&self, service: &str) -> String { match &self.context.spec { ContextSpec::Local(_) => { @@ -516,6 +527,7 @@ mod tests { repository: "team/apps".parse().unwrap(), domain: "example.com".parse().unwrap(), image_pull_secret: Some("registry-auth".parse().unwrap()), + object_storage_endpoint: None, access: OpenBaoClusterAccess { namespace: "team/prod".parse().unwrap(), url: "https://bao.example.com".parse().unwrap(), -- 2.39.5 From e932252a77ef39fbcb91ecc15a6493454e6225d6 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 11:56:45 -0400 Subject: [PATCH 26/34] chore: lock aws-sdk-s3 for object bucket CORS --- Cargo.lock | 2 ++ harmony_app/src/tenant.rs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 5925c2fb..78b9e8f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3976,6 +3976,8 @@ dependencies = [ "askama", "assertor", "async-trait", + "aws-config", + "aws-sdk-s3", "base64 0.22.1", "bollard", "brocade", diff --git a/harmony_app/src/tenant.rs b/harmony_app/src/tenant.rs index e4a22f64..8e82df70 100644 --- a/harmony_app/src/tenant.rs +++ b/harmony_app/src/tenant.rs @@ -187,7 +187,11 @@ fn application_deployer_rules() -> Vec { verbs(), ), rule("postgresql.cnpg.io", &["clusters"], verbs()), - rule("objectbucket.io", &["objectbucketclaims", "objectbuckets"], verbs()), + rule( + "objectbucket.io", + &["objectbucketclaims", "objectbuckets"], + verbs(), + ), ] } -- 2.39.5 From b6f4382ea42a088f55404134ce7ad022735e0dc0 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 12:04:51 -0400 Subject: [PATCH 27/34] fix: startup probe grace so slow boots do not crash-loop --- harmony_app/src/application/k8s_anywhere.rs | 56 ++++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/harmony_app/src/application/k8s_anywhere.rs b/harmony_app/src/application/k8s_anywhere.rs index 7a504cd3..5fd02502 100644 --- a/harmony_app/src/application/k8s_anywhere.rs +++ b/harmony_app/src/application/k8s_anywhere.rs @@ -663,11 +663,12 @@ fn deployment( ) }) .unwrap_or_default(); - let probe = service + let probes = service .health .as_ref() - .map(|health| health_probe(health, ports)) - .transpose()?; + .map(|health| health_probes(health, ports)) + .transpose()? + .unwrap_or_default(); let strategy = match application.rollout.strategy { RolloutStrategy::Rolling => DeploymentStrategy { type_: Some("RollingUpdate".to_string()), @@ -737,8 +738,9 @@ fn deployment( }), env: (!env.is_empty()).then_some(env), volume_mounts: (!volume_mounts.is_empty()).then_some(volume_mounts), - readiness_probe: probe.clone(), - liveness_probe: probe, + startup_probe: probes.startup, + readiness_probe: probes.readiness, + liveness_probe: probes.liveness, resources: resources(service), security_context: Some(SecurityContext { allow_privilege_escalation: Some(false), @@ -869,10 +871,21 @@ fn ingress( }) } -fn health_probe( +/// Default window where startup probe failures do not restart the container. +/// Success still marks the pod started as soon as the first probe passes. +const STARTUP_GRACE: Duration = Duration::from_secs(120); + +#[derive(Default)] +struct ContainerProbes { + startup: Option, + readiness: Option, + liveness: Option, +} + +fn health_probes( health: &HealthCheck, ports: &BTreeMap<(&str, &str), u16>, -) -> Result { +) -> Result { let (reference, interval, timeout, initial_delay) = match health { HealthCheck::Http { port, @@ -889,16 +902,20 @@ fn health_probe( } => (port, interval, timeout, initial_delay), }; let port = IntOrString::Int(i32::from(port_number(ports, reference)?)); - let mut probe = Probe { + let period = seconds(*interval).max(1); + // Ceiling of grace/period so slow boots get a full STARTUP_GRACE of failures. + let startup_failures = + ((STARTUP_GRACE.as_secs() as i32 + period - 1) / period).clamp(1, i32::MAX); + + let mut base = Probe { initial_delay_seconds: Some(seconds(*initial_delay)), - period_seconds: Some(seconds(*interval)), + period_seconds: Some(period), timeout_seconds: Some(seconds(*timeout)), - failure_threshold: Some(3), ..Default::default() }; match health { HealthCheck::Http { path, .. } => { - probe.http_get = Some(HTTPGetAction { + base.http_get = Some(HTTPGetAction { path: Some(path.clone()), port, scheme: Some("HTTP".to_string()), @@ -906,13 +923,26 @@ fn health_probe( }); } HealthCheck::Tcp { .. } => { - probe.tcp_socket = Some(TCPSocketAction { + base.tcp_socket = Some(TCPSocketAction { port, ..Default::default() }); } } - Ok(probe) + + // Startup absorbs boot failures; readiness/liveness only run after startup succeeds. + let mut startup = base.clone(); + startup.failure_threshold = Some(startup_failures); + + let mut runtime = base; + runtime.initial_delay_seconds = Some(0); + runtime.failure_threshold = Some(3); + + Ok(ContainerProbes { + startup: Some(startup), + readiness: Some(runtime.clone()), + liveness: Some(runtime), + }) } fn resources(service: &Service) -> Option { -- 2.39.5 From 3d435a854ec9f3f8096ced71ddc066c932616ea3 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 12:13:34 -0400 Subject: [PATCH 28/34] refactor: put bucket CORS with SigV4 HTTP instead of aws-sdk-s3 --- Cargo.lock | 1918 ++++++++---------- harmony/Cargo.toml | 3 +- harmony/src/modules/storage/object_bucket.rs | 172 +- 3 files changed, 975 insertions(+), 1118 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78b9e8f7..fb5288e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,8 +8,8 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.11.1", - "bytes 1.11.1", + "bitflags 2.13.1", + "bytes 1.12.1", "futures-core", "futures-sink", "memchr", @@ -21,23 +21,23 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.12.1" +version = "3.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" +checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-utils", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "brotli", - "bytes 1.11.1", + "bytes 1.12.1", "bytestring", "derive_more", "encoding_rs", "flate2", - "foldhash 0.1.5", + "foldhash 0.2.0", "futures-core", "h2 0.3.27", "http 0.2.12", @@ -49,7 +49,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "sha1 0.11.0", "smallvec", "tokio", @@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -104,7 +104,7 @@ dependencies = [ "actix-utils", "futures-core", "futures-util", - "mio 1.2.0", + "mio 1.2.2", "socket2 0.5.10", "tokio", "tracing", @@ -132,9 +132,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.13.0" +version = "4.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" dependencies = [ "actix-codec", "actix-http", @@ -145,13 +145,13 @@ dependencies = [ "actix-service", "actix-utils", "actix-web-codegen", - "bytes 1.11.1", + "bytes 1.12.1", "bytestring", "cfg-if", "cookie 0.16.2", "derive_more", "encoding_rs", - "foldhash 0.1.5", + "foldhash 0.2.0", "futures-core", "futures-util", "impl-more", @@ -167,7 +167,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.3", + "socket2 0.6.5", "time", "tracing", "url", @@ -182,7 +182,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -265,9 +265,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -345,15 +345,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -366,9 +366,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "askama" @@ -397,7 +397,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -455,7 +455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86dde77d8a733a9dbaf865a9eb65c72e09c88f3d14d3dd0d2aecf511920ee4fe" dependencies = [ "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "futures-util", "memchr", "nkeys", @@ -463,7 +463,7 @@ dependencies = [ "once_cell", "pin-project 1.1.13", "portable-atomic", - "rand 0.8.6", + "rand 0.8.7", "regex", "ring", "rustls-native-certs 0.7.3", @@ -504,18 +504,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -535,15 +535,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.16" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +checksum = "1b180a3c8b55960db3426d8964b8745e652466a1a49fe1a2eda828046d30b5e4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -555,13 +555,14 @@ dependencies = [ "aws-smithy-json", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", - "bytes 1.11.1", + "bytes 1.12.1", "fastrand", "hex", - "http 1.4.0", - "sha1 0.10.6", + "http 1.5.0", + "sha1 0.10.7", "time", "tokio", "tracing", @@ -571,9 +572,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -583,9 +584,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "zeroize", @@ -593,21 +594,22 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] name = "aws-runtime" -version = "1.7.3" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -618,13 +620,13 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "aws-types", - "bytes 1.11.1", + "bytes 1.12.1", "bytes-utils", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.5.0", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "percent-encoding", "pin-project-lite", "tracing", @@ -633,10 +635,11 @@ dependencies = [ [[package]] name = "aws-sdk-s3" -version = "1.132.0" +version = "1.140.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5575840a3a6b11f6011463ebe359320dfe5b67babb5e9b06fed6ddf809a9ab40" +checksum = "e9660cf991e512fbe6094f1041ff3d3282bc5aeeeb6184e8de437ec2de024a10" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-sigv4", @@ -648,16 +651,17 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-smithy-xml", "aws-types", - "bytes 1.11.1", + "bytes 1.12.1", "fastrand", "hex", "hmac 0.13.0", "http 0.2.12", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "lru 0.16.4", "percent-encoding", "regex-lite", @@ -668,10 +672,11 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.98.0" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" +checksum = "6ffd0fbe7873cb548a7aa60f9573c268fff94155397fd4f14dc9f1ecaaab8516" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -680,22 +685,24 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", - "bytes 1.11.1", + "bytes 1.12.1", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.5.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.100.0" +version = "1.107.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" +checksum = "175763eb222a46377df7aa257a3bca980ab3e96703fefc8f4d0b8da6ad2e254c" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -704,22 +711,24 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", - "bytes 1.11.1", + "bytes 1.12.1", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.5.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.103.0" +version = "1.110.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +checksum = "dd8b14781dfbff48984017d57167b6ea0b6471c6920ec52b44a2677c7feb3c13" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -729,37 +738,37 @@ dependencies = [ "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-smithy-xml", "aws-types", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.5.0", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.4.3" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", "aws-smithy-http", "aws-smithy-runtime-api", "aws-smithy-types", - "bytes 1.11.1", - "crypto-bigint 0.5.5", + "bytes 1.12.1", + "crypto-bigint", "form_urlencoded", "hex", "hmac 0.13.0", "http 0.2.12", - "http 1.4.0", - "p256 0.11.1", + "http 1.5.0", + "p256", "percent-encoding", - "ring", "sha2 0.11.0", "subtle", "time", @@ -769,9 +778,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -780,17 +789,17 @@ dependencies = [ [[package]] name = "aws-smithy-checksums" -version = "0.64.8" +version = "0.65.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e8e65f4f81fcccdeb6c3eca2af17ac21d421a1786a26a394aecf421d616d3a" +checksum = "b67ecd999972b58e67cab052f5129906c08c25883bd0788ceefc55ef97d61307" dependencies = [ "aws-smithy-http", "aws-smithy-types", - "bytes 1.11.1", + "bytes 1.12.1", "crc-fast", "hex", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", "md-5 0.11.0", "pin-project-lite", @@ -801,30 +810,30 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.20" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944" dependencies = [ "aws-smithy-types", - "bytes 1.11.1", + "bytes 1.12.1", "crc32fast", ] [[package]] name = "aws-smithy-http" -version = "0.63.6" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", "aws-smithy-types", - "bytes 1.11.1", + "bytes 1.12.1", "bytes-utils", "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", "percent-encoding", "pin-project-lite", @@ -834,27 +843,27 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.12" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", "h2 0.3.27", - "h2 0.4.14", + "h2 0.4.15", "http 0.2.12", - "http 1.4.0", + "http 1.5.0", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.9.0", + "hyper 1.11.0", "hyper-rustls 0.24.2", "hyper-rustls 0.27.9", "hyper-util", "pin-project-lite", "rustls 0.21.12", - "rustls 0.23.40", - "rustls-native-certs 0.8.3", + "rustls 0.23.43", + "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -864,9 +873,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.62.6" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -875,28 +884,31 @@ dependencies = [ [[package]] name = "aws-smithy-observability" -version = "0.2.6" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.15" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +checksum = "512346c7212ab7436df2d77a16d976a468ae44a418835511d2a69269810aaf62" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", + "aws-smithy-xml", "urlencoding", ] [[package]] name = "aws-smithy-runtime" -version = "1.11.3" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -905,12 +917,12 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", "aws-smithy-types", - "bytes 1.11.1", + "bytes 1.12.1", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.5.0", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "pin-project-lite", "pin-utils", @@ -920,16 +932,16 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.12.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32" +checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", "aws-smithy-types", - "bytes 1.11.1", + "bytes 1.12.1", "http 0.2.12", - "http 1.4.0", + "http 1.5.0", "pin-project-lite", "tokio", "tracing", @@ -938,40 +950,40 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "aws-smithy-schema" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.0", + "http 1.5.0", ] [[package]] name = "aws-smithy-types" -version = "1.4.8" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" dependencies = [ "base64-simd", - "bytes 1.11.1", + "bytes 1.12.1", "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.0", + "http 1.5.0", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "itoa", "num-integer", @@ -986,22 +998,26 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.15" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.15" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94" dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "rustc_version", "tracing", @@ -1014,13 +1030,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", - "bytes 1.11.1", + "bytes 1.12.1", "form_urlencoded", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.11.0", "hyper-util", "itoa", "matchit", @@ -1046,10 +1062,10 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", @@ -1067,11 +1083,11 @@ checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" dependencies = [ "axum", "axum-core", - "bytes 1.11.1", + "bytes 1.12.1", "cookie 0.18.1", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", @@ -1089,7 +1105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ "fastrand", - "gloo-timers", + "gloo-timers 0.3.0", "tokio", ] @@ -1108,12 +1124,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base16ct" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" - [[package]] name = "base16ct" version = "0.2.0" @@ -1182,18 +1192,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1226,9 +1236,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1260,13 +1270,13 @@ checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" dependencies = [ "base64 0.22.1", "bollard-stubs", - "bytes 1.11.1", + "bytes 1.12.1", "futures-core", "futures-util", "hex", - "http 1.4.0", + "http 1.5.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.11.0", "hyper-named-pipe", "hyper-util", "hyperlocal 0.9.1", @@ -1277,7 +1287,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", "tower-service", @@ -1351,9 +1361,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1362,9 +1372,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1381,20 +1391,20 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -1410,9 +1420,9 @@ checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -1423,7 +1433,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "either", ] @@ -1433,14 +1443,14 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", ] [[package]] name = "camino" -version = "1.2.2" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -1482,7 +1492,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1511,9 +1521,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -1545,9 +1555,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -1562,9 +1572,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1573,9 +1583,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1607,9 +1617,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", "clap_derive", @@ -1617,9 +1627,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstream", "anstyle", @@ -1629,14 +1639,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -1656,9 +1666,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "color-eyre" @@ -1699,15 +1709,15 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "memchr", ] [[package]] name = "compact_str" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" dependencies = [ "castaway", "cfg-if", @@ -1717,20 +1727,11 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -1850,7 +1851,7 @@ dependencies = [ "aes-gcm", "base64 0.22.1", "percent-encoding", - "rand 0.8.6", + "rand 0.8.7", "subtle", "time", "version_check", @@ -1939,7 +1940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ "digest 0.10.7", - "spin 0.10.0", + "spin 0.10.1", ] [[package]] @@ -1953,18 +1954,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1972,27 +1973,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -2032,10 +2033,10 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "futures-core", - "mio 1.2.0", + "mio 1.2.2", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -2052,18 +2053,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crypto-bigint" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-bigint" version = "0.5.5" @@ -2173,7 +2162,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2231,7 +2220,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2244,7 +2233,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2266,7 +2255,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2277,7 +2266,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2301,13 +2290,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] -name = "der" -version = "0.6.1" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "const-oid 0.9.6", - "zeroize", + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.19", ] [[package]] @@ -2327,7 +2337,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -2339,7 +2348,7 @@ checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2381,7 +2390,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2401,7 +2410,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core 0.20.2", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2423,7 +2432,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -2466,7 +2475,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", @@ -2503,14 +2512,24 @@ dependencies = [ ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "dispatch2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -2545,7 +2564,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2566,30 +2585,18 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.14.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" -dependencies = [ - "der 0.6.1", - "elliptic-curve 0.12.3", - "rfc6979 0.3.1", - "signature 1.6.4", -] - [[package]] name = "ecdsa" version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", + "der", "digest 0.10.7", - "elliptic-curve 0.13.8", - "rfc6979 0.4.0", - "signature 2.2.0", - "spki 0.7.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", ] [[package]] @@ -2598,8 +2605,8 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8 0.10.2", - "signature 2.2.0", + "pkcs8", + "signature", ] [[package]] @@ -2613,7 +2620,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2 0.10.9", - "signature 2.2.0", + "signature", "subtle", "zeroize", ] @@ -2627,55 +2634,35 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "either" -version = "1.15.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" dependencies = [ "serde", ] -[[package]] -name = "elliptic-curve" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" -dependencies = [ - "base16ct 0.1.1", - "crypto-bigint 0.4.9", - "der 0.6.1", - "digest 0.10.7", - "ff 0.12.1", - "generic-array", - "group 0.12.1", - "pkcs8 0.9.0", - "rand_core 0.6.4", - "sec1 0.3.0", - "subtle", - "zeroize", -] - [[package]] name = "elliptic-curve" version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct 0.2.0", - "crypto-bigint 0.5.5", + "base16ct", + "crypto-bigint", "digest 0.10.7", - "ff 0.13.1", + "ff", "generic-array", - "group 0.13.0", + "group", "hkdf", "pem-rfc7468", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", - "sec1 0.7.3", + "sec1", "subtle", "zeroize", ] @@ -2706,29 +2693,29 @@ dependencies = [ [[package]] name = "enum-ordinalize" -version = "4.3.2" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.2" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "env_filter" -version = "1.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -2736,9 +2723,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -2787,11 +2774,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -3012,7 +2998,7 @@ dependencies = [ "env_logger", "harmony", "harmony_macros", - "http 1.4.0", + "http 1.5.0", "inquire 0.7.5", "k8s-openapi", "kube", @@ -3452,7 +3438,7 @@ dependencies = [ "harmony-reconciler-contracts", "k8s-openapi", "kube", - "rand 0.9.4", + "rand 0.9.5", "serde_json", "tokio", "tracing", @@ -3561,19 +3547,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "ff" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ff" @@ -3625,7 +3601,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -3689,9 +3665,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -3704,9 +3680,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -3714,15 +3690,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -3742,38 +3718,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -3847,37 +3823,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", + "wasm-bindgen", ] [[package]] name = "getset" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3909,14 +3882,15 @@ dependencies = [ ] [[package]] -name = "group" -version = "0.12.1" +name = "gloo-timers" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" dependencies = [ - "ff 0.12.1", - "rand_core 0.6.4", - "subtle", + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -3925,7 +3899,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff 0.13.1", + "ff", "rand_core 0.6.4", "subtle", ] @@ -3936,7 +3910,7 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "fnv", "futures-core", "futures-sink", @@ -3951,16 +3925,16 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", - "bytes 1.11.1", + "bytes 1.12.1", "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.5.0", "indexmap 2.14.0", "slab", "tokio", @@ -3976,8 +3950,6 @@ dependencies = [ "askama", "assertor", "async-trait", - "aws-config", - "aws-sdk-s3", "base64 0.22.1", "bollard", "brocade", @@ -4004,7 +3976,8 @@ dependencies = [ "harmony_zitadel_auth", "helm-wrapper-rs", "hex", - "http 1.4.0", + "hmac 0.12.1", + "http 1.5.0", "httptest", "inquire 0.7.5", "k3d-rs", @@ -4021,7 +3994,7 @@ dependencies = [ "opnsense-config-xml", "option-ext", "pretty_assertions", - "rand 0.9.4", + "rand 0.9.5", "reqwest 0.11.27", "russh", "russh-keys", @@ -4042,7 +4015,7 @@ dependencies = [ "temp-dir", "temp-file", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-retry", "tokio-util", @@ -4076,7 +4049,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "toml", "tracing", @@ -4133,7 +4106,7 @@ dependencies = [ "serde_yaml", "similar", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "toml", "tracing", @@ -4166,7 +4139,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "tracing-subscriber", @@ -4200,7 +4173,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-stream", "toml", @@ -4243,7 +4216,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "tracing-subscriber", @@ -4277,7 +4250,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -4295,7 +4268,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] @@ -4336,7 +4309,7 @@ dependencies = [ "serde_json", "serde_yaml", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] @@ -4360,7 +4333,7 @@ dependencies = [ "reqwest 0.12.28", "sha2 0.10.9", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-test", "url", @@ -4379,7 +4352,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.19", "uuid", ] @@ -4484,7 +4457,7 @@ dependencies = [ "serde_json", "sqlx", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "toml", ] @@ -4496,7 +4469,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4506,7 +4479,7 @@ dependencies = [ "directories", "lazy_static", "log", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -4544,7 +4517,7 @@ dependencies = [ "serde", "serde_json", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] @@ -4570,7 +4543,7 @@ dependencies = [ "quote", "serde", "serde_yaml", - "syn 2.0.117", + "syn 2.0.119", "url", ] @@ -4583,7 +4556,7 @@ dependencies = [ "harmony-reconciler-contracts", "harmony_secret_derive", "harmony_zitadel_jwt", - "http 1.4.0", + "http 1.5.0", "infisical", "inquire 0.7.5", "interactive-parse", @@ -4595,7 +4568,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "url", "vaultrs", @@ -4609,7 +4582,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4635,7 +4608,7 @@ name = "harmony_types" version = "0.1.0" dependencies = [ "log", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "url", @@ -4658,13 +4631,13 @@ dependencies = [ "httptest", "jsonwebtoken", "openidconnect", - "rand 0.9.4", + "rand 0.9.5", "reqwest 0.12.28", "schemars 0.8.22", "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", "tokio", "tracing", @@ -4740,12 +4713,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "headers-core", - "http 1.4.0", + "http 1.5.0", "httpdate", "mime", - "sha1 0.10.6", + "sha1 0.10.7", ] [[package]] @@ -4754,7 +4727,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.4.0", + "http 1.5.0", ] [[package]] @@ -4773,7 +4746,7 @@ dependencies = [ "non-blank-string-rs", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -4841,18 +4814,18 @@ version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "fnv", "itoa", ] [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "itoa", ] @@ -4871,31 +4844,31 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "http 0.2.12", "pin-project-lite", ] [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ - "bytes 1.11.1", - "http 1.4.0", + "bytes 1.12.1", + "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "pin-project-lite", ] @@ -4918,13 +4891,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a422b4c865d103368628ae1247be6159ad8041f803eb9e2176cf69ad7d13da40" dependencies = [ "bstr", - "bytes 1.11.1", + "bytes 1.12.1", "crossbeam-channel", "form_urlencoded", "futures", - "http 1.4.0", + "http 1.5.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.11.0", "hyper-util", "log", "once_cell", @@ -4937,9 +4910,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] @@ -4950,7 +4923,7 @@ version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "futures-channel", "futures-core", "futures-util", @@ -4970,17 +4943,17 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", - "bytes 1.11.1", + "bytes 1.12.1", "futures-channel", "futures-core", - "h2 0.4.14", - "http 1.4.0", - "http-body 1.0.1", + "h2 0.4.15", + "http 1.5.0", + "http-body 1.1.0", "httparse", "httpdate", "itoa", @@ -4992,19 +4965,18 @@ dependencies = [ [[package]] name = "hyper-http-proxy" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ad4b0a1e37510028bc4ba81d0e38d239c39671b0f0ce9e02dfa93a8133f7c08" +checksum = "8021e0ae20c08eadc94d0bdafdeda66d4f0858541c146ae6e46b219bfe58497e" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "futures-util", "headers", - "http 1.4.0", - "hyper 1.9.0", + "http 1.5.0", + "hyper 1.11.0", "hyper-rustls 0.27.9", "hyper-util", "pin-project-lite", - "rustls-native-certs 0.7.3", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -5012,17 +4984,16 @@ dependencies = [ [[package]] name = "hyper-named-pipe" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" dependencies = [ "hex", - "hyper 1.9.0", + "hyper 1.11.0", "hyper-util", "pin-project-lite", "tokio", "tower-service", - "winapi", ] [[package]] @@ -5046,16 +5017,16 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", - "hyper 1.9.0", + "http 1.5.0", + "hyper 1.11.0", "hyper-util", "log", - "rustls 0.23.40", - "rustls-native-certs 0.8.3", + "rustls 0.23.43", + "rustls-native-certs 0.8.4", "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] @@ -5064,7 +5035,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.9.0", + "hyper 1.11.0", "hyper-util", "pin-project-lite", "tokio", @@ -5078,17 +5049,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "futures-channel", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.9.0", + "http 1.5.0", + "http-body 1.1.0", + "hyper 1.11.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -5115,7 +5086,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.9.0", + "hyper 1.11.0", "hyper-util", "pin-project-lite", "tokio", @@ -5228,12 +5199,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -5283,9 +5248,9 @@ dependencies = [ [[package]] name = "impl-more" -version = "0.1.9" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" [[package]] name = "indenter" @@ -5318,9 +5283,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", @@ -5394,7 +5359,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm 0.25.0", "dyn-clone", "fuzzy-matcher", @@ -5415,7 +5380,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5496,10 +5461,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ + "defmt", + "jiff-core", "jiff-static", "log", "portable-atomic", @@ -5508,14 +5475,24 @@ dependencies = [ ] [[package]] -name = "jiff-static" -version = "0.2.24" +name = "jiff-core" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5530,7 +5507,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -5545,7 +5522,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5564,28 +5541,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -5598,7 +5574,7 @@ dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -5611,7 +5587,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -5721,15 +5697,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cb276b85b6e94ded00ac8ea2c68fcf4697ea0553cb25fddc35d4a0ab718db8d" dependencies = [ "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "chrono", "either", "futures", "home", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.11.0", "hyper-http-proxy", "hyper-rustls 0.27.9", "hyper-timeout", @@ -5738,12 +5714,12 @@ dependencies = [ "k8s-openapi", "kube-core", "pem", - "rustls 0.23.40", + "rustls 0.23.43", "secrecy", "serde", "serde_json", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-tungstenite", "tokio-util", @@ -5761,14 +5737,14 @@ dependencies = [ "chrono", "derive_more", "form_urlencoded", - "http 1.4.0", + "http 1.5.0", "json-patch", "k8s-openapi", "schemars 0.8.22", "serde", "serde-value", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -5782,7 +5758,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5806,7 +5782,7 @@ dependencies = [ "pin-project 1.1.13", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", "tracing", @@ -5835,20 +5811,14 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin 0.9.9", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -5858,14 +5828,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.9.1", ] [[package]] @@ -5936,9 +5906,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "log-panics" @@ -6004,7 +5974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" dependencies = [ "axum-core", - "http 1.4.0", + "http 1.5.0", "itoa", "maud_macros", ] @@ -6018,7 +5988,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6056,16 +6026,16 @@ dependencies = [ "flume", "if-addrs", "log", - "mio 1.2.0", + "mio 1.2.2", "socket-pktinfo", - "socket2 0.6.3", + "socket2 0.6.5", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -6097,9 +6067,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -6115,7 +6085,7 @@ dependencies = [ "nkeys", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -6130,7 +6100,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "derive_builder 0.20.2", "getset", @@ -6150,7 +6120,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6167,7 +6137,7 @@ dependencies = [ "harmony_types", "log", "opnsense-api", - "rand 0.9.4", + "rand 0.9.5", "russh", "russh-keys", "serde", @@ -6206,7 +6176,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.2.17", "log", - "rand 0.8.6", + "rand 0.8.7", "signatory", ] @@ -6243,18 +6213,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" dependencies = [ - "rand 0.8.6", + "rand 0.8.7", ] [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -6268,7 +6238,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -6290,11 +6260,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -6318,8 +6287,8 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http 1.4.0", - "rand 0.8.6", + "http 1.5.0", + "rand 0.8.7", "reqwest 0.12.28", "serde", "serde_json", @@ -6338,6 +6307,28 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -6350,8 +6341,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2", + "objc2-core-foundation", ] [[package]] @@ -6369,10 +6361,10 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b74df13319e08bc386d333d3dc289c774c88cc543cae31f5347db07b5ec2172" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "chrono", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-auth", "jwt", "lazy_static", @@ -6383,7 +6375,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "unicase", @@ -6403,7 +6395,7 @@ dependencies = [ "serde_json", "strum 0.27.2", "strum_macros 0.27.2", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -6415,16 +6407,16 @@ dependencies = [ "arc-swap", "async-trait", "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "cfg-if", "chrono", "either", "futures", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.11.0", "hyper-rustls 0.27.9", "hyper-timeout", "hyper-util", @@ -6502,13 +6494,13 @@ dependencies = [ "dyn-clone", "ed25519-dalek", "hmac 0.12.1", - "http 1.4.0", + "http 1.5.0", "itertools 0.10.5", "log", "oauth2", - "p256 0.13.2", + "p256", "p384", - "rand 0.8.6", + "rand 0.8.7", "rsa", "serde", "serde-value", @@ -6542,7 +6534,7 @@ dependencies = [ "base64 0.22.1", "env_logger", "harmony_types", - "http 1.4.0", + "http 1.5.0", "inquire 0.7.5", "log", "opnsense-config", @@ -6550,7 +6542,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-test", ] @@ -6568,7 +6560,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "toml", ] @@ -6607,9 +6599,9 @@ dependencies = [ "env_logger", "log", "pretty_assertions", - "rand 0.9.4", + "rand 0.9.5", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "uuid", "xml-rs", @@ -6684,25 +6676,14 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" -[[package]] -name = "p256" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" -dependencies = [ - "ecdsa 0.14.8", - "elliptic-curve 0.12.3", - "sha2 0.10.9", -] - [[package]] name = "p256" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", + "ecdsa", + "elliptic-curve", "primeorder", "sha2 0.10.9", ] @@ -6713,8 +6694,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", + "ecdsa", + "elliptic-curve", "primeorder", "sha2 0.10.9", ] @@ -6725,9 +6706,9 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct 0.2.0", - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", + "base16ct", + "ecdsa", + "elliptic-curve", "primeorder", "rand_core 0.6.4", "sha2 0.10.9", @@ -6828,9 +6809,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" dependencies = [ "memchr", "ucd-trie", @@ -6838,9 +6819,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" dependencies = [ "pest", "pest_generator", @@ -6848,25 +6829,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -6906,7 +6886,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6927,9 +6907,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", + "der", + "pkcs8", + "spki", ] [[package]] @@ -6940,21 +6920,11 @@ checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ "aes", "cbc", - "der 0.7.10", + "der", "pbkdf2 0.12.2", "scrypt", "sha2 0.10.9", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" -dependencies = [ - "der 0.6.1", - "spki 0.6.0", + "spki", ] [[package]] @@ -6963,10 +6933,10 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", + "der", "pkcs5", "rand_core 0.6.4", - "spki 0.7.3", + "spki", ] [[package]] @@ -6989,7 +6959,7 @@ checksum = "7697e9e1fdcfd452699eb8c419994a8fd120f0f5ac5a7dd26398a9a983b8dc89" dependencies = [ "base64 0.13.1", "byteorder", - "bytes 1.11.1", + "bytes 1.12.1", "chrono", "containers-api", "flate2", @@ -7042,9 +7012,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" @@ -7116,23 +7086,13 @@ dependencies = [ "yansi", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "primeorder" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve 0.13.8", + "elliptic-curve", ] [[package]] @@ -7141,36 +7101,14 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -7183,7 +7121,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "version_check", ] @@ -7221,19 +7159,19 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.40", - "socket2 0.6.3", - "thiserror 2.0.18", + "rustls 0.23.43", + "socket2 0.6.5", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -7241,20 +7179,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ - "bytes 1.11.1", - "getrandom 0.3.4", + "bytes 1.12.1", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", - "rustls 0.23.40", + "rustls 0.23.43", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -7262,23 +7201,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.5", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -7303,9 +7242,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -7314,9 +7253,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -7324,12 +7263,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", + "chacha20 0.10.1", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -7377,13 +7316,22 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cassowary", "compact_str", "crossterm 0.28.1", @@ -7424,16 +7372,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -7444,34 +7392,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -7481,9 +7429,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -7498,9 +7446,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -7509,7 +7457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ "base64 0.21.7", - "bytes 1.11.1", + "bytes 1.12.1", "cookie 0.17.0", "cookie_store", "encoding_rs", @@ -7554,15 +7502,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "futures-channel", "futures-core", "futures-util", - "h2 0.4.14", - "http 1.4.0", - "http-body 1.0.1", + "h2 0.4.15", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.11.0", "hyper-rustls 0.27.9", "hyper-util", "js-sys", @@ -7570,7 +7518,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls 0.23.43", "rustls-pki-types", "serde", "serde_json", @@ -7587,18 +7535,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", -] - -[[package]] -name = "rfc6979" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" -dependencies = [ - "crypto-bigint 0.4.9", - "hmac 0.12.1", - "zeroize", + "webpki-roots 1.0.9", ] [[package]] @@ -7652,11 +7589,11 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "sha2 0.10.9", - "signature 2.2.0", - "spki 0.7.3", + "signature", + "spki", "subtle", "zeroize", ] @@ -7670,7 +7607,7 @@ dependencies = [ "aes", "aes-gcm", "async-trait", - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "cbc", "chacha20 0.9.1", @@ -7678,7 +7615,7 @@ dependencies = [ "curve25519-dalek", "des", "digest 0.10.7", - "elliptic-curve 0.13.8", + "elliptic-curve", "flate2", "futures", "generic-array", @@ -7687,15 +7624,15 @@ dependencies = [ "log", "num-bigint", "once_cell", - "p256 0.13.2", + "p256", "p384", "p521", "poly1305", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "russh-cryptovec", "russh-keys", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "ssh-encoding", "ssh-key", @@ -7728,11 +7665,11 @@ dependencies = [ "cbc", "ctr", "data-encoding", - "der 0.7.10", + "der", "digest 0.10.7", - "ecdsa 0.16.9", + "ecdsa", "ed25519-dalek", - "elliptic-curve 0.13.8", + "elliptic-curve", "futures", "hmac 0.12.1", "home", @@ -7740,22 +7677,22 @@ dependencies = [ "log", "md5", "num-integer", - "p256 0.13.2", + "p256", "p384", "p521", "pbkdf2 0.11.0", "pkcs1", "pkcs5", - "pkcs8 0.10.2", - "rand 0.8.6", + "pkcs8", + "rand 0.8.7", "rand_core 0.6.4", "rsa", "russh-cryptovec", - "sec1 0.7.3", + "sec1", "serde", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", - "spki 0.7.3", + "spki", "ssh-encoding", "ssh-key", "thiserror 1.0.69", @@ -7767,20 +7704,22 @@ dependencies = [ [[package]] name = "russh-sftp" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09daa0ebcf53fb18d7b16167586a68b5bf2cfa3eaad49e661a19302552a2b879" +checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9" dependencies = [ - "bitflags 2.11.1", - "bytes 1.11.1", + "bitflags 2.13.1", + "bytes 1.12.1", "chrono", "dashmap", + "gloo-timers 0.4.0", "log", "serde", "serde_bytes", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", + "wasm-bindgen-futures", ] [[package]] @@ -7793,22 +7732,22 @@ dependencies = [ "bitvec", "cbc", "hmac 0.12.1", - "rand 0.8.6", + "rand 0.8.7", "sha2 0.10.9", "thiserror 1.0.69", ] [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -7827,8 +7766,8 @@ checksum = "759a090a17ce545d1adcffcc48207d5136c8984d8153bd8247b1ad4a71e49f5f" dependencies = [ "anyhow", "async-trait", - "bytes 1.11.1", - "http 1.4.0", + "bytes 1.12.1", + "http 1.5.0", "reqwest 0.12.28", "rustify_derive", "serde", @@ -7859,7 +7798,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7872,7 +7811,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -7893,9 +7832,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -7922,9 +7861,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", @@ -7952,9 +7891,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -7994,9 +7933,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -8057,9 +7996,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -8076,7 +8015,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8115,30 +8054,16 @@ dependencies = [ "libc", ] -[[package]] -name = "sec1" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" -dependencies = [ - "base16ct 0.1.1", - "der 0.6.1", - "generic-array", - "pkcs8 0.9.0", - "subtle", - "zeroize", -] - [[package]] name = "sec1" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct 0.2.0", - "der 0.7.10", + "base16ct", + "der", "generic-array", - "pkcs8 0.10.2", + "pkcs8", "subtle", "zeroize", ] @@ -8158,7 +8083,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -8171,7 +8096,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -8200,9 +8125,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -8242,22 +8167,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -8268,14 +8193,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -8315,13 +8240,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -8342,7 +8267,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8359,9 +8284,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -8370,7 +8295,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -8379,14 +8304,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8404,9 +8329,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -8457,9 +8382,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -8479,7 +8404,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio 0.8.11", - "mio 1.2.0", + "mio 1.2.2", "signal-hook", ] @@ -8499,22 +8424,12 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" dependencies = [ - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", - "signature 2.2.0", + "signature", "zeroize", ] -[[package]] -name = "signature" -version = "1.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - [[package]] name = "signature" version = "2.2.0" @@ -8527,15 +8442,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -8561,7 +8476,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", ] @@ -8573,9 +8488,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -8598,7 +8513,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8608,7 +8523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" dependencies = [ "libc", - "socket2 0.6.3", + "socket2 0.6.5", "windows-sys 0.60.2", ] @@ -8624,9 +8539,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -8634,28 +8549,18 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" - -[[package]] -name = "spki" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" -dependencies = [ - "base64ct", - "der 0.6.1", -] +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" @@ -8664,7 +8569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", + "der", ] [[package]] @@ -8687,7 +8592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "crc", "crossbeam-queue", "either", @@ -8707,7 +8612,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-stream", "tracing", @@ -8724,7 +8629,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8747,7 +8652,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.117", + "syn 2.0.119", "tokio", "url", ] @@ -8760,9 +8665,9 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", - "bytes 1.11.1", + "bytes 1.12.1", "crc", "digest 0.10.7", "dotenvy", @@ -8781,15 +8686,15 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.6", + "rand 0.8.7", "rsa", "serde", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "whoami", ] @@ -8802,7 +8707,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "crc", "dotenvy", @@ -8819,14 +8724,14 @@ dependencies = [ "md-5 0.10.6", "memchr", "once_cell", - "rand 0.8.6", + "rand 0.8.7", "serde", "serde_json", "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "whoami", ] @@ -8850,7 +8755,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "url", ] @@ -8892,14 +8797,14 @@ dependencies = [ "bcrypt-pbkdf", "ed25519-dalek", "num-bigint-dig", - "p256 0.13.2", + "p256", "p384", "p521", "rand_core 0.6.4", "rsa", - "sec1 0.7.3", + "sec1", "sha2 0.10.9", - "signature 2.2.0", + "signature", "ssh-cipher", "ssh-encoding", "subtle", @@ -8969,7 +8874,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8981,7 +8886,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -9015,9 +8920,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -9059,7 +8975,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -9134,7 +9050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -9157,11 +9073,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -9172,37 +9088,36 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -9212,15 +9127,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -9238,9 +9153,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -9253,40 +9168,40 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "libc", - "mio 1.2.0", + "mio 1.2.2", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "tokio-retry" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40f644c762e9d396831ae2f8935c954b0d758c4532e924bead0f666d0c1c8640" +checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" dependencies = [ "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "tokio", ] @@ -9306,15 +9221,15 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.40", + "rustls 0.23.43", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -9346,13 +9261,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "futures-core", "futures-sink", + "futures-util", + "libc", "pin-project-lite", "slab", "tokio", @@ -9365,12 +9282,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" dependencies = [ "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.5.0", "httparse", - "rand 0.8.6", + "rand 0.8.7", "ring", "rustls-pki-types", "tokio", @@ -9425,23 +9342,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -9474,11 +9391,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "base64 0.22.1", - "bitflags 2.11.1", - "bytes 1.11.1", + "bitflags 2.13.1", + "bytes 1.12.1", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "mime", "pin-project-lite", "tower", @@ -9520,7 +9437,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -9609,14 +9526,14 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "data-encoding", - "http 1.4.0", + "http 1.5.0", "httparse", "log", - "rand 0.9.4", - "sha1 0.10.6", - "thiserror 2.0.18", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.19", "utf-8", ] @@ -9628,9 +9545,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -9673,9 +9590,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -9773,13 +9690,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", - "rand 0.10.1", + "rand 0.10.2", "serde_core", "wasm-bindgen", ] @@ -9797,9 +9714,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f81eb4d9221ca29bad43d4b6871b6d2e7656e1af2cfca624a87e5d17880d831d" dependencies = [ "async-trait", - "bytes 1.11.1", + "bytes 1.12.1", "derive_builder 0.12.0", - "http 1.4.0", + "http 1.5.0", "reqwest 0.12.28", "rustify", "rustify_derive", @@ -9885,20 +9802,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -9909,9 +9817,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -9922,9 +9830,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -9932,9 +9840,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9942,48 +9850,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -9997,23 +9883,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -10032,15 +9906,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f" dependencies = [ - "core-foundation 0.10.1", "jni", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys", @@ -10058,14 +9932,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -10151,7 +10025,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -10162,7 +10036,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -10431,9 +10305,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -10448,100 +10322,12 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -10605,15 +10391,15 @@ dependencies = [ "quote", "serde", "serde_tokenstream", - "syn 2.0.117", + "syn 2.0.119", "xml-rs", ] [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10628,28 +10414,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure 0.13.2", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -10669,15 +10455,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure 0.13.2", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -10709,14 +10495,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 60a47692..6993ca97 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -90,8 +90,7 @@ harmony_inventory_agent = { path = "../harmony_inventory_agent" } harmony_secret_derive = { path = "../harmony_secret_derive" } harmony_secret = { path = "../harmony_secret" } harmony_zitadel_auth = { path = "../harmony_zitadel_auth" } -aws-config = "1" -aws-sdk-s3 = "1" +hmac = "0.12" askama.workspace = true sha2 = "0.10" sqlx.workspace = true diff --git a/harmony/src/modules/storage/object_bucket.rs b/harmony/src/modules/storage/object_bucket.rs index cf24d3f2..2e025f8b 100644 --- a/harmony/src/modules/storage/object_bucket.rs +++ b/harmony/src/modules/storage/object_bucket.rs @@ -2,16 +2,14 @@ use std::collections::BTreeMap; use std::time::Duration; use async_trait::async_trait; -use aws_config::BehaviorVersion; -use aws_sdk_s3::Client as S3Client; -use aws_sdk_s3::config::{Credentials, Region}; -use aws_sdk_s3::types::{CorsConfiguration, CorsRule}; +use hmac::{Hmac, Mac}; use k8s_openapi::ByteString; use k8s_openapi::api::core::v1::{ConfigMap, Secret}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use kube::CustomResource; use log::{debug, info}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::data::Version; use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; @@ -369,9 +367,9 @@ async fn apply_bucket_cors( credentials: &BucketCredentials, origins: &[String], ) -> Result<(), InterpretError> { - let origins: Vec = origins + let origins: Vec<&str> = origins .iter() - .map(|o| o.trim().to_string()) + .map(|o| o.trim()) .filter(|o| !o.is_empty()) .collect(); if origins.is_empty() { @@ -383,51 +381,62 @@ async fn apply_bucket_cors( credentials.bucket, credentials.endpoint, origins ); - let conf = aws_sdk_s3::config::Builder::from( - &aws_config::defaults(BehaviorVersion::latest()) - .region(Region::new(credentials.region.clone())) - .credentials_provider(Credentials::new( - &credentials.access_key, - &credentials.secret_key, - None, - None, - "harmony-object-bucket", - )) - .endpoint_url(&credentials.endpoint) - .load() - .await, - ) - .force_path_style(true) - .build(); - let client = S3Client::from_conf(conf); + let body = cors_configuration_xml(&origins); + let endpoint = credentials + .endpoint + .trim_end_matches('/') + .parse::() + .map_err(|e| InterpretError::new(format!("invalid bucket endpoint: {e}")))?; + let host = endpoint + .host_str() + .ok_or_else(|| InterpretError::new("bucket endpoint missing host".to_string()))?; + let host_header = match endpoint.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + let path = format!("/{}", credentials.bucket.trim_matches('/')); + let mut url = endpoint; + url.set_path(&path); + url.set_query(Some("cors")); - let rule = CorsRule::builder() - .set_allowed_origins(Some(origins)) - .set_allowed_methods(Some( - ["GET", "PUT", "POST", "DELETE", "HEAD"] - .into_iter() - .map(str::to_string) - .collect(), - )) - .set_allowed_headers(Some(vec!["*".into()])) - .set_expose_headers(Some(vec![ - "ETag".into(), - "x-amz-request-id".into(), - "x-amz-id-2".into(), - ])) - .max_age_seconds(3600) - .build() - .map_err(|e| InterpretError::new(format!("build CORS rule: {e}")))?; + let now = chrono::Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let payload_hash = hex::encode(Sha256::digest(body.as_bytes())); + let region = credentials.region.as_str(); + let service = "s3"; + let credential_scope = format!("{date_stamp}/{region}/{service}/aws4_request"); - client - .put_bucket_cors() - .bucket(&credentials.bucket) - .cors_configuration( - CorsConfiguration::builder() - .cors_rules(rule) - .build() - .map_err(|e| InterpretError::new(format!("build CORS configuration: {e}")))?, - ) + let canonical_headers = format!( + "content-type:application/xml\nhost:{host_header}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n" + ); + let signed_headers = "content-type;host;x-amz-content-sha256;x-amz-date"; + let canonical_request = format!( + "PUT\n{}\ncors\n{}\n{}\n{}", + url.path(), + canonical_headers, + signed_headers, + payload_hash + ); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}", + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + let signing_key = aws4_signing_key(&credentials.secret_key, &date_stamp, region, service); + let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())); + let authorization = format!( + "AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}", + credentials.access_key + ); + + let response = reqwest::Client::new() + .put(url) + .header("content-type", "application/xml") + .header("host", host_header) + .header("x-amz-content-sha256", payload_hash) + .header("x-amz-date", amz_date) + .header("authorization", authorization) + .body(body) .send() .await .map_err(|e| { @@ -436,10 +445,65 @@ async fn apply_bucket_cors( credentials.bucket, credentials.endpoint )) })?; - + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(InterpretError::new(format!( + "put bucket CORS on '{}' via {} failed: {status} {text}", + credentials.bucket, credentials.endpoint + ))); + } Ok(()) } +fn cors_configuration_xml(origins: &[&str]) -> String { + let mut rules = String::new(); + for origin in origins { + let origin = xml_escape(origin); + rules.push_str(&format!( + r#" + + {origin} + GET + PUT + POST + DELETE + HEAD + * + ETag + x-amz-request-id + x-amz-id-2 + 3600 + "# + )); + } + format!( + "{rules}\n" + ) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { + let mut mac = Hmac::::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +fn aws4_signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec { + let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes()); + let k_region = hmac_sha256(&k_date, region.as_bytes()); + let k_service = hmac_sha256(&k_region, service.as_bytes()); + hmac_sha256(&k_service, b"aws4_request") +} + #[cfg(test)] mod tests { use super::*; @@ -484,6 +548,14 @@ mod tests { assert_eq!(credentials.endpoint, "http://rgw.svc"); } + #[test] + fn cors_xml_lists_origins_and_methods() { + let xml = cors_configuration_xml(&["https://app.example.com"]); + assert!(xml.contains("https://app.example.com")); + assert!(xml.contains("PUT")); + assert!(xml.contains("*")); + } + #[test] fn endpoint_override_replaces_internal_rgw_url() { let cm = ConfigMap { -- 2.39.5 From 00b1c8ed8555c258017ad195d27607379a11e00c Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 12:27:33 -0400 Subject: [PATCH 29/34] fix: SigV4 CORS query must be cors= for Ceph RGW --- harmony/src/modules/storage/object_bucket.rs | 164 +++++++++++++------ 1 file changed, 113 insertions(+), 51 deletions(-) diff --git a/harmony/src/modules/storage/object_bucket.rs b/harmony/src/modules/storage/object_bucket.rs index 2e025f8b..0c7b5acf 100644 --- a/harmony/src/modules/storage/object_bucket.rs +++ b/harmony/src/modules/storage/object_bucket.rs @@ -382,60 +382,15 @@ async fn apply_bucket_cors( ); let body = cors_configuration_xml(&origins); - let endpoint = credentials - .endpoint - .trim_end_matches('/') - .parse::() - .map_err(|e| InterpretError::new(format!("invalid bucket endpoint: {e}")))?; - let host = endpoint - .host_str() - .ok_or_else(|| InterpretError::new("bucket endpoint missing host".to_string()))?; - let host_header = match endpoint.port() { - Some(port) => format!("{host}:{port}"), - None => host.to_string(), - }; - let path = format!("/{}", credentials.bucket.trim_matches('/')); - let mut url = endpoint; - url.set_path(&path); - url.set_query(Some("cors")); - - let now = chrono::Utc::now(); - let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); - let date_stamp = now.format("%Y%m%d").to_string(); - let payload_hash = hex::encode(Sha256::digest(body.as_bytes())); - let region = credentials.region.as_str(); - let service = "s3"; - let credential_scope = format!("{date_stamp}/{region}/{service}/aws4_request"); - - let canonical_headers = format!( - "content-type:application/xml\nhost:{host_header}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n" - ); - let signed_headers = "content-type;host;x-amz-content-sha256;x-amz-date"; - let canonical_request = format!( - "PUT\n{}\ncors\n{}\n{}\n{}", - url.path(), - canonical_headers, - signed_headers, - payload_hash - ); - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}", - hex::encode(Sha256::digest(canonical_request.as_bytes())) - ); - let signing_key = aws4_signing_key(&credentials.secret_key, &date_stamp, region, service); - let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())); - let authorization = format!( - "AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}", - credentials.access_key - ); + let signed = sign_s3_put_cors(credentials, body.as_bytes())?; let response = reqwest::Client::new() - .put(url) + .put(&signed.url) .header("content-type", "application/xml") - .header("host", host_header) - .header("x-amz-content-sha256", payload_hash) - .header("x-amz-date", amz_date) - .header("authorization", authorization) + .header("host", &signed.host) + .header("x-amz-content-sha256", &signed.payload_hash) + .header("x-amz-date", &signed.amz_date) + .header("authorization", &signed.authorization) .body(body) .send() .await @@ -456,6 +411,96 @@ async fn apply_bucket_cors( Ok(()) } +struct SignedS3Request { + url: String, + host: String, + amz_date: String, + payload_hash: String, + authorization: String, +} + +/// Path-style `PUT /{bucket}?cors` with AWS SigV4 (Ceph RGW compatible). +fn sign_s3_put_cors( + credentials: &BucketCredentials, + body: &[u8], +) -> Result { + let endpoint = credentials + .endpoint + .trim_end_matches('/') + .parse::() + .map_err(|e| InterpretError::new(format!("invalid bucket endpoint: {e}")))?; + let host = endpoint + .host_str() + .ok_or_else(|| InterpretError::new("bucket endpoint missing host".to_string()))?; + let host_header = match endpoint.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + let bucket = credentials.bucket.trim_matches('/'); + // URI-encode path segments per AWS (unreserved stay literal). + let canonical_uri = format!("/{}", aws_uri_encode(bucket, false)); + let url = format!( + "{}{}?cors", + credentials.endpoint.trim_end_matches('/'), + canonical_uri + ); + + let now = chrono::Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let payload_hash = hex::encode(Sha256::digest(body)); + // App secret may store place-neutral "default"; RGW on cb1 is configured as us-east-1. + let region = match credentials.region.as_str() { + "" | "default" => "us-east-1", + other => other, + }; + let service = "s3"; + let credential_scope = format!("{date_stamp}/{region}/{service}/aws4_request"); + + // Query params: name=value, sorted; empty value still needs '='. + let canonical_query = "cors="; + let canonical_headers = format!( + "content-type:application/xml\nhost:{host_header}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n" + ); + let signed_headers = "content-type;host;x-amz-content-sha256;x-amz-date"; + let canonical_request = format!( + "PUT\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}" + ); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}", + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + let signing_key = aws4_signing_key(&credentials.secret_key, &date_stamp, region, service); + let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())); + let authorization = format!( + "AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}", + credentials.access_key + ); + + Ok(SignedS3Request { + url, + host: host_header, + amz_date, + payload_hash, + authorization, + }) +} + +/// AWS SigV4 URI encode. `encode_slash` is false for path segments that must keep `/`. +fn aws_uri_encode(input: &str, encode_slash: bool) -> String { + let mut out = String::with_capacity(input.len()); + for b in input.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + b'/' if !encode_slash => out.push('/'), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + fn cors_configuration_xml(origins: &[&str]) -> String { let mut rules = String::new(); for origin in origins { @@ -556,6 +601,23 @@ mod tests { assert!(xml.contains("*")); } + #[test] + fn sign_s3_put_cors_uses_cors_eq_query_and_us_east_1_for_default_region() { + let credentials = BucketCredentials { + endpoint: "https://s3.example.com".into(), + bucket: "my-bucket".into(), + region: "default".into(), + access_key: "AKIA".into(), + secret_key: "secret".into(), + }; + let signed = sign_s3_put_cors(&credentials, b"").unwrap(); + assert_eq!(signed.url, "https://s3.example.com/my-bucket?cors"); + assert!(signed.authorization.contains("/us-east-1/s3/aws4_request")); + // Signature is deterministic for fixed clock only; at least shape is present. + assert!(signed.authorization.contains("Signature=")); + assert_eq!(signed.host, "s3.example.com"); + } + #[test] fn endpoint_override_replaces_internal_rgw_url() { let cm = ConfigMap { -- 2.39.5 From 66fea4ff6dc740912faa49f016060cd25ba57969 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 12:56:24 -0400 Subject: [PATCH 30/34] feat: ManagedPostgres debug_route with weight-toggle OKD Route --- harmony/src/modules/postgresql/mod.rs | 2 + .../modules/postgresql/score_debug_route.rs | 169 ++++++++++++++++++ harmony_app/src/application/k8s_anywhere.rs | 8 +- harmony_app/src/application/resources.rs | 10 ++ 4 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 harmony/src/modules/postgresql/score_debug_route.rs diff --git a/harmony/src/modules/postgresql/mod.rs b/harmony/src/modules/postgresql/mod.rs index f4d7b418..b734205e 100644 --- a/harmony/src/modules/postgresql/mod.rs +++ b/harmony/src/modules/postgresql/mod.rs @@ -6,6 +6,8 @@ pub use score_connect::*; pub use score_k8s::*; mod score_public; pub use score_public::*; +mod score_debug_route; +pub use score_debug_route::*; pub mod failover; mod operator; diff --git a/harmony/src/modules/postgresql/score_debug_route.rs b/harmony/src/modules/postgresql/score_debug_route.rs new file mode 100644 index 00000000..9986f577 --- /dev/null +++ b/harmony/src/modules/postgresql/score_debug_route.rs @@ -0,0 +1,169 @@ +use std::collections::BTreeMap; + +use async_trait::async_trait; +use harmony_types::id::Id; +use kube::api::ObjectMeta; +use log::info; +use serde::Serialize; + +use crate::data::Version; +use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; +use crate::inventory::Inventory; +use crate::modules::k8s::resource::K8sResourceScore; +use crate::modules::okd::crd::route::{ + Route, RoutePort, RouteSpec, RouteTargetReference, TLSConfig, +}; +use crate::score::Score; +use crate::topology::{K8sclient, TlsRouter, Topology}; + +/// Always-declared OKD TLS passthrough Route to a CNPG `-rw` Service for VPN/debug access. +/// +/// **Toggle in OKD console:** edit the Route and set `spec.to.weight`: +/// - `0` — off (default on first create; no traffic) +/// - `100` — on +/// +/// Subsequent Harmony ships **preserve** an existing Route's weight so UI toggles stick. +#[derive(Debug, Clone, Serialize)] +pub struct PostgresDebugRouteScore { + pub namespace: String, + pub cluster_name: String, +} + +impl PostgresDebugRouteScore { + pub fn new(namespace: impl Into, cluster_name: impl Into) -> Self { + Self { + namespace: namespace.into(), + cluster_name: cluster_name.into(), + } + } + + fn route_name(&self) -> String { + format!("{}-rw-debug", self.cluster_name) + } + + fn backend(&self) -> String { + format!("{}-rw", self.cluster_name) + } +} + +impl Score for PostgresDebugRouteScore { + fn create_interpret(&self) -> Box> { + Box::new(PostgresDebugRouteInterpret { + score: self.clone(), + }) + } + + fn name(&self) -> String { + format!( + "PostgresDebugRouteScore({}/{})", + self.namespace, self.cluster_name + ) + } +} + +#[derive(Debug, Clone)] +struct PostgresDebugRouteInterpret { + score: PostgresDebugRouteScore, +} + +#[async_trait] +impl Interpret for PostgresDebugRouteInterpret { + async fn execute( + &self, + inventory: &Inventory, + topology: &T, + ) -> Result { + let domain = topology + .get_internal_domain() + .await + .map_err(InterpretError::new)? + .ok_or_else(|| { + InterpretError::new( + "cluster has no internal apps domain; cannot declare DB debug route".into(), + ) + })?; + let hostname = format!("{}.{}", self.score.cluster_name, domain); + let route_name = self.score.route_name(); + let backend = self.score.backend(); + + let client = topology + .k8s_client() + .await + .map_err(|e| InterpretError::new(format!("get k8s client: {e}")))?; + + let weight = match client + .get_resource::(&route_name, Some(&self.score.namespace)) + .await + { + Ok(Some(existing)) => existing.spec.to.weight.unwrap_or(0), + Ok(None) => 0, + Err(e) => { + return Err(InterpretError::new(format!( + "get existing DB debug route: {e}" + ))); + } + }; + + info!( + "DB debug route '{}/{}' host={} weight={} (0=off 100=on; edit Route in console to toggle)", + self.score.namespace, route_name, hostname, weight + ); + + let mut annotations = BTreeMap::new(); + annotations.insert( + "harmony.nationtech.io/db-expose".into(), + "Set spec.to.weight to 100 to enable VPN/debug access, 0 to disable. Ships preserve weight.".into(), + ); + + let route = Route { + metadata: ObjectMeta { + name: Some(route_name.clone()), + namespace: Some(self.score.namespace.clone()), + annotations: Some(annotations), + ..ObjectMeta::default() + }, + spec: RouteSpec { + host: Some(hostname.clone()), + wildcard_policy: Some("None".into()), + to: RouteTargetReference { + kind: "Service".into(), + name: backend, + weight: Some(weight), + }, + port: Some(RoutePort { target_port: 5432 }), + tls: Some(TLSConfig { + termination: "passthrough".into(), + insecure_edge_termination_policy: Some("None".into()), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + + K8sResourceScore::single(route, Some(self.score.namespace.clone())) + .create_interpret() + .execute(inventory, topology) + .await?; + + Ok(Outcome::success(format!( + "DB debug route '{hostname}' weight={weight} (edit Route/{route_name} weight 0/100 to toggle)" + ))) + } + + fn get_name(&self) -> InterpretName { + InterpretName::Custom("PostgresDebugRouteInterpret") + } + + fn get_version(&self) -> Version { + todo!() + } + + fn get_status(&self) -> InterpretStatus { + todo!() + } + + fn get_children(&self) -> Vec { + todo!() + } +} diff --git a/harmony_app/src/application/k8s_anywhere.rs b/harmony_app/src/application/k8s_anywhere.rs index 5fd02502..7d028059 100644 --- a/harmony_app/src/application/k8s_anywhere.rs +++ b/harmony_app/src/application/k8s_anywhere.rs @@ -6,7 +6,7 @@ use harmony::data::Version; use harmony::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; use harmony::inventory::Inventory; use harmony::modules::k8s::resource::K8sResourceScore; -use harmony::modules::postgresql::K8sPostgreSQLScore; +use harmony::modules::postgresql::{K8sPostgreSQLScore, PostgresDebugRouteScore}; use harmony::modules::registry_pull_secret::RegistryPullSecretScore; use harmony::modules::storage::ObjectBucketScore; use harmony::modules::zitadel::{ZitadelContract, ZitadelScore, ZitadelSetupScore}; @@ -180,6 +180,12 @@ impl HarmonyApp for Application { application_database_binding(&database.name), ); scores.push(Box::new(score)); + if database.debug_route { + scores.push(Box::new(PostgresDebugRouteScore::new( + ctx.namespace(), + &database.name, + ))); + } } ManagedResource::Bucket(bucket) => { let endpoint = bucket diff --git a/harmony_app/src/application/resources.rs b/harmony_app/src/application/resources.rs index 9eb40e82..93a4ae11 100644 --- a/harmony_app/src/application/resources.rs +++ b/harmony_app/src/application/resources.rs @@ -98,6 +98,10 @@ pub struct ManagedPostgres { pub name: String, pub instances: u32, pub version: Option, + /// Always declare an OKD TLS passthrough Route to `{name}-rw` for VPN/debug. + /// Default **off** (`spec.to.weight: 0`). In the OKD console, set weight to `100` to enable. + /// Harmony ships preserve the live weight so UI toggles are not clobbered. + pub debug_route: bool, } impl ManagedPostgres { @@ -106,6 +110,7 @@ impl ManagedPostgres { name: name.into(), instances: 1, version: None, + debug_route: false, } } pub fn instances(mut self, instances: u32) -> Self { @@ -118,6 +123,11 @@ impl ManagedPostgres { self.version = Some(version.into()); self } + /// Declare a router TLS passthrough Route (weight 0 until enabled in console). + pub fn debug_route(mut self) -> Self { + self.debug_route = true; + self + } pub fn reference(&self) -> DatabaseRef { DatabaseRef(self.name.clone()) } -- 2.39.5 From 63aae5d02a7ea94149a965f3c94db051df98a95e Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 13:10:55 -0400 Subject: [PATCH 31/34] fix: DB debug route uses router-assigned host, no cluster config --- .../modules/postgresql/score_debug_route.rs | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/harmony/src/modules/postgresql/score_debug_route.rs b/harmony/src/modules/postgresql/score_debug_route.rs index 9986f577..369a8fc0 100644 --- a/harmony/src/modules/postgresql/score_debug_route.rs +++ b/harmony/src/modules/postgresql/score_debug_route.rs @@ -14,10 +14,13 @@ use crate::modules::okd::crd::route::{ Route, RoutePort, RouteSpec, RouteTargetReference, TLSConfig, }; use crate::score::Score; -use crate::topology::{K8sclient, TlsRouter, Topology}; +use crate::topology::{K8sclient, Topology}; /// Always-declared OKD TLS passthrough Route to a CNPG `-rw` Service for VPN/debug access. /// +/// Host is **omitted** so the cluster default router assigns +/// `{route-name}-{namespace}.{apps-domain}` (no cluster-scoped config read). +/// /// **Toggle in OKD console:** edit the Route and set `spec.to.weight`: /// - `0` — off (default on first create; no traffic) /// - `100` — on @@ -46,7 +49,7 @@ impl PostgresDebugRouteScore { } } -impl Score for PostgresDebugRouteScore { +impl Score for PostgresDebugRouteScore { fn create_interpret(&self) -> Box> { Box::new(PostgresDebugRouteInterpret { score: self.clone(), @@ -67,22 +70,12 @@ struct PostgresDebugRouteInterpret { } #[async_trait] -impl Interpret for PostgresDebugRouteInterpret { +impl Interpret for PostgresDebugRouteInterpret { async fn execute( &self, inventory: &Inventory, topology: &T, ) -> Result { - let domain = topology - .get_internal_domain() - .await - .map_err(InterpretError::new)? - .ok_or_else(|| { - InterpretError::new( - "cluster has no internal apps domain; cannot declare DB debug route".into(), - ) - })?; - let hostname = format!("{}.{}", self.score.cluster_name, domain); let route_name = self.score.route_name(); let backend = self.score.backend(); @@ -91,12 +84,25 @@ impl Interpret for PostgresDebugRouteInt .await .map_err(|e| InterpretError::new(format!("get k8s client: {e}")))?; - let weight = match client + // Preserve weight + host from live object so console toggles and router-assigned + // FQDNs survive re-ship. New routes omit host → default router assigns + // `{name}-{namespace}.{apps-domain}` (no cluster-scoped config read). + let (weight, host) = match client .get_resource::(&route_name, Some(&self.score.namespace)) .await { - Ok(Some(existing)) => existing.spec.to.weight.unwrap_or(0), - Ok(None) => 0, + Ok(Some(existing)) => { + let host = existing.spec.host.or_else(|| { + existing + .status + .as_ref() + .and_then(|s| s.ingress.as_ref()) + .and_then(|ings| ings.first()) + .and_then(|i| i.host.clone()) + }); + (existing.spec.to.weight.unwrap_or(0), host) + } + Ok(None) => (0, None), Err(e) => { return Err(InterpretError::new(format!( "get existing DB debug route: {e}" @@ -104,15 +110,16 @@ impl Interpret for PostgresDebugRouteInt } }; + let host_label = host.clone().unwrap_or_else(|| "(router-assigned)".into()); info!( "DB debug route '{}/{}' host={} weight={} (0=off 100=on; edit Route in console to toggle)", - self.score.namespace, route_name, hostname, weight + self.score.namespace, route_name, host_label, weight ); let mut annotations = BTreeMap::new(); annotations.insert( "harmony.nationtech.io/db-expose".into(), - "Set spec.to.weight to 100 to enable VPN/debug access, 0 to disable. Ships preserve weight.".into(), + "Set spec.to.weight to 100 to enable VPN/debug access, 0 to disable. Ships preserve weight. Host is router-assigned when empty.".into(), ); let route = Route { @@ -123,7 +130,7 @@ impl Interpret for PostgresDebugRouteInt ..ObjectMeta::default() }, spec: RouteSpec { - host: Some(hostname.clone()), + host, wildcard_policy: Some("None".into()), to: RouteTargetReference { kind: "Service".into(), @@ -147,7 +154,7 @@ impl Interpret for PostgresDebugRouteInt .await?; Ok(Outcome::success(format!( - "DB debug route '{hostname}' weight={weight} (edit Route/{route_name} weight 0/100 to toggle)" + "DB debug route '{route_name}' weight={weight} host={host_label} (edit weight 0/100 to toggle)" ))) } -- 2.39.5 From b96849036c103fac062f4f3d32799e15ed2e8fa1 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 13:16:15 -0400 Subject: [PATCH 32/34] feat: grant tenant deployers OpenShift routes RBAC --- harmony_app/src/tenant.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/harmony_app/src/tenant.rs b/harmony_app/src/tenant.rs index 8e82df70..1780d067 100644 --- a/harmony_app/src/tenant.rs +++ b/harmony_app/src/tenant.rs @@ -180,6 +180,8 @@ fn application_deployer_rules() -> Vec { &["ingresses", "networkpolicies"], verbs(), ), + // TLS passthrough to CNPG (debug_route) needs Routes; plain Ingress is HTTP-only. + rule("route.openshift.io", &["routes"], verbs()), rule("policy", &["poddisruptionbudgets"], verbs()), rule( "rbac.authorization.k8s.io", @@ -209,7 +211,7 @@ mod tests { use super::*; #[test] - fn application_deployer_can_manage_cnpg_and_obc_without_fleet_permissions() { + fn application_deployer_can_manage_cnpg_obc_and_routes_without_fleet_permissions() { let rules = application_deployer_rules(); assert!(rules.iter().any(|rule| { rule.api_groups.as_deref() == Some(&["postgresql.cnpg.io".to_string()]) @@ -217,7 +219,14 @@ mod tests { })); assert!(rules.iter().any(|rule| { rule.api_groups.as_deref() == Some(&["objectbucket.io".to_string()]) - && rule.resources.as_deref() == Some(&["objectbucketclaims".to_string()]) + && rule + .resources + .as_ref() + .is_some_and(|r| r.iter().any(|n| n == "objectbucketclaims")) + })); + assert!(rules.iter().any(|rule| { + rule.api_groups.as_deref() == Some(&["route.openshift.io".to_string()]) + && rule.resources.as_deref() == Some(&["routes".to_string()]) })); assert!(!rules.iter().any(|rule| { rule.api_groups -- 2.39.5 From 7d75358f487993ef47ed7030b1624d7cdfa02af5 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 1 Aug 2026 14:48:08 -0400 Subject: [PATCH 33/34] fix: PG debug expose via NodePort Service for PG16 clients --- .../modules/postgresql/score_debug_route.rs | 136 ++++++++++-------- harmony_app/src/application/resources.rs | 8 +- 2 files changed, 81 insertions(+), 63 deletions(-) diff --git a/harmony/src/modules/postgresql/score_debug_route.rs b/harmony/src/modules/postgresql/score_debug_route.rs index 369a8fc0..4c5218aa 100644 --- a/harmony/src/modules/postgresql/score_debug_route.rs +++ b/harmony/src/modules/postgresql/score_debug_route.rs @@ -2,30 +2,33 @@ use std::collections::BTreeMap; use async_trait::async_trait; use harmony_types::id::Id; -use kube::api::ObjectMeta; -use log::info; +use k8s_openapi::api::core::v1::{Service, ServicePort, ServiceSpec}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; +use log::{info, warn}; use serde::Serialize; use crate::data::Version; use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; use crate::inventory::Inventory; use crate::modules::k8s::resource::K8sResourceScore; -use crate::modules::okd::crd::route::{ - Route, RoutePort, RouteSpec, RouteTargetReference, TLSConfig, -}; +use crate::modules::okd::crd::route::Route; use crate::score::Score; use crate::topology::{K8sclient, Topology}; -/// Always-declared OKD TLS passthrough Route to a CNPG `-rw` Service for VPN/debug access. +/// VPN/debug expose for a CNPG primary via a companion Service. /// -/// Host is **omitted** so the cluster default router assigns -/// `{route-name}-{namespace}.{apps-domain}` (no cluster-scoped config read). +/// **Why not an OKD TLS passthrough Route?** PostgreSQL before 17 does TLS only after the +/// PostgreSQL `SSLRequest` startup packet, not with a TLS ClientHello first. The OpenShift +/// router needs ClientHello+SNI for passthrough. PG 17 adds `sslnegotiation=direct`; we pin +/// PG 16 for Flyway/Quarkus, so typical clients (pgAdmin, stock libpq) cannot use a passthrough +/// Route. A NodePort speaks plain TCP to the pod; the client then does normal PG SSL to CNPG. /// -/// **Toggle in OKD console:** edit the Route and set `spec.to.weight`: -/// - `0` — off (default on first create; no traffic) -/// - `100` — on +/// **Toggle in OKD console:** edit Service `{cluster}-rw-debug`: +/// - `spec.type: ClusterIP` — off (default on first create) +/// - `spec.type: NodePort` — on (any node/VPN IP + `nodePort`, `sslmode=require`) /// -/// Subsequent Harmony ships **preserve** an existing Route's weight so UI toggles stick. +/// Ships preserve live `type` and `nodePort`. #[derive(Debug, Clone, Serialize)] pub struct PostgresDebugRouteScore { pub namespace: String, @@ -40,13 +43,9 @@ impl PostgresDebugRouteScore { } } - fn route_name(&self) -> String { + fn service_name(&self) -> String { format!("{}-rw-debug", self.cluster_name) } - - fn backend(&self) -> String { - format!("{}-rw", self.cluster_name) - } } impl Score for PostgresDebugRouteScore { @@ -76,85 +75,104 @@ impl Interpret for PostgresDebugRouteInterpret { inventory: &Inventory, topology: &T, ) -> Result { - let route_name = self.score.route_name(); - let backend = self.score.backend(); - + let name = self.score.service_name(); let client = topology .k8s_client() .await .map_err(|e| InterpretError::new(format!("get k8s client: {e}")))?; - // Preserve weight + host from live object so console toggles and router-assigned - // FQDNs survive re-ship. New routes omit host → default router assigns - // `{name}-{namespace}.{apps-domain}` (no cluster-scoped config read). - let (weight, host) = match client - .get_resource::(&route_name, Some(&self.score.namespace)) + // Best-effort cleanup of the earlier passthrough Route (same name). + match client + .delete_resource::(&name, Some(&self.score.namespace)) + .await + { + Ok(()) => {} + Err(e) => warn!( + "could not delete legacy DB debug Route {}/{}: {e}", + self.score.namespace, name + ), + } + + let (svc_type, node_port) = match client + .get_resource::(&name, Some(&self.score.namespace)) .await { Ok(Some(existing)) => { - let host = existing.spec.host.or_else(|| { - existing - .status - .as_ref() - .and_then(|s| s.ingress.as_ref()) - .and_then(|ings| ings.first()) - .and_then(|i| i.host.clone()) - }); - (existing.spec.to.weight.unwrap_or(0), host) + let t = existing + .spec + .as_ref() + .and_then(|s| s.type_.clone()) + .unwrap_or_else(|| "ClusterIP".into()); + let np = existing + .spec + .as_ref() + .and_then(|s| s.ports.as_ref()) + .and_then(|p| p.first()) + .and_then(|p| p.node_port); + (t, np) } - Ok(None) => (0, None), + Ok(None) => ("ClusterIP".into(), None), Err(e) => { return Err(InterpretError::new(format!( - "get existing DB debug route: {e}" + "get existing DB debug service: {e}" ))); } }; - let host_label = host.clone().unwrap_or_else(|| "(router-assigned)".into()); info!( - "DB debug route '{}/{}' host={} weight={} (0=off 100=on; edit Route in console to toggle)", - self.score.namespace, route_name, host_label, weight + "DB debug service '{}/{}' type={} nodePort={:?} (ClusterIP=off NodePort=on)", + self.score.namespace, name, svc_type, node_port ); let mut annotations = BTreeMap::new(); annotations.insert( "harmony.nationtech.io/db-expose".into(), - "Set spec.to.weight to 100 to enable VPN/debug access, 0 to disable. Ships preserve weight. Host is router-assigned when empty.".into(), + "Set spec.type to NodePort to enable VPN/debug (node IP + nodePort, sslmode=require). ClusterIP disables. Ships preserve type/nodePort. PG16 cannot use TLS-passthrough Routes (needs sslnegotiation=direct from PG17+).".into(), ); - let route = Route { + let mut port = ServicePort { + name: Some("postgres".into()), + port: 5432, + protocol: Some("TCP".into()), + target_port: Some(IntOrString::Int(5432)), + ..Default::default() + }; + if svc_type == "NodePort" + && let Some(np) = node_port + { + port.node_port = Some(np); + } + + let service = Service { metadata: ObjectMeta { - name: Some(route_name.clone()), + name: Some(name.clone()), namespace: Some(self.score.namespace.clone()), annotations: Some(annotations), + labels: Some(BTreeMap::from([ + ("cnpg.io/cluster".into(), self.score.cluster_name.clone()), + ("harmony.nationtech.io/role".into(), "db-debug".into()), + ])), ..ObjectMeta::default() }, - spec: RouteSpec { - host, - wildcard_policy: Some("None".into()), - to: RouteTargetReference { - kind: "Service".into(), - name: backend, - weight: Some(weight), - }, - port: Some(RoutePort { target_port: 5432 }), - tls: Some(TLSConfig { - termination: "passthrough".into(), - insecure_edge_termination_policy: Some("None".into()), - ..Default::default() - }), + spec: Some(ServiceSpec { + type_: Some(svc_type.clone()), + selector: Some(BTreeMap::from([ + ("cnpg.io/cluster".into(), self.score.cluster_name.clone()), + ("cnpg.io/instanceRole".into(), "primary".into()), + ])), + ports: Some(vec![port]), ..Default::default() - }, + }), ..Default::default() }; - K8sResourceScore::single(route, Some(self.score.namespace.clone())) + K8sResourceScore::single(service, Some(self.score.namespace.clone())) .create_interpret() .execute(inventory, topology) .await?; Ok(Outcome::success(format!( - "DB debug route '{route_name}' weight={weight} host={host_label} (edit weight 0/100 to toggle)" + "DB debug service '{name}' type={svc_type} (edit Service type ClusterIP/NodePort to toggle)" ))) } diff --git a/harmony_app/src/application/resources.rs b/harmony_app/src/application/resources.rs index 93a4ae11..f1458ec0 100644 --- a/harmony_app/src/application/resources.rs +++ b/harmony_app/src/application/resources.rs @@ -98,9 +98,9 @@ pub struct ManagedPostgres { pub name: String, pub instances: u32, pub version: Option, - /// Always declare an OKD TLS passthrough Route to `{name}-rw` for VPN/debug. - /// Default **off** (`spec.to.weight: 0`). In the OKD console, set weight to `100` to enable. - /// Harmony ships preserve the live weight so UI toggles are not clobbered. + /// Companion Service `{name}-rw-debug` for VPN/debug (NodePort toggle). + /// Default **off** (`ClusterIP`). In the OKD console set `spec.type: NodePort` to enable. + /// Ships preserve live type/nodePort. (Not a TLS Route: PG16 lacks direct TLS/SNI for routers.) pub debug_route: bool, } @@ -123,7 +123,7 @@ impl ManagedPostgres { self.version = Some(version.into()); self } - /// Declare a router TLS passthrough Route (weight 0 until enabled in console). + /// Declare a debug Service (ClusterIP off / NodePort on in the console). pub fn debug_route(mut self) -> Self { self.debug_route = true; self -- 2.39.5 From 0c6cc91fcffd4f61909ea29547b49943618c0b04 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 3 Aug 2026 16:52:56 -0400 Subject: [PATCH 34/34] chore: Add notice about architecture review in code review skill --- .../skills/essential-code-review/SKILL.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.opencode/skills/essential-code-review/SKILL.md b/.opencode/skills/essential-code-review/SKILL.md index 5b66099e..ca7dcc47 100644 --- a/.opencode/skills/essential-code-review/SKILL.md +++ b/.opencode/skills/essential-code-review/SKILL.md @@ -93,6 +93,27 @@ The opposite failure is one function or type owning unrelated reasons to change. Split by concern when the parts have different callers, lifecycles, trust boundaries, or tests. Do not split merely to shorten a function. +### Misplaced knowledge + +Architecture follows information ownership, not call-site convenience. Put +each fact in the layer whose reason to change it: + +- domain and policy code state intent and invariants; +- adapters own provider syntax, protocol details, paths, and storage layout; +- composition roots select and wire abstractions without recreating backend + construction or deriving provider-specific coordinates; +- neutral value-type crates contain shared vocabulary, not constants belonging + to one backend. + +Ask which module should change when the provider, path layout, authorization +syntax, or source chain changes. That module owns the knowledge. Warning signs +include raw HCL, SQL, manifests, or provider paths in policy code; storage +constants in generic types; the same authenticated adapter constructed twice +for different views; and application code rebuilding a source chain already +owned by infrastructure. Move the mechanism to the deepest existing owner and +expose the smallest operation or view the caller needs. Keep product choices in +the application layer; moving everything downward is also misplaced ownership. + ### Duplicate knowledge Two copies of the same condition, default, mapping, or deployment recipe in one @@ -148,6 +169,7 @@ After tests pass, inspect only the diff and ask: 5. Did test setup reimplement production behavior? 6. Can control flow become linear and local? 7. Does every changed file need to be in this PR? +8. Does each fact live where its reason to change lives? Rewrite when the answer exposes accidental complexity. Run verification again. -- 2.39.5