feat(auth): add auth token command, penpot seed support

- `sunbeam auth token` prints JSON headers for MCP headersHelper:
  {"Authorization": "Bearer <token>"}
- Add penpot to PG_USERS, pg_db_map, KV seed, and all_paths
- Add cert-manager to VSO auth role bound namespaces
This commit is contained in:
2026-04-04 12:53:53 +01:00
parent c6bd8be030
commit dce085cd0c
5 changed files with 1232 additions and 128 deletions

View File

@@ -32,23 +32,43 @@ const DEFAULT_CLIENT_ID: &str = "sunbeam-cli";
// Cache file helpers // Cache file helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Cache path for auth tokens — per-domain so multiple environments work. /// Legacy auth cache dir — used only for migration.
fn cache_path_for_domain(domain: &str) -> PathBuf { fn legacy_auth_dir() -> PathBuf {
let dir = dirs::data_dir() dirs::data_dir()
.unwrap_or_else(|| { .unwrap_or_else(|| {
dirs::home_dir() dirs::home_dir()
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
.join(".local/share") .join(".local/share")
}) })
.join("sunbeam") .join("sunbeam")
.join("auth"); .join("auth")
if domain.is_empty() { }
dir.join("default.json")
/// Cache path for auth tokens — per-domain so multiple environments work.
/// Files live under ~/.sunbeam/auth/{safe_domain}.json.
fn cache_path_for_domain(domain: &str) -> PathBuf {
let dir = crate::config::sunbeam_dir().join("auth");
let filename = if domain.is_empty() {
"default.json".to_string()
} else { } else {
// Sanitize domain for filename
let safe = domain.replace(['/', '\\', ':'], "_"); let safe = domain.replace(['/', '\\', ':'], "_");
dir.join(format!("{safe}.json")) format!("{safe}.json")
};
let new_path = dir.join(&filename);
// Migration: copy from legacy location if new path doesn't exist yet
if !new_path.exists() {
let legacy = legacy_auth_dir().join(&filename);
if legacy.exists() {
if let Some(parent) = new_path.parent() {
let _ = std::fs::create_dir_all(parent);
} }
let _ = std::fs::copy(&legacy, &new_path);
}
}
new_path
} }
fn cache_path() -> PathBuf { fn cache_path() -> PathBuf {
@@ -489,6 +509,15 @@ pub async fn get_token() -> Result<String> {
)) ))
} }
/// Print the current access token as a JSON headers object.
/// Designed for use as a Claude Code MCP `headersHelper`.
/// Output: {"Authorization": "Bearer <token>"}
pub async fn cmd_auth_token() -> Result<()> {
let token = get_token().await?;
println!("{{\"Authorization\": \"Bearer {token}\"}}");
Ok(())
}
/// Interactive browser-based OAuth2 login. /// Interactive browser-based OAuth2 login.
/// SSO login — Hydra OIDC authorization code flow with PKCE. /// SSO login — Hydra OIDC authorization code flow with PKCE.
/// `gitea_redirect`: if Some, the browser callback page auto-redirects to Gitea token page. /// `gitea_redirect`: if Some, the browser callback page auto-redirects to Gitea token page.
@@ -939,8 +968,7 @@ mod tests {
fn test_cache_path_is_under_sunbeam() { fn test_cache_path_is_under_sunbeam() {
let path = cache_path_for_domain("sunbeam.pt"); let path = cache_path_for_domain("sunbeam.pt");
let path_str = path.to_string_lossy(); let path_str = path.to_string_lossy();
assert!(path_str.contains("sunbeam")); assert!(path_str.contains(".sunbeam/auth"));
assert!(path_str.contains("auth"));
assert!(path_str.ends_with("sunbeam.pt.json")); assert!(path_str.ends_with("sunbeam.pt.json"));
} }

View File

@@ -143,6 +143,12 @@ pub enum Verb {
action: Option<PmAction>, action: Option<PmAction>,
}, },
/// Workflow management (list, status, retry, cancel, run).
Workflow {
#[command(subcommand)]
action: crate::workflows::cmd::WorkflowAction,
},
/// Self-update from latest mainline commit. /// Self-update from latest mainline commit.
Update, Update,
@@ -174,6 +180,8 @@ pub enum AuthAction {
Logout, Logout,
/// Show current authentication status. /// Show current authentication status.
Status, Status,
/// Print the current access token (for use in scripts and MCP headers).
Token,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
@@ -750,6 +758,109 @@ mod tests {
]); ]);
assert!(result.is_err()); assert!(result.is_err());
} }
// -- Workflow subcommand tests --
#[test]
fn test_workflow_list() {
let cli = parse(&["sunbeam", "workflow", "list"]);
match cli.verb {
Some(Verb::Workflow { action }) => {
assert!(matches!(action, crate::workflows::cmd::WorkflowAction::List { .. }));
}
_ => panic!("expected Workflow List"),
}
}
#[test]
fn test_workflow_list_with_status_filter() {
let cli = parse(&["sunbeam", "workflow", "list", "--status", "complete"]);
match cli.verb {
Some(Verb::Workflow { action }) => match action {
crate::workflows::cmd::WorkflowAction::List { status } => {
assert_eq!(status, "complete");
}
_ => panic!("expected List"),
},
_ => panic!("expected Workflow"),
}
}
#[test]
fn test_workflow_status() {
let cli = parse(&["sunbeam", "workflow", "status", "abc-123"]);
match cli.verb {
Some(Verb::Workflow { action }) => match action {
crate::workflows::cmd::WorkflowAction::Status { id } => {
assert_eq!(id, "abc-123");
}
_ => panic!("expected Status"),
},
_ => panic!("expected Workflow"),
}
}
#[test]
fn test_workflow_retry() {
let cli = parse(&["sunbeam", "workflow", "retry", "wf-456"]);
match cli.verb {
Some(Verb::Workflow { action }) => match action {
crate::workflows::cmd::WorkflowAction::Retry { id } => {
assert_eq!(id, "wf-456");
}
_ => panic!("expected Retry"),
},
_ => panic!("expected Workflow"),
}
}
#[test]
fn test_workflow_cancel() {
let cli = parse(&["sunbeam", "workflow", "cancel", "wf-789"]);
match cli.verb {
Some(Verb::Workflow { action }) => match action {
crate::workflows::cmd::WorkflowAction::Cancel { id } => {
assert_eq!(id, "wf-789");
}
_ => panic!("expected Cancel"),
},
_ => panic!("expected Workflow"),
}
}
#[test]
fn test_workflow_run_default_file() {
let cli = parse(&["sunbeam", "workflow", "run"]);
match cli.verb {
Some(Verb::Workflow { action }) => match action {
crate::workflows::cmd::WorkflowAction::Run { file } => {
assert_eq!(file, "");
}
_ => panic!("expected Run"),
},
_ => panic!("expected Workflow"),
}
}
#[test]
fn test_workflow_run_with_file() {
let cli = parse(&["sunbeam", "workflow", "run", "deploy.yaml"]);
match cli.verb {
Some(Verb::Workflow { action }) => match action {
crate::workflows::cmd::WorkflowAction::Run { file } => {
assert_eq!(file, "deploy.yaml");
}
_ => panic!("expected Run"),
},
_ => panic!("expected Workflow"),
}
}
#[test]
fn test_workflow_status_missing_id() {
let result = Cli::try_parse_from(&["sunbeam", "workflow", "status"]);
assert!(result.is_err());
}
} }
/// Main dispatch function — parse CLI args and route to subcommands. /// Main dispatch function — parse CLI args and route to subcommands.
@@ -786,7 +897,49 @@ pub async fn dispatch() -> Result<()> {
Ok(()) Ok(())
} }
Some(Verb::Up) => crate::cluster::cmd_up().await, Some(Verb::Up) => {
crate::output::step("Bringing up cluster (workflow engine)...");
let ctx_name = {
let cfg = crate::config::load_config();
if cfg.current_context.is_empty() {
"default".to_string()
} else {
cfg.current_context.clone()
}
};
let host = crate::workflows::host::create_host(&ctx_name).await?;
crate::workflows::up::register(&host).await;
let step_ctx = crate::workflows::StepContext::from_active();
let initial_data = serde_json::json!({
"__ctx": step_ctx,
"domain": "",
});
let instance = wfe::run_workflow_sync(
&host,
"up",
1,
initial_data,
std::time::Duration::from_secs(3600),
)
.await
.map_err(|e| SunbeamError::Other(format!("up workflow failed: {e}")))?;
crate::workflows::up::print_summary(&instance);
crate::workflows::host::shutdown_host(host).await;
if instance.status != wfe_core::models::WorkflowStatus::Complete {
return Err(SunbeamError::Other(format!(
"up workflow ended with status {:?}",
instance.status
)));
}
Ok(())
}
Some(Verb::Status { target }) => { Some(Verb::Status { target }) => {
crate::services::cmd_status(target.as_deref()).await crate::services::cmd_status(target.as_deref()).await
@@ -829,9 +982,91 @@ pub async fn dispatch() -> Result<()> {
crate::manifests::cmd_apply(&env_str, &domain, &email, &ns).await crate::manifests::cmd_apply(&env_str, &domain, &email, &ns).await
} }
Some(Verb::Seed) => crate::secrets::cmd_seed().await, Some(Verb::Seed) => {
crate::output::step("Seeding secrets (workflow engine)...");
Some(Verb::Verify) => crate::secrets::cmd_verify().await, let ctx_name = {
let cfg = crate::config::load_config();
if cfg.current_context.is_empty() {
"default".to_string()
} else {
cfg.current_context.clone()
}
};
let host = crate::workflows::host::create_host(&ctx_name).await?;
crate::workflows::seed::register(&host).await;
let step_ctx = crate::workflows::StepContext::from_active();
let initial_data = serde_json::json!({
"__ctx": step_ctx,
});
let instance = wfe::run_workflow_sync(
&host,
"seed",
1,
initial_data,
std::time::Duration::from_secs(900),
)
.await
.map_err(|e| SunbeamError::secrets(format!("seed workflow failed: {e}")))?;
crate::workflows::seed::print_summary(&instance);
crate::workflows::host::shutdown_host(host).await;
if instance.status != wfe_core::models::WorkflowStatus::Complete {
return Err(SunbeamError::secrets(format!(
"seed workflow ended with status {:?}",
instance.status
)));
}
Ok(())
}
Some(Verb::Verify) => {
crate::output::step("Verifying VSO -> OpenBao integration (workflow engine)...");
let ctx_name = {
let cfg = crate::config::load_config();
if cfg.current_context.is_empty() {
"default".to_string()
} else {
cfg.current_context.clone()
}
};
let host = crate::workflows::host::create_host(&ctx_name).await?;
crate::workflows::verify::register(&host).await;
let step_ctx = crate::workflows::StepContext::from_active();
let initial_data = serde_json::json!({
"__ctx": step_ctx,
});
let instance = wfe::run_workflow_sync(
&host,
"verify",
1,
initial_data,
std::time::Duration::from_secs(300),
)
.await
.map_err(|e| SunbeamError::Other(format!("verify workflow failed: {e}")))?;
crate::workflows::verify::print_summary(&instance);
crate::workflows::host::shutdown_host(host).await;
if instance.status != wfe_core::models::WorkflowStatus::Complete {
return Err(SunbeamError::Other(format!(
"verify workflow ended with status {:?}",
instance.status
)));
}
Ok(())
}
Some(Verb::Logs { target, follow }) => { Some(Verb::Logs { target, follow }) => {
crate::services::cmd_logs(&target, follow).await crate::services::cmd_logs(&target, follow).await
@@ -856,7 +1091,48 @@ pub async fn dispatch() -> Result<()> {
Some(Verb::Mirror) => crate::images::cmd_mirror().await, Some(Verb::Mirror) => crate::images::cmd_mirror().await,
Some(Verb::Bootstrap) => crate::gitea::cmd_bootstrap().await, Some(Verb::Bootstrap) => {
crate::output::step("Bootstrapping Gitea (workflow engine)...");
let ctx_name = {
let cfg = crate::config::load_config();
if cfg.current_context.is_empty() {
"default".to_string()
} else {
cfg.current_context.clone()
}
};
let host = crate::workflows::host::create_host(&ctx_name).await?;
crate::workflows::bootstrap::register(&host).await;
let step_ctx = crate::workflows::StepContext::from_active();
let initial_data = serde_json::json!({
"__ctx": step_ctx,
});
let instance = wfe::run_workflow_sync(
&host,
"bootstrap",
1,
initial_data,
std::time::Duration::from_secs(300),
)
.await
.map_err(|e| SunbeamError::Other(format!("bootstrap workflow failed: {e}")))?;
crate::workflows::bootstrap::print_summary(&instance);
crate::workflows::host::shutdown_host(host).await;
if instance.status != wfe_core::models::WorkflowStatus::Complete {
return Err(SunbeamError::Other(format!(
"bootstrap workflow ended with status {:?}",
instance.status
)));
}
Ok(())
}
Some(Verb::Config { action }) => match action { Some(Verb::Config { action }) => match action {
None => { None => {
@@ -1053,6 +1329,7 @@ pub async fn dispatch() -> Result<()> {
} }
Some(AuthAction::Logout) => crate::auth::cmd_auth_logout().await, Some(AuthAction::Logout) => crate::auth::cmd_auth_logout().await,
Some(AuthAction::Status) => crate::auth::cmd_auth_status().await, Some(AuthAction::Status) => crate::auth::cmd_auth_status().await,
Some(AuthAction::Token) => crate::auth::cmd_auth_token().await,
}, },
Some(Verb::Pm { action }) => match action { Some(Verb::Pm { action }) => match action {
@@ -1087,6 +1364,18 @@ pub async fn dispatch() -> Result<()> {
} }
}, },
Some(Verb::Workflow { action }) => {
let ctx_name = {
let cfg = crate::config::load_config();
if cfg.current_context.is_empty() {
"default".to_string()
} else {
cfg.current_context.clone()
}
};
crate::workflows::cmd::dispatch(&ctx_name, action).await
}
Some(Verb::Update) => crate::update::cmd_update().await, Some(Verb::Update) => crate::update::cmd_update().await,
Some(Verb::Version) => { Some(Verb::Version) => {

View File

@@ -20,9 +20,9 @@ use crate::output::{ok, step, warn};
// ── Constants ─────────────────────────────────────────────────────────────── // ── Constants ───────────────────────────────────────────────────────────────
const ADMIN_USERNAME: &str = "estudio-admin"; pub(crate) const ADMIN_USERNAME: &str = "estudio-admin";
const GITEA_ADMIN_USER: &str = "gitea_admin"; pub(crate) const GITEA_ADMIN_USER: &str = "gitea_admin";
const PG_USERS: &[&str] = &[ pub(crate) const PG_USERS: &[&str] = &[
"kratos", "kratos",
"hydra", "hydra",
"gitea", "gitea",
@@ -36,14 +36,15 @@ const PG_USERS: &[&str] = &[
"find", "find",
"calendars", "calendars",
"projects", "projects",
"penpot",
]; ];
const SMTP_URI: &str = "smtp://postfix.lasuite.svc.cluster.local:25/?skip_ssl_verify=true"; pub(crate) const SMTP_URI: &str = "smtp://postfix.lasuite.svc.cluster.local:25/?skip_ssl_verify=true";
// ── Key generation ────────────────────────────────────────────────────────── // ── Key generation ──────────────────────────────────────────────────────────
/// Generate a Fernet-compatible key (32 random bytes, URL-safe base64). /// Generate a Fernet-compatible key (32 random bytes, URL-safe base64).
fn gen_fernet_key() -> String { pub(crate) fn gen_fernet_key() -> String {
use base64::Engine; use base64::Engine;
let mut buf = [0u8; 32]; let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf); rand::thread_rng().fill_bytes(&mut buf);
@@ -52,7 +53,7 @@ fn gen_fernet_key() -> String {
/// Generate an RSA 2048-bit DKIM key pair. /// Generate an RSA 2048-bit DKIM key pair.
/// Returns (private_pem_pkcs8, public_pem). Returns ("", "") on failure. /// Returns (private_pem_pkcs8, public_pem). Returns ("", "") on failure.
fn gen_dkim_key_pair() -> (String, String) { pub(crate) fn gen_dkim_key_pair() -> (String, String) {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let bits = 2048; let bits = 2048;
let private_key = match RsaPrivateKey::new(&mut rng, bits) { let private_key = match RsaPrivateKey::new(&mut rng, bits) {
@@ -84,7 +85,7 @@ fn gen_dkim_key_pair() -> (String, String) {
} }
/// Generate a URL-safe random token (32 bytes). /// Generate a URL-safe random token (32 bytes).
fn rand_token() -> String { pub(crate) fn rand_token() -> String {
use base64::Engine; use base64::Engine;
let mut buf = [0u8; 32]; let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf); rand::thread_rng().fill_bytes(&mut buf);
@@ -92,7 +93,7 @@ fn rand_token() -> String {
} }
/// Generate a URL-safe random token with a specific byte count. /// Generate a URL-safe random token with a specific byte count.
fn rand_token_n(n: usize) -> String { pub(crate) fn rand_token_n(n: usize) -> String {
use base64::Engine; use base64::Engine;
let mut buf = vec![0u8; n]; let mut buf = vec![0u8; n];
rand::thread_rng().fill_bytes(&mut buf); rand::thread_rng().fill_bytes(&mut buf);
@@ -102,7 +103,7 @@ fn rand_token_n(n: usize) -> String {
// ── Port-forward helper ───────────────────────────────────────────────────── // ── Port-forward helper ─────────────────────────────────────────────────────
/// Port-forward guard — cancels the background forwarder on drop. /// Port-forward guard — cancels the background forwarder on drop.
struct PortForwardGuard { pub(crate) struct PortForwardGuard {
_abort_handle: tokio::task::AbortHandle, _abort_handle: tokio::task::AbortHandle,
pub local_port: u16, pub local_port: u16,
} }
@@ -115,7 +116,7 @@ impl Drop for PortForwardGuard {
/// Open a kube-rs port-forward to `pod_name` in `namespace` on `remote_port`. /// Open a kube-rs port-forward to `pod_name` in `namespace` on `remote_port`.
/// Binds a local TCP listener and proxies connections to the pod. /// Binds a local TCP listener and proxies connections to the pod.
async fn port_forward( pub(crate) async fn port_forward(
namespace: &str, namespace: &str,
pod_name: &str, pod_name: &str,
remote_port: u16, remote_port: u16,
@@ -192,7 +193,7 @@ async fn port_forward(
} }
/// Port-forward to a service by finding a matching pod via label selector. /// Port-forward to a service by finding a matching pod via label selector.
async fn port_forward_svc( pub(crate) async fn port_forward_svc(
namespace: &str, namespace: &str,
label_selector: &str, label_selector: &str,
remote_port: u16, remote_port: u16,
@@ -221,10 +222,10 @@ struct SeedResult {
} }
/// Read-or-create pattern: reads existing KV values, only generates missing ones. /// Read-or-create pattern: reads existing KV values, only generates missing ones.
async fn get_or_create( pub(crate) async fn get_or_create(
bao: &BaoClient, bao: &BaoClient,
path: &str, path: &str,
fields: &[(&str, &dyn Fn() -> String)], fields: &[(&str, &(dyn Fn() -> String + Send + Sync))],
dirty_paths: &mut HashSet<String>, dirty_paths: &mut HashSet<String>,
) -> Result<HashMap<String, String>> { ) -> Result<HashMap<String, String>> {
let existing = bao.kv_get("secret", path).await?.unwrap_or_default(); let existing = bao.kv_get("secret", path).await?.unwrap_or_default();
@@ -358,7 +359,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"hydra", "hydra",
&[ &[
("system-secret", &rand_token as &dyn Fn() -> String), ("system-secret", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("cookie-secret", &rand_token), ("cookie-secret", &rand_token),
("pairwise-salt", &rand_token), ("pairwise-salt", &rand_token),
], ],
@@ -371,7 +372,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"kratos", "kratos",
&[ &[
("secrets-default", &rand_token as &dyn Fn() -> String), ("secrets-default", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("secrets-cookie", &rand_token), ("secrets-cookie", &rand_token),
("smtp-connection-uri", &smtp_uri_fn), ("smtp-connection-uri", &smtp_uri_fn),
], ],
@@ -383,7 +384,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"seaweedfs", "seaweedfs",
&[ &[
("access-key", &rand_token as &dyn Fn() -> String), ("access-key", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("secret-key", &rand_token), ("secret-key", &rand_token),
], ],
&mut dirty_paths, &mut dirty_paths,
@@ -397,7 +398,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&[ &[
( (
"admin-username", "admin-username",
&gitea_admin_user_fn as &dyn Fn() -> String, &gitea_admin_user_fn as &(dyn Fn() -> String + Send + Sync),
), ),
("admin-password", &rand_token), ("admin-password", &rand_token),
], ],
@@ -410,7 +411,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"hive", "hive",
&[ &[
("oidc-client-id", &hive_local_fn as &dyn Fn() -> String), ("oidc-client-id", &hive_local_fn as &(dyn Fn() -> String + Send + Sync)),
("oidc-client-secret", &rand_token), ("oidc-client-secret", &rand_token),
], ],
&mut dirty_paths, &mut dirty_paths,
@@ -422,7 +423,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"livekit", "livekit",
&[ &[
("api-key", &devkey_fn as &dyn Fn() -> String), ("api-key", &devkey_fn as &(dyn Fn() -> String + Send + Sync)),
("api-secret", &rand_token), ("api-secret", &rand_token),
], ],
&mut dirty_paths, &mut dirty_paths,
@@ -432,7 +433,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
let people = get_or_create( let people = get_or_create(
&bao, &bao,
"people", "people",
&[("django-secret-key", &rand_token as &dyn Fn() -> String)], &[("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))],
&mut dirty_paths, &mut dirty_paths,
) )
.await?; .await?;
@@ -441,7 +442,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"login-ui", "login-ui",
&[ &[
("cookie-secret", &rand_token as &dyn Fn() -> String), ("cookie-secret", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("csrf-cookie-secret", &rand_token), ("csrf-cookie-secret", &rand_token),
], ],
&mut dirty_paths, &mut dirty_paths,
@@ -464,7 +465,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"kratos-admin", "kratos-admin",
&[ &[
("cookie-secret", &rand_token as &dyn Fn() -> String), ("cookie-secret", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("csrf-cookie-secret", &rand_token), ("csrf-cookie-secret", &rand_token),
("admin-identity-ids", &empty_fn), ("admin-identity-ids", &empty_fn),
("s3-access-key", &sw_access_fn), ("s3-access-key", &sw_access_fn),
@@ -478,7 +479,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"docs", "docs",
&[ &[
("django-secret-key", &rand_token as &dyn Fn() -> String), ("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("collaboration-secret", &rand_token), ("collaboration-secret", &rand_token),
], ],
&mut dirty_paths, &mut dirty_paths,
@@ -489,7 +490,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"meet", "meet",
&[ &[
("django-secret-key", &rand_token as &dyn Fn() -> String), ("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("application-jwt-secret-key", &rand_token), ("application-jwt-secret-key", &rand_token),
], ],
&mut dirty_paths, &mut dirty_paths,
@@ -499,7 +500,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
let drive = get_or_create( let drive = get_or_create(
&bao, &bao,
"drive", "drive",
&[("django-secret-key", &rand_token as &dyn Fn() -> String)], &[("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))],
&mut dirty_paths, &mut dirty_paths,
) )
.await?; .await?;
@@ -507,7 +508,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
let projects = get_or_create( let projects = get_or_create(
&bao, &bao,
"projects", "projects",
&[("secret-key", &rand_token as &dyn Fn() -> String)], &[("secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))],
&mut dirty_paths, &mut dirty_paths,
) )
.await?; .await?;
@@ -517,7 +518,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"calendars", "calendars",
&[ &[
("django-secret-key", &cal_django_fn as &dyn Fn() -> String), ("django-secret-key", &cal_django_fn as &(dyn Fn() -> String + Send + Sync)),
("salt-key", &rand_token), ("salt-key", &rand_token),
("caldav-inbound-api-key", &rand_token), ("caldav-inbound-api-key", &rand_token),
("caldav-outbound-api-key", &rand_token), ("caldav-outbound-api-key", &rand_token),
@@ -563,12 +564,12 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"messages", "messages",
&[ &[
("django-secret-key", &rand_token as &dyn Fn() -> String), ("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("salt-key", &rand_token), ("salt-key", &rand_token),
("mda-api-secret", &rand_token), ("mda-api-secret", &rand_token),
( (
"oidc-refresh-token-key", "oidc-refresh-token-key",
&gen_fernet_key as &dyn Fn() -> String, &gen_fernet_key as &(dyn Fn() -> String + Send + Sync),
), ),
("dkim-private-key", &dkim_priv_fn), ("dkim-private-key", &dkim_priv_fn),
("dkim-public-key", &dkim_pub_fn), ("dkim-public-key", &dkim_pub_fn),
@@ -586,7 +587,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"collabora", "collabora",
&[ &[
("username", &admin_fn as &dyn Fn() -> String), ("username", &admin_fn as &(dyn Fn() -> String + Send + Sync)),
("password", &rand_token), ("password", &rand_token),
], ],
&mut dirty_paths, &mut dirty_paths,
@@ -597,7 +598,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
&bao, &bao,
"tuwunel", "tuwunel",
&[ &[
("oidc-client-id", &empty_fn as &dyn Fn() -> String), ("oidc-client-id", &empty_fn as &(dyn Fn() -> String + Send + Sync)),
("oidc-client-secret", &empty_fn), ("oidc-client-secret", &empty_fn),
("turn-secret", &empty_fn), ("turn-secret", &empty_fn),
("registration-token", &rand_token), ("registration-token", &rand_token),
@@ -609,18 +610,26 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
let grafana = get_or_create( let grafana = get_or_create(
&bao, &bao,
"grafana", "grafana",
&[("admin-password", &rand_token as &dyn Fn() -> String)], &[("admin-password", &rand_token as &(dyn Fn() -> String + Send + Sync))],
&mut dirty_paths, &mut dirty_paths,
) )
.await?; .await?;
let scw_access_fn = || scw_config("access-key"); let scw_access_fn = || scw_config("access-key");
let scw_secret_fn = || scw_config("secret-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( let scaleway_s3 = get_or_create(
&bao, &bao,
"scaleway-s3", "scaleway-s3",
&[ &[
("access-key-id", &scw_access_fn as &dyn Fn() -> String), ("access-key-id", &scw_access_fn as &(dyn Fn() -> String + Send + Sync)),
("secret-access-key", &scw_secret_fn), ("secret-access-key", &scw_secret_fn),
], ],
&mut dirty_paths, &mut dirty_paths,
@@ -662,6 +671,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
("tuwunel", &tuwunel), ("tuwunel", &tuwunel),
("grafana", &grafana), ("grafana", &grafana),
("scaleway-s3", &scaleway_s3), ("scaleway-s3", &scaleway_s3),
("penpot", &penpot),
]; ];
for (path, data) in all_paths { for (path, data) in all_paths {
@@ -694,7 +704,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
"auth/kubernetes/role/vso", "auth/kubernetes/role/vso",
&serde_json::json!({ &serde_json::json!({
"bound_service_account_names": "default", "bound_service_account_names": "default",
"bound_service_account_namespaces": "ory,devtools,storage,lasuite,matrix,media,data,monitoring", "bound_service_account_namespaces": "ory,devtools,storage,lasuite,matrix,media,data,monitoring,cert-manager",
"policies": "vso-reader", "policies": "vso-reader",
"ttl": "1h" "ttl": "1h"
}), }),
@@ -742,7 +752,7 @@ async fn seed_openbao() -> Result<Option<SeedResult>> {
// ── Database secrets engine ───────────────────────────────────────────────── // ── Database secrets engine ─────────────────────────────────────────────────
/// Enable OpenBao database secrets engine and create PostgreSQL static roles. /// Enable OpenBao database secrets engine and create PostgreSQL static roles.
async fn configure_db_engine(bao: &BaoClient) -> Result<()> { pub(crate) async fn configure_db_engine(bao: &BaoClient) -> Result<()> {
ok("Configuring OpenBao database secrets engine..."); ok("Configuring OpenBao database secrets engine...");
let pg_rw = "postgres-rw.data.svc.cluster.local:5432"; let pg_rw = "postgres-rw.data.svc.cluster.local:5432";
@@ -825,7 +835,7 @@ async fn configure_db_engine(bao: &BaoClient) -> Result<()> {
} }
/// Execute a psql command on the CNPG primary pod. /// Execute a psql command on the CNPG primary pod.
async fn psql_exec(cnpg_pod: &str, sql: &str) -> Result<(i32, String)> { pub(crate) async fn psql_exec(cnpg_pod: &str, sql: &str) -> Result<(i32, String)> {
k::kube_exec( k::kube_exec(
"data", "data",
cnpg_pod, cnpg_pod,
@@ -838,16 +848,16 @@ async fn psql_exec(cnpg_pod: &str, sql: &str) -> Result<(i32, String)> {
// ── Kratos admin identity seeding ─────────────────────────────────────────── // ── Kratos admin identity seeding ───────────────────────────────────────────
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct KratosIdentity { pub(crate) struct KratosIdentity {
id: String, pub(crate) id: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct KratosRecovery { pub(crate) struct KratosRecovery {
#[serde(default)] #[serde(default)]
recovery_link: String, pub(crate) recovery_link: String,
#[serde(default)] #[serde(default)]
recovery_code: String, pub(crate) recovery_code: String,
} }
/// Ensure estudio-admin@<domain> exists in Kratos and is the only admin identity. /// Ensure estudio-admin@<domain> exists in Kratos and is the only admin identity.
@@ -951,77 +961,7 @@ async fn seed_kratos_admin_identity(bao: &BaoClient) -> (String, String) {
// ── cmd_seed — main entry point ───────────────────────────────────────────── // ── cmd_seed — main entry point ─────────────────────────────────────────────
/// Seed OpenBao KV with crypto-random credentials, then mirror to K8s Secrets.
/// File-based advisory lock for `cmd_seed` to prevent concurrent runs.
struct SeedLock {
path: std::path::PathBuf,
}
impl SeedLock {
fn acquire() -> Result<Self> {
let lock_path = dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap().join(".local/share"))
.join("sunbeam")
.join("seed.lock");
std::fs::create_dir_all(lock_path.parent().unwrap())?;
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(mut f) => {
use std::io::Write;
write!(f, "{}", std::process::id())?;
Ok(SeedLock { path: lock_path })
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
// Check if the PID in the file is still alive
if let Ok(pid_str) = std::fs::read_to_string(&lock_path) {
if let Ok(pid) = pid_str.trim().parse::<i32>() {
// kill(pid, 0) checks if process exists without sending a signal
let alive = is_pid_alive(pid);
if alive {
return Err(SunbeamError::secrets(
"Another sunbeam seed is already running. Wait for it to finish.",
));
}
}
}
// Stale lock, remove and retry
std::fs::remove_file(&lock_path)?;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)?;
use std::io::Write;
write!(f, "{}", std::process::id())?;
Ok(SeedLock { path: lock_path })
}
Err(e) => Err(e.into()),
}
}
}
impl Drop for SeedLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
/// Check if a process with the given PID is still alive.
fn is_pid_alive(pid: i32) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub async fn cmd_seed() -> Result<()> { pub async fn cmd_seed() -> Result<()> {
let _lock = SeedLock::acquire()?;
step("Seeding secrets..."); step("Seeding secrets...");
let seed_result = seed_openbao().await?; let seed_result = seed_openbao().await?;
@@ -1512,7 +1452,7 @@ spec:
// ── Utility helpers ───────────────────────────────────────────────────────── // ── Utility helpers ─────────────────────────────────────────────────────────
async fn wait_pod_running(ns: &str, pod_name: &str, timeout_secs: u64) -> bool { pub(crate) async fn wait_pod_running(ns: &str, pod_name: &str, timeout_secs: u64) -> bool {
let client = match k::get_client().await { let client = match k::get_client().await {
Ok(c) => c, Ok(c) => c,
Err(_) => return false, Err(_) => return false,
@@ -1537,7 +1477,7 @@ async fn wait_pod_running(ns: &str, pod_name: &str, timeout_secs: u64) -> bool {
false false
} }
fn scw_config(key: &str) -> String { pub(crate) fn scw_config(key: &str) -> String {
std::process::Command::new("scw") std::process::Command::new("scw")
.args(["config", "get", key]) .args(["config", "get", key])
.output() .output()
@@ -1565,7 +1505,7 @@ async fn delete_k8s_secret(ns: &str, name: &str) -> Result<()> {
Ok(()) Ok(())
} }
async fn delete_resource(ns: &str, kind: &str, name: &str) -> Result<()> { pub(crate) async fn delete_resource(ns: &str, kind: &str, name: &str) -> Result<()> {
let ctx = format!("--context={}", k::context()); let ctx = format!("--context={}", k::context());
let _ = tokio::process::Command::new("kubectl") let _ = tokio::process::Command::new("kubectl")
.args([&ctx, "-n", ns, "delete", kind, name, "--ignore-not-found"]) .args([&ctx, "-n", ns, "delete", kind, name, "--ignore-not-found"])

View File

@@ -0,0 +1,556 @@
//! KV secret seeding steps: generate/read all credentials, write dirty paths,
//! configure Kubernetes auth for VSO.
//!
//! Data-struct-agnostic — reads JSON fields directly for cross-workflow reuse.
use std::collections::{HashMap, HashSet};
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::openbao::BaoClient;
use crate::output::{ok, warn};
use crate::secrets::{
self, gen_dkim_key_pair, gen_fernet_key, rand_token, rand_token_n, scw_config,
GITEA_ADMIN_USER, SMTP_URI,
};
fn step_err(msg: impl Into<String>) -> wfe_core::WfeError {
wfe_core::WfeError::StepExecution(msg.into())
}
fn json_bool(data: &serde_json::Value, key: &str) -> bool {
data.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
}
fn json_str(data: &serde_json::Value, key: &str) -> Option<String> {
data.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
}
// ── SeedAllKVPaths ──────────────────────────────────────────────────────────
/// Single step that runs the get_or_create loop for all 19 services.
/// Sets `creds` and `dirty_paths` in data.
///
/// Reads: `skip_seed`, `ob_pod`, `root_token`
/// Writes: `creds`, `dirty_paths`
#[derive(Default)]
pub struct SeedAllKVPaths;
#[async_trait::async_trait]
impl StepBody for SeedAllKVPaths {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = &ctx.workflow.data;
if json_bool(data, "skip_seed") {
return Ok(ExecutionResult::next());
}
let ob_pod = match json_str(data, "ob_pod") {
Some(p) => p,
None => {
warn("No ob_pod set -- skipping KV seeding.");
return Ok(ExecutionResult::next());
}
};
let root_token = match json_str(data, "root_token") {
Some(t) => t,
None => {
warn("No root_token set -- skipping KV seeding.");
return Ok(ExecutionResult::next());
}
};
let pf = secrets::port_forward("data", &ob_pod, 8200).await
.map_err(|e| step_err(e.to_string()))?;
let bao = BaoClient::with_token(
&format!("http://127.0.0.1:{}", pf.local_port),
&root_token,
);
let mut dirty_paths: HashSet<String> = HashSet::new();
let hydra = secrets::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.map_err(|e| step_err(e.to_string()))?;
let smtp_uri_fn = || SMTP_URI.to_string();
let kratos = secrets::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.map_err(|e| step_err(e.to_string()))?;
let seaweedfs = secrets::get_or_create(
&bao, "seaweedfs",
&[
("access-key", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("secret-key", &rand_token),
],
&mut dirty_paths,
).await.map_err(|e| step_err(e.to_string()))?;
let gitea_admin_user_fn = || GITEA_ADMIN_USER.to_string();
let gitea = secrets::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.map_err(|e| step_err(e.to_string()))?;
let hive_local_fn = || "hive-local".to_string();
let hive = secrets::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.map_err(|e| step_err(e.to_string()))?;
let devkey_fn = || "devkey".to_string();
let livekit = secrets::get_or_create(
&bao, "livekit",
&[
("api-key", &devkey_fn as &(dyn Fn() -> String + Send + Sync)),
("api-secret", &rand_token),
],
&mut dirty_paths,
).await.map_err(|e| step_err(e.to_string()))?;
let people = secrets::get_or_create(
&bao, "people",
&[("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))],
&mut dirty_paths,
).await.map_err(|e| step_err(e.to_string()))?;
let login_ui = secrets::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.map_err(|e| step_err(e.to_string()))?;
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 = secrets::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.map_err(|e| step_err(e.to_string()))?;
let docs = secrets::get_or_create(
&bao, "docs",
&[
("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync)),
("collaboration-secret", &rand_token),
],
&mut dirty_paths,
).await.map_err(|e| step_err(e.to_string()))?;
let meet = secrets::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.map_err(|e| step_err(e.to_string()))?;
let drive = secrets::get_or_create(
&bao, "drive",
&[("django-secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))],
&mut dirty_paths,
).await.map_err(|e| step_err(e.to_string()))?;
let projects = secrets::get_or_create(
&bao, "projects",
&[("secret-key", &rand_token as &(dyn Fn() -> String + Send + Sync))],
&mut dirty_paths,
).await.map_err(|e| step_err(e.to_string()))?;
let cal_django_fn = || rand_token_n(50);
let calendars = secrets::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.map_err(|e| step_err(e.to_string()))?;
// DKIM key pair
let existing_messages = bao.kv_get("secret", "messages").await
.map_err(|e| step_err(e.to_string()))?
.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 = secrets::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.map_err(|e| step_err(e.to_string()))?;
let admin_fn = || "admin".to_string();
let collabora = secrets::get_or_create(
&bao, "collabora",
&[
("username", &admin_fn as &(dyn Fn() -> String + Send + Sync)),
("password", &rand_token),
],
&mut dirty_paths,
).await.map_err(|e| step_err(e.to_string()))?;
let tuwunel = secrets::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.map_err(|e| step_err(e.to_string()))?;
let grafana = secrets::get_or_create(
&bao, "grafana",
&[("admin-password", &rand_token as &(dyn Fn() -> String + Send + Sync))],
&mut dirty_paths,
).await.map_err(|e| step_err(e.to_string()))?;
let scw_access_fn = || scw_config("access-key");
let scw_secret_fn = || scw_config("secret-key");
let scaleway_s3 = secrets::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.map_err(|e| step_err(e.to_string()))?;
// Build credentials map
let mut creds = HashMap::new();
let field_map: &[(&str, &str, &HashMap<String, String>)] = &[
("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());
}
// Store per-path data for WriteDirtyKVPaths
let all_paths: &[(&str, &HashMap<String, String>)] = &[
("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),
];
for (path, data) in all_paths {
let json = serde_json::to_string(data).map_err(|e| step_err(e.to_string()))?;
creds.insert(format!("kv_data/{path}"), json);
}
let dirty_vec: Vec<String> = dirty_paths.into_iter().collect();
let mut result = ExecutionResult::next();
result.output_data = Some(serde_json::json!({
"creds": creds,
"dirty_paths": dirty_vec,
}));
Ok(result)
}
}
// ── WriteDirtyKVPaths ───────────────────────────────────────────────────────
/// Write all modified KV paths to OpenBao.
///
/// Reads: `skip_seed`, `ob_pod`, `root_token`, `dirty_paths`, `creds`
#[derive(Default)]
pub struct WriteDirtyKVPaths;
#[async_trait::async_trait]
impl StepBody for WriteDirtyKVPaths {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = &ctx.workflow.data;
if json_bool(data, "skip_seed") {
return Ok(ExecutionResult::next());
}
let ob_pod = match json_str(data, "ob_pod") {
Some(p) => p,
None => return Ok(ExecutionResult::next()),
};
let root_token = match json_str(data, "root_token") {
Some(t) => t,
None => return Ok(ExecutionResult::next()),
};
let dirty_paths: Vec<String> = data.get("dirty_paths")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
if dirty_paths.is_empty() {
ok("All OpenBao KV secrets already present -- skipping writes.");
return Ok(ExecutionResult::next());
}
let mut sorted_paths = dirty_paths.clone();
sorted_paths.sort();
ok(&format!("Writing new secrets to OpenBao KV ({})...", sorted_paths.join(", ")));
let pf = secrets::port_forward("data", &ob_pod, 8200).await
.map_err(|e| step_err(e.to_string()))?;
let bao = BaoClient::with_token(
&format!("http://127.0.0.1:{}", pf.local_port),
&root_token,
);
let creds: HashMap<String, String> = data.get("creds")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let dirty_set: HashSet<&str> = dirty_paths.iter().map(|s| s.as_str()).collect();
let kv_paths = [
"hydra", "kratos", "seaweedfs", "gitea", "hive", "livekit",
"people", "login-ui", "kratos-admin", "docs", "meet", "drive",
"projects", "calendars", "messages", "collabora", "tuwunel",
"grafana", "scaleway-s3", "penpot",
];
for path in kv_paths {
if dirty_set.contains(path) {
let json_key = format!("kv_data/{path}");
if let Some(json_str) = creds.get(&json_key) {
let path_data: HashMap<String, String> = serde_json::from_str(json_str)
.map_err(|e| step_err(e.to_string()))?;
bao.kv_patch("secret", path, &path_data).await
.map_err(|e| step_err(e.to_string()))?;
}
}
}
Ok(ExecutionResult::next())
}
}
// ── ConfigureKubernetesAuth ─────────────────────────────────────────────────
/// Enable Kubernetes auth, configure it, write VSO policy and role.
///
/// Reads: `skip_seed`, `ob_pod`, `root_token`
#[derive(Default)]
pub struct ConfigureKubernetesAuth;
#[async_trait::async_trait]
impl StepBody for ConfigureKubernetesAuth {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = &ctx.workflow.data;
if json_bool(data, "skip_seed") {
return Ok(ExecutionResult::next());
}
let ob_pod = match json_str(data, "ob_pod") {
Some(p) => p,
None => return Ok(ExecutionResult::next()),
};
let root_token = match json_str(data, "root_token") {
Some(t) => t,
None => return Ok(ExecutionResult::next()),
};
ok("Configuring Kubernetes auth for VSO...");
let pf = secrets::port_forward("data", &ob_pod, 8200).await
.map_err(|e| step_err(e.to_string()))?;
let bao = BaoClient::with_token(
&format!("http://127.0.0.1:{}", pf.local_port),
&root_token,
);
let _ = bao.auth_enable("kubernetes", "kubernetes").await;
bao.write(
"auth/kubernetes/config",
&serde_json::json!({
"kubernetes_host": "https://kubernetes.default.svc.cluster.local"
}),
).await.map_err(|e| step_err(e.to_string()))?;
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
.map_err(|e| step_err(e.to_string()))?;
bao.write(
"auth/kubernetes/role/vso",
&serde_json::json!({
"bound_service_account_names": "default",
"bound_service_account_namespaces": "ory,devtools,storage,lasuite,matrix,media,data,monitoring,cert-manager",
"policies": "vso-reader",
"ttl": "1h"
}),
).await.map_err(|e| step_err(e.to_string()))?;
Ok(ExecutionResult::next())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use wfe::run_workflow_sync;
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::WorkflowStatus;
async fn run_step<S: StepBody + Default + 'static>(
data: serde_json::Value,
) -> wfe_core::models::WorkflowInstance {
let host = crate::workflows::host::create_test_host().await.unwrap();
host.register_step::<S>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<S>()
.name("test-step")
.end_workflow()
.build("test-wf", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(&host, "test-wf", 1, data, Duration::from_secs(5))
.await
.unwrap();
host.stop().await;
instance
}
#[tokio::test]
async fn test_seed_all_kv_paths_skip_seed() {
let instance = run_step::<SeedAllKVPaths>(serde_json::json!({ "skip_seed": true })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_seed_all_kv_paths_no_ob_pod() {
let instance = run_step::<SeedAllKVPaths>(serde_json::json!({ "skip_seed": false })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_seed_all_kv_paths_no_root_token() {
let instance = run_step::<SeedAllKVPaths>(
serde_json::json!({ "skip_seed": false, "ob_pod": "openbao-0" })
).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_write_dirty_kv_paths_skip_seed() {
let instance = run_step::<WriteDirtyKVPaths>(serde_json::json!({ "skip_seed": true })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_write_dirty_kv_paths_empty_dirty_paths() {
let instance = run_step::<WriteDirtyKVPaths>(serde_json::json!({
"skip_seed": false, "ob_pod": "openbao-0", "root_token": "hvs.test", "dirty_paths": [],
})).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_configure_kubernetes_auth_skip_seed() {
let instance = run_step::<ConfigureKubernetesAuth>(serde_json::json!({ "skip_seed": true })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_configure_kubernetes_auth_no_ob_pod() {
let instance = run_step::<ConfigureKubernetesAuth>(serde_json::json!({ "skip_seed": false })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
}

View File

@@ -0,0 +1,291 @@
//! PostgreSQL setup steps: wait for CNPG cluster, create roles/databases,
//! configure OpenBao database secrets engine.
//!
//! Data-struct-agnostic — reads JSON fields directly for cross-workflow reuse.
use std::collections::HashMap;
use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, ApiResource, DynamicObject, ListParams};
use wfe_core::models::ExecutionResult;
use wfe_core::traits::{StepBody, StepExecutionContext};
use crate::kube as k;
use crate::openbao::BaoClient;
use crate::output::{ok, warn};
use crate::secrets::{self, PG_USERS};
fn step_err(msg: impl Into<String>) -> wfe_core::WfeError {
wfe_core::WfeError::StepExecution(msg.into())
}
fn json_bool(data: &serde_json::Value, key: &str) -> bool {
data.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
}
fn json_str(data: &serde_json::Value, key: &str) -> Option<String> {
data.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
}
// ── Pure helpers (testable without K8s) ─────────────────────────────────────
/// Build the user to database mapping used by EnsurePGRolesAndDatabases.
pub(crate) fn pg_db_map() -> HashMap<&'static str, &'static 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"),
("penpot", "penpot_db"),
].into_iter().collect()
}
/// SQL to idempotently create a postgres user if it does not exist.
pub(crate) fn ensure_user_sql(user: &str) -> String {
format!(
"DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='{user}') \
THEN CREATE USER {user}; END IF; END $$;"
)
}
/// SQL to create a database owned by the given user.
pub(crate) fn create_db_sql(db: &str, user: &str) -> String {
format!("CREATE DATABASE {db} OWNER {user};")
}
// ── WaitForPostgres ─────────────────────────────────────────────────────────
/// Wait for CNPG cluster healthy state, set `pg_pod`.
///
/// Reads: `skip_seed`
/// Writes: `pg_pod`
#[derive(Default)]
pub struct WaitForPostgres;
#[async_trait::async_trait]
impl StepBody for WaitForPostgres {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
if json_bool(&ctx.workflow.data, "skip_seed") {
return Ok(ExecutionResult::next());
}
ok("Waiting for postgres cluster...");
let mut pg_pod = String::new();
let client = k::get_client().await.map_err(|e| step_err(e.to_string()))?;
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<DynamicObject> = 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" {
let pods: Api<Pod> = 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.");
}
let mut result = ExecutionResult::next();
if !pg_pod.is_empty() {
result.output_data = Some(serde_json::json!({ "pg_pod": pg_pod }));
}
Ok(result)
}
}
// ── EnsurePGRolesAndDatabases ───────────────────────────────────────────────
/// Create all 13 users and databases.
///
/// Reads: `skip_seed`, `pg_pod`
#[derive(Default)]
pub struct EnsurePGRolesAndDatabases;
#[async_trait::async_trait]
impl StepBody for EnsurePGRolesAndDatabases {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = &ctx.workflow.data;
if json_bool(data, "skip_seed") {
return Ok(ExecutionResult::next());
}
let pg_pod = match json_str(data, "pg_pod") {
Some(p) if !p.is_empty() => p,
_ => return Ok(ExecutionResult::next()),
};
ok("Ensuring postgres roles and databases exist...");
let db_map = pg_db_map();
for user in PG_USERS {
let sql = ensure_user_sql(user);
let _ = k::kube_exec("data", &pg_pod, &["psql", "-U", "postgres", "-c", &sql], Some("postgres")).await;
let db = db_map.get(user).copied().unwrap_or("unknown_db");
let sql = create_db_sql(db, user);
let _ = k::kube_exec("data", &pg_pod, &["psql", "-U", "postgres", "-c", &sql], Some("postgres")).await;
}
Ok(ExecutionResult::next())
}
}
// ── ConfigureDatabaseEngine ─────────────────────────────────────────────────
/// Configure OpenBao database secrets engine.
///
/// Reads: `skip_seed`, `pg_pod`, `ob_pod`, `root_token`
#[derive(Default)]
pub struct ConfigureDatabaseEngine;
#[async_trait::async_trait]
impl StepBody for ConfigureDatabaseEngine {
async fn run(
&mut self,
ctx: &StepExecutionContext<'_>,
) -> wfe_core::Result<ExecutionResult> {
let data = &ctx.workflow.data;
if json_bool(data, "skip_seed") {
return Ok(ExecutionResult::next());
}
let _pg_pod = match json_str(data, "pg_pod") {
Some(p) if !p.is_empty() => p,
_ => return Ok(ExecutionResult::next()),
};
let ob_pod = match json_str(data, "ob_pod") {
Some(p) => p,
None => { warn("Skipping DB engine config -- missing ob_pod."); return Ok(ExecutionResult::next()); }
};
let root_token = match json_str(data, "root_token") {
Some(t) if !t.is_empty() => t,
_ => { warn("Skipping DB engine config -- missing root_token."); return Ok(ExecutionResult::next()); }
};
match secrets::port_forward("data", &ob_pod, 8200).await {
Ok(pf) => {
let bao = BaoClient::with_token(
&format!("http://127.0.0.1:{}", pf.local_port),
&root_token,
);
if let Err(e) = secrets::configure_db_engine(&bao).await {
warn(&format!("DB engine config failed: {e}"));
}
}
Err(e) => warn(&format!("Port-forward to OpenBao failed: {e}")),
}
Ok(ExecutionResult::next())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use wfe::run_workflow_sync;
use wfe_core::builder::WorkflowBuilder;
use wfe_core::models::WorkflowStatus;
async fn run_step<S: StepBody + Default + 'static>(
data: serde_json::Value,
) -> wfe_core::models::WorkflowInstance {
let host = crate::workflows::host::create_test_host().await.unwrap();
host.register_step::<S>().await;
let def = WorkflowBuilder::<serde_json::Value>::new()
.start_with::<S>()
.name("test-step")
.end_workflow()
.build("test-wf", 1);
host.register_workflow_definition(def).await;
let instance = run_workflow_sync(&host, "test-wf", 1, data, Duration::from_secs(5))
.await
.unwrap();
host.stop().await;
instance
}
#[tokio::test]
async fn test_wait_for_postgres_skip_seed() {
let instance = run_step::<WaitForPostgres>(serde_json::json!({ "skip_seed": true })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_ensure_pg_roles_skip_seed() {
let instance = run_step::<EnsurePGRolesAndDatabases>(serde_json::json!({ "skip_seed": true })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_ensure_pg_roles_no_pg_pod() {
let instance = run_step::<EnsurePGRolesAndDatabases>(serde_json::json!({ "skip_seed": false })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_configure_db_engine_skip_seed() {
let instance = run_step::<ConfigureDatabaseEngine>(serde_json::json!({ "skip_seed": true })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[tokio::test]
async fn test_configure_db_engine_no_pg_pod() {
let instance = run_step::<ConfigureDatabaseEngine>(serde_json::json!({ "skip_seed": false })).await;
assert_eq!(instance.status, WorkflowStatus::Complete);
}
#[test]
fn test_pg_db_map_contains_all_users() {
let map = pg_db_map();
assert_eq!(map.len(), 13);
for user in PG_USERS {
assert!(map.contains_key(user), "pg_db_map missing key for: {user}");
}
}
#[test]
fn test_ensure_user_sql_format() {
let sql = ensure_user_sql("kratos");
assert!(sql.contains("rolname='kratos'"));
assert!(sql.contains("CREATE USER kratos"));
}
#[test]
fn test_create_db_sql_format() {
assert_eq!(create_db_sql("kratos_db", "kratos"), "CREATE DATABASE kratos_db OWNER kratos;");
}
}