From ff297b61b5b2fe59a0091c1e87ae45c5f3520346 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sun, 5 Apr 2026 19:44:23 +0100 Subject: [PATCH] chore: remove pre-WFE monolithic functions Remove ~1400 lines of dead code now replaced by WFE primitives: - cluster.rs: cmd_up, ensure_cert_manager, ensure_tls_cert/secret, wait_for_core, print_urls, secrets_dir, CERT_MANAGER_URL - secrets.rs: cmd_seed, cmd_verify, seed_openbao, seed_kratos_admin_identity, SeedResult, delete_crd, delete_k8s_secret, kubectl_jsonpath --- src/cluster.rs | 334 +------ src/secrets.rs | 1169 +----------------------- src/workflows/up/steps/certificates.rs | 17 - 3 files changed, 10 insertions(+), 1510 deletions(-) diff --git a/src/cluster.rs b/src/cluster.rs index 8950c32a..528d560a 100644 --- a/src/cluster.rs +++ b/src/cluster.rs @@ -1,218 +1,6 @@ -//! Cluster lifecycle — cert-manager, TLS, core service readiness. -//! -//! Pure K8s implementation: no Lima VM operations. +//! Cluster lifecycle helpers. -use crate::constants::GITEA_ADMIN_USER; -use crate::error::{Result, ResultExt, SunbeamError}; -use std::path::PathBuf; - -pub(crate) const CERT_MANAGER_URL: &str = - "https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml"; - -pub(crate) fn secrets_dir() -> PathBuf { - crate::config::get_infra_dir() - .join("secrets") - .join("local") -} - -// --------------------------------------------------------------------------- -// cert-manager -// --------------------------------------------------------------------------- - -async fn ensure_cert_manager() -> Result<()> { - crate::output::step("cert-manager..."); - - if crate::kube::ns_exists("cert-manager").await? { - crate::output::ok("Already installed."); - return Ok(()); - } - - crate::output::ok("Installing..."); - - // Download and apply cert-manager YAML - let body = reqwest::get(CERT_MANAGER_URL) - .await - .ctx("Failed to download cert-manager manifest")? - .text() - .await - .ctx("Failed to read cert-manager manifest body")?; - - crate::kube::kube_apply(&body).await?; - - // Wait for rollout - for dep in &[ - "cert-manager", - "cert-manager-webhook", - "cert-manager-cainjector", - ] { - crate::output::ok(&format!("Waiting for {dep}...")); - wait_rollout("cert-manager", dep, 120).await?; - } - - crate::output::ok("Installed."); - Ok(()) -} - -// --------------------------------------------------------------------------- -// TLS certificate (rcgen) -// --------------------------------------------------------------------------- - -pub(crate) async fn ensure_tls_cert(domain: &str) -> Result<()> { - crate::output::step("TLS certificate..."); - - let dir = secrets_dir(); - let cert_path = dir.join("tls.crt"); - let key_path = dir.join("tls.key"); - - if cert_path.exists() { - crate::output::ok(&format!("Cert exists. Domain: {domain}")); - return Ok(()); - } - - crate::output::ok(&format!("Generating wildcard cert for *.{domain}...")); - std::fs::create_dir_all(&dir) - .with_ctx(|| format!("Failed to create secrets dir: {}", dir.display()))?; - - let subject_alt_names = vec![format!("*.{domain}")]; - let mut params = rcgen::CertificateParams::new(subject_alt_names) - .map_err(|e| SunbeamError::kube(format!("Failed to create certificate params: {e}")))?; - params - .distinguished_name - .push(rcgen::DnType::CommonName, format!("*.{domain}")); - - let key_pair = rcgen::KeyPair::generate() - .map_err(|e| SunbeamError::kube(format!("Failed to generate key pair: {e}")))?; - let cert = params - .self_signed(&key_pair) - .map_err(|e| SunbeamError::kube(format!("Failed to generate self-signed certificate: {e}")))?; - - std::fs::write(&cert_path, cert.pem()) - .with_ctx(|| format!("Failed to write {}", cert_path.display()))?; - std::fs::write(&key_path, key_pair.serialize_pem()) - .with_ctx(|| format!("Failed to write {}", key_path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?; - } - - crate::output::ok(&format!("Cert generated. Domain: {domain}")); - Ok(()) -} - -// --------------------------------------------------------------------------- -// TLS secret -// --------------------------------------------------------------------------- - -pub(crate) async fn ensure_tls_secret(domain: &str) -> Result<()> { - crate::output::step("TLS secret..."); - - let _ = domain; // domain used contextually above; secret uses files - crate::kube::ensure_ns("ingress").await?; - - let dir = secrets_dir(); - let cert_pem = - std::fs::read_to_string(dir.join("tls.crt")).ctx("Failed to read tls.crt")?; - let key_pem = - std::fs::read_to_string(dir.join("tls.key")).ctx("Failed to read tls.key")?; - - // Create TLS secret via kube-rs - let client = crate::kube::get_client().await?; - let api: kube::api::Api = - kube::api::Api::namespaced(client.clone(), "ingress"); - - let b64_cert = base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - cert_pem.as_bytes(), - ); - let b64_key = base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - key_pem.as_bytes(), - ); - - let secret_obj = serde_json::json!({ - "apiVersion": "v1", - "kind": "Secret", - "metadata": { - "name": "pingora-tls", - "namespace": "ingress", - }, - "type": "kubernetes.io/tls", - "data": { - "tls.crt": b64_cert, - "tls.key": b64_key, - }, - }); - - let pp = kube::api::PatchParams::apply("sunbeam").force(); - api.patch("pingora-tls", &pp, &kube::api::Patch::Apply(secret_obj)) - .await - .ctx("Failed to create TLS secret")?; - - crate::output::ok("Done."); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Wait for core -// --------------------------------------------------------------------------- - -async fn wait_for_core() -> Result<()> { - crate::output::step("Waiting for core services..."); - - for (ns, dep) in &[("data", "valkey"), ("ory", "kratos"), ("ory", "hydra")] { - let _ = wait_rollout(ns, dep, 120).await; - } - - crate::output::ok("Core services ready."); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Print URLs -// --------------------------------------------------------------------------- - -pub(crate) fn print_urls(domain: &str, _gitea_admin_pass: &str) { - let sep = "\u{2500}".repeat(60); - println!("\n{sep}"); - println!(" Stack is up. Domain: {domain}"); - println!("{sep}"); - - let urls: &[(&str, String)] = &[ - ("Auth", format!("https://auth.{domain}/")), - ("Docs", format!("https://docs.{domain}/")), - ("Meet", format!("https://meet.{domain}/")), - ("Drive", format!("https://drive.{domain}/")), - ("Chat", format!("https://chat.{domain}/")), - ("Mail", format!("https://mail.{domain}/")), - ("People", format!("https://people.{domain}/")), - ( - "Gitea", - format!( - "https://src.{domain}/ ({GITEA_ADMIN_USER} / )" - ), - ), - ]; - - for (name, url) in urls { - println!(" {name:<10} {url}"); - } - - println!(); - println!(" OpenBao UI:"); - println!(" kubectl --context=sunbeam -n data port-forward svc/openbao 8200:8200"); - println!(" http://localhost:8200"); - println!( - " token: kubectl --context=sunbeam -n data get secret openbao-keys \ - -o jsonpath='{{.data.root-token}}' | base64 -d" - ); - println!("{sep}\n"); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +use crate::error::{Result, SunbeamError}; /// Poll deployment rollout status (approximate: check Available condition). pub(crate) async fn wait_rollout(ns: &str, deployment: &str, timeout_secs: u64) -> Result<()> { @@ -242,134 +30,20 @@ pub(crate) async fn wait_rollout(ns: &str, deployment: &str, timeout_secs: u64) } } } - None => { - // Deployment doesn't exist yet — keep waiting - } + None => {} } tokio::time::sleep(Duration::from_secs(3)).await; } } -// --------------------------------------------------------------------------- -// Commands -// --------------------------------------------------------------------------- - -/// Full cluster bring-up (pure K8s — no Lima VM operations). -pub async fn cmd_up() -> Result<()> { - // Resolve domain from cluster state - let domain = crate::kube::get_domain().await?; - - ensure_cert_manager().await?; - ensure_tls_cert(&domain).await?; - ensure_tls_secret(&domain).await?; - - // Apply manifests - crate::manifests::cmd_apply("local", &domain, "", "").await?; - - // Seed secrets - crate::secrets::cmd_seed().await?; - - // Gitea bootstrap - crate::gitea::cmd_bootstrap().await?; - - // Mirror amd64-only images - crate::images::cmd_mirror().await?; - - // Wait for core services - wait_for_core().await?; - - // Get gitea admin password for URL display - let admin_pass = crate::kube::kube_get_secret_field( - "devtools", - "gitea-admin-credentials", - "password", - ) - .await - .unwrap_or_default(); - - print_urls(&domain, &admin_pass); - Ok(()) -} - #[cfg(test)] mod tests { use super::*; - - #[test] - fn cert_manager_url_points_to_github_release() { - assert!(CERT_MANAGER_URL.starts_with("https://github.com/cert-manager/cert-manager/")); - assert!(CERT_MANAGER_URL.contains("/releases/download/")); - assert!(CERT_MANAGER_URL.ends_with(".yaml")); - } - - #[test] - fn cert_manager_url_has_version() { - // Verify the URL contains a version tag like v1.x.x - assert!( - CERT_MANAGER_URL.contains("/v1."), - "CERT_MANAGER_URL should reference a v1.x release" - ); - } - - #[test] - fn secrets_dir_ends_with_secrets_local() { - let dir = secrets_dir(); - assert!( - dir.ends_with("secrets/local"), - "secrets_dir() should end with secrets/local, got: {}", - dir.display() - ); - } - - #[test] - fn secrets_dir_has_at_least_three_components() { - let dir = secrets_dir(); - let components: Vec<_> = dir.components().collect(); - assert!( - components.len() >= 3, - "secrets_dir() should have at least 3 path components (base/secrets/local), got: {}", - dir.display() - ); - } + use crate::constants::GITEA_ADMIN_USER; #[test] fn gitea_admin_user_constant() { assert_eq!(GITEA_ADMIN_USER, "gitea_admin"); } - - #[test] - fn print_urls_contains_expected_services() { - // Capture print_urls output by checking the URL construction logic. - // We can't easily capture stdout in unit tests, but we can verify - // the URL format matches expectations. - let domain = "test.local"; - let expected_urls = [ - format!("https://auth.{domain}/"), - format!("https://docs.{domain}/"), - format!("https://meet.{domain}/"), - format!("https://drive.{domain}/"), - format!("https://chat.{domain}/"), - format!("https://mail.{domain}/"), - format!("https://people.{domain}/"), - format!("https://src.{domain}/"), - ]; - - // Verify URL patterns are valid - for url in &expected_urls { - assert!(url.starts_with("https://")); - assert!(url.contains(domain)); - } - } - - #[test] - fn print_urls_gitea_includes_credentials() { - let domain = "example.local"; - let gitea_url = format!( - "https://src.{domain}/ ({GITEA_ADMIN_USER} / )" - ); - assert!(gitea_url.contains(GITEA_ADMIN_USER)); - assert!(gitea_url.contains("")); - assert!(gitea_url.contains(&format!("src.{domain}"))); - } } diff --git a/src/secrets.rs b/src/secrets.rs index c85b7485..704292eb 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -1,12 +1,11 @@ -//! Secrets management — OpenBao KV seeding, DB engine config, VSO verification. +//! Secrets management — shared helpers for OpenBao, port-forwarding, and DB engine config. //! -//! Replaces Python's `kubectl exec openbao-0 -- bao ...` pattern with: -//! 1. kube-rs port-forward to openbao pod on port 8200 -//! 2. `crate::openbao::BaoClient` for all HTTP API calls +//! High-level seed/verify orchestration lives in WFE workflow primitives; +//! this module provides the building blocks they call into. use crate::error::{Result, ResultExt, SunbeamError}; use k8s_openapi::api::core::v1::Pod; -use kube::api::{Api, ApiResource, DynamicObject, ListParams}; +use kube::api::{Api, ListParams}; use rand::RngCore; use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey}; use rsa::RsaPrivateKey; @@ -16,7 +15,7 @@ use tokio::net::TcpListener; use crate::kube as k; use crate::openbao::BaoClient; -use crate::output::{ok, step, warn}; +use crate::output::{ok, warn}; // ── Constants ─────────────────────────────────────────────────────────────── @@ -226,13 +225,6 @@ pub(crate) async fn port_forward_svc( // ── OpenBao KV seeding ────────────────────────────────────────────────────── -/// Internal result from seed_openbao, used by cmd_seed. -struct SeedResult { - creds: HashMap, - ob_pod: String, - root_token: String, -} - /// Read-or-create pattern: reads existing KV values, only generates missing ones. pub(crate) async fn get_or_create( bao: &BaoClient, @@ -254,513 +246,6 @@ pub(crate) async fn get_or_create( Ok(result) } -/// Initialize/unseal OpenBao, generate/read credentials idempotently, configure VSO auth. -async fn seed_openbao() -> Result> { - let client = k::get_client().await?; - let pods: Api = Api::namespaced(client.clone(), "data"); - let lp = ListParams::default().labels("app.kubernetes.io/name=openbao,component=server"); - let pod_list = pods.list(&lp).await?; - - let ob_pod = match pod_list - .items - .first() - .and_then(|p| p.metadata.name.as_deref()) - { - Some(name) => name.to_string(), - None => { - ok("OpenBao pod not found -- skipping."); - return Ok(None); - } - }; - - ok(&format!("OpenBao ({ob_pod})...")); - let _ = wait_pod_running("data", &ob_pod, 120).await; - - let pf = port_forward("data", &ob_pod, 8200).await?; - let bao_url = format!("http://127.0.0.1:{}", pf.local_port); - let bao = BaoClient::new(&bao_url); - - // ── Init / Unseal ─────────────────────────────────────────────────── - let mut unseal_key = String::new(); - let mut root_token = String::new(); - - let status = bao.seal_status().await.unwrap_or_else(|_| { - crate::openbao::SealStatusResponse { - initialized: false, - sealed: true, - progress: 0, - t: 0, - n: 0, - } - }); - - let mut already_initialized = status.initialized; - if !already_initialized { - if let Ok(Some(_)) = k::kube_get_secret("data", "openbao-keys").await { - already_initialized = true; - } - } - - if !already_initialized { - ok("Initializing OpenBao..."); - match bao.init(1, 1).await { - Ok(init) => { - unseal_key = init.unseal_keys_b64[0].clone(); - root_token = init.root_token.clone(); - let mut data = HashMap::new(); - data.insert("key".to_string(), unseal_key.clone()); - data.insert("root-token".to_string(), root_token.clone()); - k::create_secret("data", "openbao-keys", data).await?; - ok("Initialized -- keys stored in secret/openbao-keys."); - } - Err(e) => { - warn(&format!( - "Init failed -- resetting OpenBao storage for local dev... ({e})" - )); - let _ = delete_resource("data", "pvc", "data-openbao-0").await; - let _ = delete_resource("data", "pod", &ob_pod).await; - warn("OpenBao storage reset. Run --seed again after the pod restarts."); - return Ok(None); - } - } - } else { - ok("Already initialized."); - if let Ok(key) = k::kube_get_secret_field("data", "openbao-keys", "key").await { - unseal_key = key; - } - if let Ok(token) = k::kube_get_secret_field("data", "openbao-keys", "root-token").await { - root_token = token; - } - } - - // Unseal if needed - let status = bao.seal_status().await.unwrap_or_else(|_| { - crate::openbao::SealStatusResponse { - initialized: true, - sealed: true, - progress: 0, - t: 0, - n: 0, - } - }); - if status.sealed && !unseal_key.is_empty() { - ok("Unsealing..."); - bao.unseal(&unseal_key).await?; - } - - if root_token.is_empty() { - warn("No root token available -- skipping KV seeding."); - return Ok(None); - } - - let bao = BaoClient::with_token(&bao_url, &root_token); - - // ── KV seeding ────────────────────────────────────────────────────── - ok("Seeding KV (idempotent -- existing values preserved)..."); - let _ = bao.enable_secrets_engine("secret", "kv").await; - let _ = bao - .write( - "sys/mounts/secret/tune", - &serde_json::json!({"options": {"version": "2"}}), - ) - .await; - - let mut dirty_paths: HashSet = HashSet::new(); - - let hydra = get_or_create( - &bao, - "hydra", - &[ - ("system-secret", &rand_token as &(dyn Fn() -> String + Send + Sync)), - ("cookie-secret", &rand_token), - ("pairwise-salt", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let smtp_uri_fn = || SMTP_URI.to_string(); - let kratos = get_or_create( - &bao, - "kratos", - &[ - ("secrets-default", &rand_token as &(dyn Fn() -> String + Send + Sync)), - ("secrets-cookie", &rand_token), - ("smtp-connection-uri", &smtp_uri_fn), - ], - &mut dirty_paths, - ) - .await?; - - let seaweedfs = get_or_create( - &bao, - "seaweedfs", - &[ - ("access-key", &rand_token as &(dyn Fn() -> String + Send + Sync)), - ("secret-key", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let gitea_admin_user_fn = || GITEA_ADMIN_USER.to_string(); - let gitea = get_or_create( - &bao, - "gitea", - &[ - ( - "admin-username", - &gitea_admin_user_fn as &(dyn Fn() -> String + Send + Sync), - ), - ("admin-password", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let hive_local_fn = || "hive-local".to_string(); - let hive = get_or_create( - &bao, - "hive", - &[ - ("oidc-client-id", &hive_local_fn as &(dyn Fn() -> String + Send + Sync)), - ("oidc-client-secret", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let devkey_fn = || "devkey".to_string(); - let livekit = get_or_create( - &bao, - "livekit", - &[ - ("api-key", &devkey_fn as &(dyn Fn() -> String + Send + Sync)), - ("api-secret", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let people = get_or_create( - &bao, - "people", - &[("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))], - &mut dirty_paths, - ) - .await?; - - let login_ui = get_or_create( - &bao, - "login-ui", - &[ - ("cookie-secret", &rand_token as &(dyn Fn() -> String + Send + Sync)), - ("csrf-cookie-secret", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let sw_access = seaweedfs.get("access-key").cloned().unwrap_or_default(); - let sw_secret = seaweedfs.get("secret-key").cloned().unwrap_or_default(); - let empty_fn = || String::new(); - let sw_access_fn = { - let v = sw_access.clone(); - move || v.clone() - }; - let sw_secret_fn = { - let v = sw_secret.clone(); - move || v.clone() - }; - - let kratos_admin = get_or_create( - &bao, - "kratos-admin", - &[ - ("cookie-secret", &rand_token as &(dyn Fn() -> String + Send + Sync)), - ("csrf-cookie-secret", &rand_token), - ("admin-identity-ids", &empty_fn), - ("s3-access-key", &sw_access_fn), - ("s3-secret-key", &sw_secret_fn), - ], - &mut dirty_paths, - ) - .await?; - - let docs = get_or_create( - &bao, - "docs", - &[ - ("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync)), - ("collaboration-secret", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let meet = get_or_create( - &bao, - "meet", - &[ - ("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync)), - ("application-jwt-secret-key", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let drive = get_or_create( - &bao, - "drive", - &[("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))], - &mut dirty_paths, - ) - .await?; - - let projects = get_or_create( - &bao, - "projects", - &[("secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))], - &mut dirty_paths, - ) - .await?; - - let cal_django_fn = || rand_token_n(50); - let calendars = get_or_create( - &bao, - "calendars", - &[ - ("django-secret-key", &cal_django_fn as &(dyn Fn() -> String + Send + Sync)), - ("salt-key", &rand_token), - ("caldav-inbound-api-key", &rand_token), - ("caldav-outbound-api-key", &rand_token), - ("caldav-internal-api-key", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - // DKIM key pair — generated together since keys are coupled. - let existing_messages = bao.kv_get("secret", "messages").await?.unwrap_or_default(); - let (dkim_private, dkim_public) = if existing_messages - .get("dkim-private-key") - .filter(|v| !v.is_empty()) - .is_some() - { - ( - existing_messages - .get("dkim-private-key") - .cloned() - .unwrap_or_default(), - existing_messages - .get("dkim-public-key") - .cloned() - .unwrap_or_default(), - ) - } else { - gen_dkim_key_pair() - }; - - let dkim_priv_fn = { - let v = dkim_private.clone(); - move || v.clone() - }; - let dkim_pub_fn = { - let v = dkim_public.clone(); - move || v.clone() - }; - let socks_proxy_fn = || format!("sunbeam:{}", rand_token()); - let sunbeam_fn = || "sunbeam".to_string(); - - let messages = get_or_create( - &bao, - "messages", - &[ - ("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync)), - ("salt-key", &rand_token), - ("mda-api-secret", &rand_token), - ( - "oidc-refresh-token-key", - &gen_fernet_key as &(dyn Fn() -> String + Send + Sync), - ), - ("dkim-private-key", &dkim_priv_fn), - ("dkim-public-key", &dkim_pub_fn), - ("rspamd-password", &rand_token), - ("socks-proxy-users", &socks_proxy_fn), - ("mta-out-smtp-username", &sunbeam_fn), - ("mta-out-smtp-password", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let admin_fn = || "admin".to_string(); - let collabora = get_or_create( - &bao, - "collabora", - &[ - ("username", &admin_fn as &(dyn Fn() -> String + Send + Sync)), - ("password", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let tuwunel = get_or_create( - &bao, - "tuwunel", - &[ - ("oidc-client-id", &empty_fn as &(dyn Fn() -> String + Send + Sync)), - ("oidc-client-secret", &empty_fn), - ("turn-secret", &empty_fn), - ("registration-token", &rand_token), - ], - &mut dirty_paths, - ) - .await?; - - let grafana = get_or_create( - &bao, - "grafana", - &[("admin-password", &rand_token as &(dyn Fn() -> String + Send + Sync))], - &mut dirty_paths, - ) - .await?; - - let scw_access_fn = || scw_config("access-key"); - let scw_secret_fn = || scw_config("secret-key"); - let penpot = get_or_create( - &bao, - "penpot", - &[("secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))], - &mut dirty_paths, - ) - .await?; - - let scaleway_s3 = get_or_create( - &bao, - "scaleway-s3", - &[ - ("access-key-id", &scw_access_fn as &(dyn Fn() -> String + Send + Sync)), - ("secret-access-key", &scw_secret_fn), - ], - &mut dirty_paths, - ) - .await?; - - // ── Write dirty paths ─────────────────────────────────────────────── - if dirty_paths.is_empty() { - ok("All OpenBao KV secrets already present -- skipping writes."); - } else { - let mut sorted_paths: Vec<&String> = dirty_paths.iter().collect(); - sorted_paths.sort(); - ok(&format!( - "Writing new secrets to OpenBao KV ({})...", - sorted_paths - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - )); - - let all_paths: &[(&str, &HashMap)] = &[ - ("hydra", &hydra), - ("kratos", &kratos), - ("seaweedfs", &seaweedfs), - ("gitea", &gitea), - ("hive", &hive), - ("livekit", &livekit), - ("people", &people), - ("login-ui", &login_ui), - ("kratos-admin", &kratos_admin), - ("docs", &docs), - ("meet", &meet), - ("drive", &drive), - ("projects", &projects), - ("calendars", &calendars), - ("messages", &messages), - ("collabora", &collabora), - ("tuwunel", &tuwunel), - ("grafana", &grafana), - ("scaleway-s3", &scaleway_s3), - ("penpot", &penpot), - ]; - - for (path, data) in all_paths { - if dirty_paths.contains(*path) { - bao.kv_patch("secret", path, data).await?; - } - } - } - - // ── Kubernetes auth for VSO ───────────────────────────────────────── - ok("Configuring Kubernetes auth for VSO..."); - let _ = bao.auth_enable("kubernetes", "kubernetes").await; - - bao.write( - "auth/kubernetes/config", - &serde_json::json!({ - "kubernetes_host": "https://kubernetes.default.svc.cluster.local" - }), - ) - .await?; - - let policy_hcl = concat!( - "path \"secret/data/*\" { capabilities = [\"read\"] }\n", - "path \"secret/metadata/*\" { capabilities = [\"read\", \"list\"] }\n", - "path \"database/static-creds/*\" { capabilities = [\"read\"] }\n", - ); - bao.write_policy("vso-reader", policy_hcl).await?; - - bao.write( - "auth/kubernetes/role/vso", - &serde_json::json!({ - "bound_service_account_names": "default", - "bound_service_account_namespaces": "ory,devtools,storage,lasuite,stalwart,matrix,media,data,monitoring,cert-manager", - "policies": "vso-reader", - "ttl": "1h" - }), - ) - .await?; - - // Build credentials map - let mut creds = HashMap::new(); - let field_map: &[(&str, &str, &HashMap)] = &[ - ("hydra-system-secret", "system-secret", &hydra), - ("hydra-cookie-secret", "cookie-secret", &hydra), - ("hydra-pairwise-salt", "pairwise-salt", &hydra), - ("kratos-secrets-default", "secrets-default", &kratos), - ("kratos-secrets-cookie", "secrets-cookie", &kratos), - ("s3-access-key", "access-key", &seaweedfs), - ("s3-secret-key", "secret-key", &seaweedfs), - ("gitea-admin-password", "admin-password", &gitea), - ("hive-oidc-client-id", "oidc-client-id", &hive), - ("hive-oidc-client-secret", "oidc-client-secret", &hive), - ("people-django-secret", "django-secret-key", &people), - ("livekit-api-key", "api-key", &livekit), - ("livekit-api-secret", "api-secret", &livekit), - ( - "kratos-admin-cookie-secret", - "cookie-secret", - &kratos_admin, - ), - ("messages-dkim-public-key", "dkim-public-key", &messages), - ]; - - for (cred_key, field_key, source) in field_map { - creds.insert( - cred_key.to_string(), - source.get(*field_key).cloned().unwrap_or_default(), - ); - } - - Ok(Some(SeedResult { - creds, - ob_pod, - root_token, - })) -} - // ── Database secrets engine ───────────────────────────────────────────────── /// Enable OpenBao database secrets engine and create PostgreSQL static roles. @@ -857,7 +342,7 @@ pub(crate) async fn psql_exec(cnpg_pod: &str, sql: &str) -> Result<(i32, String) .await } -// ── Kratos admin identity seeding ─────────────────────────────────────────── +// ── Kratos types (used by WFE kratos-admin step) ─────────────────────────── #[derive(Debug, Deserialize)] pub(crate) struct KratosIdentity { @@ -872,596 +357,6 @@ pub(crate) struct KratosRecovery { pub(crate) recovery_code: String, } -/// Ensure estudio-admin@ exists in Kratos and is the only admin identity. -async fn seed_kratos_admin_identity(bao: &BaoClient) -> (String, String) { - let domain = match k::get_domain().await { - Ok(d) => d, - Err(e) => { - warn(&format!("Could not determine domain: {e}")); - return (String::new(), String::new()); - } - }; - let admin_email = format!("{ADMIN_USERNAME}@{domain}"); - ok(&format!( - "Ensuring Kratos admin identity ({admin_email})..." - )); - - let result: std::result::Result<(String, String), SunbeamError> = async { - let pf = match port_forward_svc("ory", "app.kubernetes.io/name=kratos-admin", 80).await { - Ok(pf) => pf, - Err(_) => port_forward_svc("ory", "app.kubernetes.io/name=kratos", 4434) - .await - .ctx("Could not port-forward to Kratos admin API")?, - }; - let base = format!("http://127.0.0.1:{}", pf.local_port); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - let http = reqwest::Client::new(); - - let resp = http - .get(format!( - "{base}/admin/identities?credentials_identifier={admin_email}&page_size=1" - )) - .header("Accept", "application/json") - .send() - .await?; - - let identities: Vec = resp.json().await.unwrap_or_default(); - let identity_id = if let Some(existing) = identities.first() { - ok(&format!( - " admin identity exists ({}...)", - &existing.id[..8.min(existing.id.len())] - )); - existing.id.clone() - } else { - let resp = http - .post(format!("{base}/admin/identities")) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .json(&serde_json::json!({ - "schema_id": "employee", - "traits": {"email": admin_email}, - "state": "active", - })) - .send() - .await?; - - let identity: KratosIdentity = - resp.json().await.map_err(|e| SunbeamError::Other(e.to_string()))?; - ok(&format!( - " created admin identity ({}...)", - &identity.id[..8.min(identity.id.len())] - )); - identity.id - }; - - let resp = http - .post(format!("{base}/admin/recovery/code")) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .json(&serde_json::json!({ - "identity_id": identity_id, - "expires_in": "24h", - })) - .send() - .await?; - - let recovery: KratosRecovery = resp.json().await.unwrap_or(KratosRecovery { - recovery_link: String::new(), - recovery_code: String::new(), - }); - - let mut patch_data = HashMap::new(); - patch_data.insert("admin-identity-ids".to_string(), admin_email.clone()); - let _ = bao.kv_patch("secret", "kratos-admin", &patch_data).await; - ok(&format!(" ADMIN_IDENTITY_IDS set to {admin_email}")); - - Ok((recovery.recovery_link, recovery.recovery_code)) - } - .await; - - match result { - Ok(r) => r, - Err(e) => { - warn(&format!( - "Could not seed Kratos admin identity (Kratos may not be ready): {e}" - )); - (String::new(), String::new()) - } - } -} - -// ── cmd_seed — main entry point ───────────────────────────────────────────── - -pub async fn cmd_seed() -> Result<()> { - step("Seeding secrets..."); - - let seed_result = seed_openbao().await?; - let (creds, ob_pod, root_token) = match seed_result { - Some(r) => (r.creds, r.ob_pod, r.root_token), - None => (HashMap::new(), String::new(), String::new()), - }; - - let s3_access_key = creds.get("s3-access-key").cloned().unwrap_or_default(); - let s3_secret_key = creds.get("s3-secret-key").cloned().unwrap_or_default(); - let hydra_system = creds - .get("hydra-system-secret") - .cloned() - .unwrap_or_default(); - let hydra_cookie = creds - .get("hydra-cookie-secret") - .cloned() - .unwrap_or_default(); - let hydra_pairwise = creds - .get("hydra-pairwise-salt") - .cloned() - .unwrap_or_default(); - let kratos_secrets_default = creds - .get("kratos-secrets-default") - .cloned() - .unwrap_or_default(); - let kratos_secrets_cookie = creds - .get("kratos-secrets-cookie") - .cloned() - .unwrap_or_default(); - let hive_oidc_id = creds - .get("hive-oidc-client-id") - .cloned() - .unwrap_or_else(|| "hive-local".into()); - let hive_oidc_sec = creds - .get("hive-oidc-client-secret") - .cloned() - .unwrap_or_default(); - let django_secret = creds - .get("people-django-secret") - .cloned() - .unwrap_or_default(); - let gitea_admin_pass = creds - .get("gitea-admin-password") - .cloned() - .unwrap_or_default(); - - // ── Wait for Postgres ─────────────────────────────────────────────── - ok("Waiting for postgres cluster..."); - let mut pg_pod = String::new(); - - let client = k::get_client().await?; - let ar = ApiResource { - group: "postgresql.cnpg.io".into(), - version: "v1".into(), - api_version: "postgresql.cnpg.io/v1".into(), - kind: "Cluster".into(), - plural: "clusters".into(), - }; - let cnpg_api: Api = Api::namespaced_with(client.clone(), "data", &ar); - for _ in 0..60 { - if let Ok(cluster) = cnpg_api.get("postgres").await { - let phase = cluster - .data - .get("status") - .and_then(|s| s.get("phase")) - .and_then(|p| p.as_str()) - .unwrap_or(""); - if phase == "Cluster in healthy state" { - // Cluster is healthy — find the primary pod name - let pods: Api = Api::namespaced(client.clone(), "data"); - let lp = ListParams::default().labels("cnpg.io/cluster=postgres,role=primary"); - if let Ok(pod_list) = pods.list(&lp).await { - if let Some(name) = pod_list - .items - .first() - .and_then(|p| p.metadata.name.as_deref()) - { - pg_pod = name.to_string(); - ok(&format!("Postgres ready ({pg_pod}).")); - break; - } - } - } - } - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - } - - if pg_pod.is_empty() { - warn("Postgres not ready after 5 min -- continuing anyway."); - } - - if !pg_pod.is_empty() { - ok("Ensuring postgres roles and databases exist..."); - let db_map: HashMap<&str, &str> = [ - ("kratos", "kratos_db"), - ("hydra", "hydra_db"), - ("gitea", "gitea_db"), - ("hive", "hive_db"), - ("docs", "docs_db"), - ("meet", "meet_db"), - ("drive", "drive_db"), - ("messages", "messages_db"), - ("conversations", "conversations_db"), - ("people", "people_db"), - ("find", "find_db"), - ("calendars", "calendars_db"), - ("projects", "projects_db"), - ] - .into_iter() - .collect(); - - for user in PG_USERS { - let ensure_sql = format!( - "DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='{user}') \ - THEN EXECUTE 'CREATE USER {user}'; END IF; END $$;" - ); - let _ = k::kube_exec( - "data", - &pg_pod, - &["psql", "-U", "postgres", "-c", &ensure_sql], - Some("postgres"), - ) - .await; - - let db = db_map.get(user).copied().unwrap_or("unknown_db"); - let create_db_sql = format!("CREATE DATABASE {db} OWNER {user};"); - let _ = k::kube_exec( - "data", - &pg_pod, - &["psql", "-U", "postgres", "-c", &create_db_sql], - Some("postgres"), - ) - .await; - } - - // Configure database secrets engine via port-forward - if !ob_pod.is_empty() && !root_token.is_empty() { - match port_forward("data", &ob_pod, 8200).await { - Ok(pf) => { - let bao_url = format!("http://127.0.0.1:{}", pf.local_port); - let bao = BaoClient::with_token(&bao_url, &root_token); - if let Err(e) = configure_db_engine(&bao).await { - warn(&format!("DB engine config failed: {e}")); - } - } - Err(e) => warn(&format!("Port-forward to OpenBao failed: {e}")), - } - } else { - warn("Skipping DB engine config -- missing ob_pod or root_token."); - } - } - - // ── Create K8s secrets ────────────────────────────────────────────── - ok("Creating K8s secrets (VSO will overwrite on next sync)..."); - - k::ensure_ns("ory").await?; - k::create_secret( - "ory", - "hydra", - HashMap::from([ - ("secretsSystem".into(), hydra_system), - ("secretsCookie".into(), hydra_cookie), - ("pairwise-salt".into(), hydra_pairwise), - ]), - ) - .await?; - k::create_secret( - "ory", - "kratos-app-secrets", - HashMap::from([ - ("secretsDefault".into(), kratos_secrets_default), - ("secretsCookie".into(), kratos_secrets_cookie), - ]), - ) - .await?; - - k::ensure_ns("devtools").await?; - k::create_secret( - "devtools", - "gitea-s3-credentials", - HashMap::from([ - ("access-key".into(), s3_access_key.clone()), - ("secret-key".into(), s3_secret_key.clone()), - ]), - ) - .await?; - k::create_secret( - "devtools", - "gitea-admin-credentials", - HashMap::from([ - ("username".into(), GITEA_ADMIN_USER.into()), - ("password".into(), gitea_admin_pass.clone()), - ]), - ) - .await?; - - // Sync Gitea admin password to Gitea's own DB - if !gitea_admin_pass.is_empty() { - let gitea_pods: Api = Api::namespaced(client.clone(), "devtools"); - let lp = ListParams::default().labels("app.kubernetes.io/name=gitea"); - if let Ok(pod_list) = gitea_pods.list(&lp).await { - if let Some(gitea_pod) = pod_list - .items - .first() - .and_then(|p| p.metadata.name.as_deref()) - { - match k::kube_exec( - "devtools", - gitea_pod, - &[ - "gitea", - "admin", - "user", - "change-password", - "--username", - GITEA_ADMIN_USER, - "--password", - &gitea_admin_pass, - "--must-change-password=false", - ], - Some("gitea"), - ) - .await - { - Ok((0, _)) => ok("Gitea admin password synced to Gitea DB."), - Ok((_, stderr)) => { - warn(&format!("Could not sync Gitea admin password: {stderr}")) - } - Err(e) => warn(&format!("Could not sync Gitea admin password: {e}")), - } - } else { - warn("Gitea pod not found -- admin password NOT synced to Gitea DB. Run seed again after Gitea is deployed."); - } - } - } - - k::ensure_ns("storage").await?; - let s3_json = format!( - r#"{{"identities":[{{"name":"seaweed","credentials":[{{"accessKey":"{}","secretKey":"{}"}}],"actions":["Admin","Read","Write","List","Tagging"]}}]}}"#, - s3_access_key, s3_secret_key - ); - k::create_secret( - "storage", - "seaweedfs-s3-credentials", - HashMap::from([ - ("S3_ACCESS_KEY".into(), s3_access_key.clone()), - ("S3_SECRET_KEY".into(), s3_secret_key.clone()), - ]), - ) - .await?; - k::create_secret( - "storage", - "seaweedfs-s3-json", - HashMap::from([("s3.json".into(), s3_json)]), - ) - .await?; - - k::ensure_ns("lasuite").await?; - k::create_secret( - "lasuite", - "seaweedfs-s3-credentials", - HashMap::from([ - ("S3_ACCESS_KEY".into(), s3_access_key), - ("S3_SECRET_KEY".into(), s3_secret_key), - ]), - ) - .await?; - k::create_secret( - "lasuite", - "hive-oidc", - HashMap::from([ - ("client-id".into(), hive_oidc_id), - ("client-secret".into(), hive_oidc_sec), - ]), - ) - .await?; - k::create_secret( - "lasuite", - "people-django-secret", - HashMap::from([("DJANGO_SECRET_KEY".into(), django_secret)]), - ) - .await?; - - k::ensure_ns("matrix").await?; - k::ensure_ns("media").await?; - k::ensure_ns("monitoring").await?; - - // ── Kratos admin identity ─────────────────────────────────────────── - if !ob_pod.is_empty() && !root_token.is_empty() { - if let Ok(pf) = port_forward("data", &ob_pod, 8200).await { - let bao_url = format!("http://127.0.0.1:{}", pf.local_port); - let bao = BaoClient::with_token(&bao_url, &root_token); - let (recovery_link, recovery_code) = seed_kratos_admin_identity(&bao).await; - if !recovery_link.is_empty() { - ok("Admin recovery link (valid 24h):"); - println!(" {recovery_link}"); - } - if !recovery_code.is_empty() { - ok("Admin recovery code (enter on the page above):"); - println!(" {recovery_code}"); - } - } - } - - let dkim_pub = creds - .get("messages-dkim-public-key") - .cloned() - .unwrap_or_default(); - if !dkim_pub.is_empty() { - let b64_key: String = dkim_pub - .replace("-----BEGIN PUBLIC KEY-----", "") - .replace("-----END PUBLIC KEY-----", "") - .replace("-----BEGIN RSA PUBLIC KEY-----", "") - .replace("-----END RSA PUBLIC KEY-----", "") - .split_whitespace() - .collect(); - - if let Ok(domain) = k::get_domain().await { - ok("DKIM DNS record (add to DNS at your registrar):"); - println!( - " default._domainkey.{domain} TXT \"v=DKIM1; k=rsa; p={b64_key}\"" - ); - } - } - - ok("All secrets seeded."); - Ok(()) -} - -// ── cmd_verify — VSO E2E verification ─────────────────────────────────────── - -/// End-to-end test of VSO -> OpenBao integration. -pub async fn cmd_verify() -> Result<()> { - step("Verifying VSO -> OpenBao integration (E2E)..."); - - let client = k::get_client().await?; - let pods: Api = Api::namespaced(client.clone(), "data"); - let lp = ListParams::default().labels("app.kubernetes.io/name=openbao,component=server"); - let pod_list = pods.list(&lp).await?; - - let ob_pod = pod_list - .items - .first() - .and_then(|p| p.metadata.name.as_deref()) - .ctx("OpenBao pod not found -- run full bring-up first.")? - .to_string(); - - let root_token = k::kube_get_secret_field("data", "openbao-keys", "root-token") - .await - .ctx("Could not read openbao-keys secret.")?; - - let pf = port_forward("data", &ob_pod, 8200).await?; - let bao_url = format!("http://127.0.0.1:{}", pf.local_port); - let bao = BaoClient::with_token(&bao_url, &root_token); - - let test_value = rand_token_n(16); - let test_ns = "ory"; - let test_name = "vso-verify"; - - let result: std::result::Result<(), SunbeamError> = async { - ok("Writing test sentinel to OpenBao secret/vso-test ..."); - let mut data = HashMap::new(); - data.insert("test-key".to_string(), test_value.clone()); - bao.kv_put("secret", "vso-test", &data).await?; - - ok(&format!("Creating VaultAuth {test_ns}/{test_name} ...")); - k::kube_apply(&format!( - r#" -apiVersion: secrets.hashicorp.com/v1beta1 -kind: VaultAuth -metadata: - name: {test_name} - namespace: {test_ns} -spec: - method: kubernetes - mount: kubernetes - kubernetes: - role: vso - serviceAccount: default -"# - )) - .await?; - - ok(&format!( - "Creating VaultStaticSecret {test_ns}/{test_name} ..." - )); - k::kube_apply(&format!( - r#" -apiVersion: secrets.hashicorp.com/v1beta1 -kind: VaultStaticSecret -metadata: - name: {test_name} - namespace: {test_ns} -spec: - vaultAuthRef: {test_name} - mount: secret - type: kv-v2 - path: vso-test - refreshAfter: 10s - destination: - name: {test_name} - create: true - overwrite: true -"# - )) - .await?; - - ok("Waiting for VSO to sync (up to 60s) ..."); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(60); - let mut synced = false; - - while tokio::time::Instant::now() < deadline { - let (code, mac) = kubectl_jsonpath( - test_ns, - "vaultstaticsecret", - test_name, - "{.status.secretMAC}", - ) - .await; - if code == 0 && !mac.is_empty() && mac != "" { - synced = true; - break; - } - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - } - - if !synced { - let (_, msg) = kubectl_jsonpath( - test_ns, - "vaultstaticsecret", - test_name, - "{.status.conditions[0].message}", - ) - .await; - return Err(SunbeamError::secrets(format!( - "VSO did not sync within 60s. Last status: {}", - if msg.is_empty() { - "unknown".to_string() - } else { - msg - } - ))); - } - - ok("Verifying K8s Secret contents ..."); - let secret = k::kube_get_secret(test_ns, test_name) - .await? - .with_ctx(|| format!("K8s Secret {test_ns}/{test_name} not found."))?; - - let data = secret.data.as_ref().ctx("Secret has no data")?; - let raw = data - .get("test-key") - .ctx("Missing key 'test-key' in secret")?; - let actual = String::from_utf8(raw.0.clone()) - .map_err(|e| SunbeamError::Other(format!("UTF-8 error: {e}")))?; - - if actual != test_value { - return Err(SunbeamError::secrets(format!( - "Value mismatch!\n expected: {:?}\n got: {:?}", - test_value, - actual - ))); - } - - ok("Sentinel value matches -- VSO -> OpenBao integration is working."); - Ok(()) - } - .await; - - // Always clean up - ok("Cleaning up test resources..."); - let _ = delete_crd(test_ns, "vaultstaticsecret", test_name).await; - let _ = delete_crd(test_ns, "vaultauth", test_name).await; - let _ = delete_k8s_secret(test_ns, test_name).await; - let _ = bao.kv_delete("secret", "vso-test").await; - - match result { - Ok(()) => { - ok("VSO E2E verification passed."); - Ok(()) - } - Err(e) => Err(SunbeamError::secrets(format!( - "VSO verification FAILED: {e}" - ))), - } -} - // ── Utility helpers ───────────────────────────────────────────────────────── pub(crate) async fn wait_pod_running(ns: &str, pod_name: &str, timeout_secs: u64) -> bool { @@ -1499,24 +394,6 @@ pub(crate) fn scw_config(key: &str) -> String { .unwrap_or_default() } -async fn delete_crd(ns: &str, kind: &str, name: &str) -> Result<()> { - let ctx = format!("--context={}", k::context()); - let _ = tokio::process::Command::new("kubectl") - .args([&ctx, "-n", ns, "delete", kind, name, "--ignore-not-found"]) - .output() - .await; - Ok(()) -} - -async fn delete_k8s_secret(ns: &str, name: &str) -> Result<()> { - let client = k::get_client().await?; - let api: Api = Api::namespaced(client.clone(), ns); - let _ = api - .delete(name, &kube::api::DeleteParams::default()) - .await; - Ok(()) -} - pub(crate) async fn delete_resource(ns: &str, kind: &str, name: &str) -> Result<()> { let ctx = format!("--context={}", k::context()); let _ = tokio::process::Command::new("kubectl") @@ -1526,23 +403,6 @@ pub(crate) async fn delete_resource(ns: &str, kind: &str, name: &str) -> Result< Ok(()) } -async fn kubectl_jsonpath(ns: &str, kind: &str, name: &str, jsonpath: &str) -> (i32, String) { - let ctx = format!("--context={}", k::context()); - let jp = format!("-o=jsonpath={jsonpath}"); - match tokio::process::Command::new("kubectl") - .args([&ctx, "-n", ns, "get", kind, name, &jp, "--ignore-not-found"]) - .output() - .await - { - Ok(output) => { - let code = output.status.code().unwrap_or(1); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - (code, stdout) - } - Err(_) => (1, String::new()), - } -} - // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -1619,23 +479,6 @@ mod tests { let _ = result; } - #[test] - fn test_seed_result_structure() { - let mut creds = HashMap::new(); - creds.insert( - "hydra-system-secret".to_string(), - "existingvalue".to_string(), - ); - let result = SeedResult { - creds, - ob_pod: "openbao-0".to_string(), - root_token: "token123".to_string(), - }; - assert!(result.creds.contains_key("hydra-system-secret")); - assert_eq!(result.creds["hydra-system-secret"], "existingvalue"); - assert_eq!(result.ob_pod, "openbao-0"); - } - #[test] fn test_dkim_public_key_extraction() { let pem = "-----BEGIN PUBLIC KEY-----\nMIIBCgKCAQ...\nbase64data\n-----END PUBLIC KEY-----"; diff --git a/src/workflows/up/steps/certificates.rs b/src/workflows/up/steps/certificates.rs index da4d6325..d5b1889d 100644 --- a/src/workflows/up/steps/certificates.rs +++ b/src/workflows/up/steps/certificates.rs @@ -187,7 +187,6 @@ fn resolve_domain(data: &UpData) -> wfe_core::Result { #[cfg(test)] mod tests { use super::*; - use crate::cluster::CERT_MANAGER_URL; #[test] fn secrets_dir_ends_with_secrets_local() { @@ -199,21 +198,6 @@ mod tests { ); } - #[test] - fn cert_manager_url_points_to_github_release() { - assert!(CERT_MANAGER_URL.starts_with("https://github.com/cert-manager/cert-manager/")); - assert!(CERT_MANAGER_URL.contains("/releases/download/")); - assert!(CERT_MANAGER_URL.ends_with(".yaml")); - } - - #[test] - fn cert_manager_url_has_version() { - assert!( - CERT_MANAGER_URL.contains("/v1."), - "CERT_MANAGER_URL should reference a v1.x release" - ); - } - #[test] fn ensure_tls_cert_is_default() { let _ = EnsureTLSCert::default(); @@ -223,5 +207,4 @@ mod tests { fn ensure_tls_secret_is_default() { let _ = EnsureTLSSecret::default(); } - }