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:
48
src/auth.rs
48
src/auth.rs
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
297
src/cli.rs
297
src/cli.rs
@@ -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) => {
|
||||||
|
|||||||
168
src/secrets.rs
168
src/secrets.rs
@@ -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"])
|
||||||
|
|||||||
556
src/workflows/seed/steps/kv_seeding.rs
Normal file
556
src/workflows/seed/steps/kv_seeding.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/workflows/seed/steps/postgres.rs
Normal file
291
src/workflows/seed/steps/postgres.rs
Normal 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;");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user