refactor: SDK images and secrets modules with submodule splits

Split images.rs (1809L) into mod.rs + builders.rs (per-service build
functions). Split secrets.rs (1727L) into mod.rs + seeding.rs (KV
get_or_create, seed_openbao) + db_engine.rs (PostgreSQL static roles).
Moves BuildTarget enum from cli.rs into images/mod.rs with conditional
clap::ValueEnum derive behind the "cli" feature.
This commit is contained in:
2026-03-21 14:37:47 +00:00
parent 8e51e0b3ae
commit bc65b9157d
5 changed files with 3631 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
//! OpenBao database secrets engine configuration.
use std::collections::HashMap;
use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, ListParams};
use crate::error::{Result, ResultExt};
use crate::kube as k;
use crate::openbao::BaoClient;
use crate::output::ok;
use super::{rand_token, PG_USERS};
/// Enable OpenBao database secrets engine and create PostgreSQL static roles.
pub async fn configure_db_engine(bao: &BaoClient) -> Result<()> {
ok("Configuring OpenBao database secrets engine...");
let pg_rw = "postgres-rw.data.svc.cluster.local:5432";
let _ = bao.enable_secrets_engine("database", "database").await;
// ── vault PG user setup ─────────────────────────────────────────────
let client = k::get_client().await?;
let pods: Api<Pod> = Api::namespaced(client.clone(), "data");
let lp = ListParams::default().labels("cnpg.io/cluster=postgres,role=primary");
let pod_list = pods.list(&lp).await?;
let cnpg_pod = pod_list
.items
.first()
.and_then(|p| p.metadata.name.as_deref())
.ctx("Could not find CNPG primary pod for vault user setup.")?
.to_string();
let existing_vault_pass = bao.kv_get_field("secret", "vault", "pg-password").await?;
let vault_pg_pass = if existing_vault_pass.is_empty() {
let new_pass = rand_token();
let mut vault_data = HashMap::new();
vault_data.insert("pg-password".to_string(), new_pass.clone());
bao.kv_put("secret", "vault", &vault_data).await?;
ok("vault KV entry written.");
new_pass
} else {
ok("vault KV entry already present -- skipping write.");
existing_vault_pass
};
let create_vault_sql = concat!(
"DO $$ BEGIN ",
"IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'vault') THEN ",
"CREATE USER vault WITH LOGIN CREATEROLE; ",
"END IF; ",
"END $$;"
);
psql_exec(&cnpg_pod, create_vault_sql).await?;
psql_exec(
&cnpg_pod,
&format!("ALTER USER vault WITH PASSWORD '{vault_pg_pass}';"),
)
.await?;
for user in PG_USERS {
psql_exec(
&cnpg_pod,
&format!("GRANT {user} TO vault WITH ADMIN OPTION;"),
)
.await?;
}
ok("vault PG user configured with ADMIN OPTION on all service roles.");
let conn_url = format!(
"postgresql://{{{{username}}}}:{{{{password}}}}@{pg_rw}/postgres?sslmode=disable"
);
bao.write_db_config(
"cnpg-postgres",
"postgresql-database-plugin",
&conn_url,
"vault",
&vault_pg_pass,
"*",
)
.await?;
ok("DB engine connection configured (vault user).");
let rotation_stmt = r#"ALTER USER "{{name}}" WITH PASSWORD '{{password}}';"#;
for user in PG_USERS {
bao.write_db_static_role(user, "cnpg-postgres", user, 86400, &[rotation_stmt])
.await?;
ok(&format!(" static-role/{user}"));
}
ok("Database secrets engine configured.");
Ok(())
}
/// Execute a psql command on the CNPG primary pod.
async fn psql_exec(cnpg_pod: &str, sql: &str) -> Result<(i32, String)> {
k::kube_exec(
"data",
cnpg_pod,
&["psql", "-U", "postgres", "-c", sql],
Some("postgres"),
)
.await
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,542 @@
//! OpenBao KV seeding — init/unseal, idempotent credential generation, VSO auth.
use std::collections::{HashMap, HashSet};
use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, ListParams};
use crate::error::Result;
use crate::kube as k;
use crate::openbao::BaoClient;
use crate::output::{ok, warn};
use super::{
gen_dkim_key_pair, gen_fernet_key, port_forward, rand_token, rand_token_n, scw_config,
wait_pod_running, delete_resource, GITEA_ADMIN_USER, SMTP_URI,
};
/// Internal result from seed_openbao, used by cmd_seed.
pub struct SeedResult {
pub creds: HashMap<String, String>,
pub ob_pod: String,
pub root_token: String,
}
/// Read-or-create pattern: reads existing KV values, only generates missing ones.
pub async fn get_or_create(
bao: &BaoClient,
path: &str,
fields: &[(&str, &dyn Fn() -> String)],
dirty_paths: &mut HashSet<String>,
) -> Result<HashMap<String, String>> {
let existing = bao.kv_get("secret", path).await?.unwrap_or_default();
let mut result = HashMap::new();
for (key, default_fn) in fields {
let val = existing.get(*key).filter(|v| !v.is_empty()).cloned();
if let Some(v) = val {
result.insert(key.to_string(), v);
} else {
result.insert(key.to_string(), default_fn());
dirty_paths.insert(path.to_string());
}
}
Ok(result)
}
/// Initialize/unseal OpenBao, generate/read credentials idempotently, configure VSO auth.
pub async fn seed_openbao() -> Result<Option<SeedResult>> {
let client = k::get_client().await?;
let pods: Api<Pod> = Api::namespaced(client.clone(), "data");
let lp = ListParams::default().labels("app.kubernetes.io/name=openbao,component=server");
let pod_list = pods.list(&lp).await?;
let ob_pod = match pod_list
.items
.first()
.and_then(|p| p.metadata.name.as_deref())
{
Some(name) => name.to_string(),
None => {
ok("OpenBao pod not found -- skipping.");
return Ok(None);
}
};
ok(&format!("OpenBao ({ob_pod})..."));
let _ = wait_pod_running("data", &ob_pod, 120).await;
let pf = port_forward("data", &ob_pod, 8200).await?;
let bao_url = format!("http://127.0.0.1:{}", pf.local_port);
let bao = BaoClient::new(&bao_url);
// ── Init / Unseal ───────────────────────────────────────────────────
let mut unseal_key = String::new();
let mut root_token = String::new();
let status = bao.seal_status().await.unwrap_or_else(|_| {
crate::openbao::SealStatusResponse {
initialized: false,
sealed: true,
progress: 0,
t: 0,
n: 0,
}
});
let mut already_initialized = status.initialized;
if !already_initialized {
if let Ok(Some(_)) = k::kube_get_secret("data", "openbao-keys").await {
already_initialized = true;
}
}
if !already_initialized {
ok("Initializing OpenBao...");
match bao.init(1, 1).await {
Ok(init) => {
unseal_key = init.unseal_keys_b64[0].clone();
root_token = init.root_token.clone();
let mut data = HashMap::new();
data.insert("key".to_string(), unseal_key.clone());
data.insert("root-token".to_string(), root_token.clone());
k::create_secret("data", "openbao-keys", data).await?;
ok("Initialized -- keys stored in secret/openbao-keys.");
}
Err(e) => {
warn(&format!(
"Init failed -- resetting OpenBao storage for local dev... ({e})"
));
let _ = delete_resource("data", "pvc", "data-openbao-0").await;
let _ = delete_resource("data", "pod", &ob_pod).await;
warn("OpenBao storage reset. Run --seed again after the pod restarts.");
return Ok(None);
}
}
} else {
ok("Already initialized.");
if let Ok(key) = k::kube_get_secret_field("data", "openbao-keys", "key").await {
unseal_key = key;
}
if let Ok(token) = k::kube_get_secret_field("data", "openbao-keys", "root-token").await {
root_token = token;
}
}
// Unseal if needed
let status = bao.seal_status().await.unwrap_or_else(|_| {
crate::openbao::SealStatusResponse {
initialized: true,
sealed: true,
progress: 0,
t: 0,
n: 0,
}
});
if status.sealed && !unseal_key.is_empty() {
ok("Unsealing...");
bao.unseal(&unseal_key).await?;
}
if root_token.is_empty() {
warn("No root token available -- skipping KV seeding.");
return Ok(None);
}
let bao = BaoClient::with_token(&bao_url, &root_token);
// ── KV seeding ──────────────────────────────────────────────────────
ok("Seeding KV (idempotent -- existing values preserved)...");
let _ = bao.enable_secrets_engine("secret", "kv").await;
let _ = bao
.write(
"sys/mounts/secret/tune",
&serde_json::json!({"options": {"version": "2"}}),
)
.await;
let mut dirty_paths: HashSet<String> = HashSet::new();
let hydra = get_or_create(
&bao,
"hydra",
&[
("system-secret", &rand_token as &dyn Fn() -> String),
("cookie-secret", &rand_token),
("pairwise-salt", &rand_token),
],
&mut dirty_paths,
)
.await?;
let smtp_uri_fn = || SMTP_URI.to_string();
let kratos = get_or_create(
&bao,
"kratos",
&[
("secrets-default", &rand_token as &dyn Fn() -> String),
("secrets-cookie", &rand_token),
("smtp-connection-uri", &smtp_uri_fn),
],
&mut dirty_paths,
)
.await?;
let seaweedfs = get_or_create(
&bao,
"seaweedfs",
&[
("access-key", &rand_token as &dyn Fn() -> String),
("secret-key", &rand_token),
],
&mut dirty_paths,
)
.await?;
let gitea_admin_user_fn = || GITEA_ADMIN_USER.to_string();
let gitea = get_or_create(
&bao,
"gitea",
&[
(
"admin-username",
&gitea_admin_user_fn as &dyn Fn() -> String,
),
("admin-password", &rand_token),
],
&mut dirty_paths,
)
.await?;
let hive_local_fn = || "hive-local".to_string();
let hive = get_or_create(
&bao,
"hive",
&[
("oidc-client-id", &hive_local_fn as &dyn Fn() -> String),
("oidc-client-secret", &rand_token),
],
&mut dirty_paths,
)
.await?;
let devkey_fn = || "devkey".to_string();
let livekit = get_or_create(
&bao,
"livekit",
&[
("api-key", &devkey_fn as &dyn Fn() -> String),
("api-secret", &rand_token),
],
&mut dirty_paths,
)
.await?;
let people = get_or_create(
&bao,
"people",
&[("django-secret-key", &rand_token as &dyn Fn() -> String)],
&mut dirty_paths,
)
.await?;
let login_ui = get_or_create(
&bao,
"login-ui",
&[
("cookie-secret", &rand_token as &dyn Fn() -> String),
("csrf-cookie-secret", &rand_token),
],
&mut dirty_paths,
)
.await?;
let sw_access = seaweedfs.get("access-key").cloned().unwrap_or_default();
let sw_secret = seaweedfs.get("secret-key").cloned().unwrap_or_default();
let empty_fn = || String::new();
let sw_access_fn = {
let v = sw_access.clone();
move || v.clone()
};
let sw_secret_fn = {
let v = sw_secret.clone();
move || v.clone()
};
let kratos_admin = get_or_create(
&bao,
"kratos-admin",
&[
("cookie-secret", &rand_token as &dyn Fn() -> String),
("csrf-cookie-secret", &rand_token),
("admin-identity-ids", &empty_fn),
("s3-access-key", &sw_access_fn),
("s3-secret-key", &sw_secret_fn),
],
&mut dirty_paths,
)
.await?;
let docs = get_or_create(
&bao,
"docs",
&[
("django-secret-key", &rand_token as &dyn Fn() -> String),
("collaboration-secret", &rand_token),
],
&mut dirty_paths,
)
.await?;
let meet = get_or_create(
&bao,
"meet",
&[
("django-secret-key", &rand_token as &dyn Fn() -> String),
("application-jwt-secret-key", &rand_token),
],
&mut dirty_paths,
)
.await?;
let drive = get_or_create(
&bao,
"drive",
&[("django-secret-key", &rand_token as &dyn Fn() -> String)],
&mut dirty_paths,
)
.await?;
let projects = get_or_create(
&bao,
"projects",
&[("secret-key", &rand_token as &dyn Fn() -> String)],
&mut dirty_paths,
)
.await?;
let cal_django_fn = || rand_token_n(50);
let calendars = get_or_create(
&bao,
"calendars",
&[
("django-secret-key", &cal_django_fn as &dyn Fn() -> String),
("salt-key", &rand_token),
("caldav-inbound-api-key", &rand_token),
("caldav-outbound-api-key", &rand_token),
("caldav-internal-api-key", &rand_token),
],
&mut dirty_paths,
)
.await?;
// DKIM key pair — generated together since keys are coupled.
let existing_messages = bao.kv_get("secret", "messages").await?.unwrap_or_default();
let (dkim_private, dkim_public) = if existing_messages
.get("dkim-private-key")
.filter(|v| !v.is_empty())
.is_some()
{
(
existing_messages
.get("dkim-private-key")
.cloned()
.unwrap_or_default(),
existing_messages
.get("dkim-public-key")
.cloned()
.unwrap_or_default(),
)
} else {
gen_dkim_key_pair()
};
let dkim_priv_fn = {
let v = dkim_private.clone();
move || v.clone()
};
let dkim_pub_fn = {
let v = dkim_public.clone();
move || v.clone()
};
let socks_proxy_fn = || format!("sunbeam:{}", rand_token());
let sunbeam_fn = || "sunbeam".to_string();
let messages = get_or_create(
&bao,
"messages",
&[
("django-secret-key", &rand_token as &dyn Fn() -> String),
("salt-key", &rand_token),
("mda-api-secret", &rand_token),
(
"oidc-refresh-token-key",
&gen_fernet_key as &dyn Fn() -> String,
),
("dkim-private-key", &dkim_priv_fn),
("dkim-public-key", &dkim_pub_fn),
("rspamd-password", &rand_token),
("socks-proxy-users", &socks_proxy_fn),
("mta-out-smtp-username", &sunbeam_fn),
("mta-out-smtp-password", &rand_token),
],
&mut dirty_paths,
)
.await?;
let admin_fn = || "admin".to_string();
let collabora = get_or_create(
&bao,
"collabora",
&[
("username", &admin_fn as &dyn Fn() -> String),
("password", &rand_token),
],
&mut dirty_paths,
)
.await?;
let tuwunel = get_or_create(
&bao,
"tuwunel",
&[
("oidc-client-id", &empty_fn as &dyn Fn() -> String),
("oidc-client-secret", &empty_fn),
("turn-secret", &empty_fn),
("registration-token", &rand_token),
],
&mut dirty_paths,
)
.await?;
let grafana = get_or_create(
&bao,
"grafana",
&[("admin-password", &rand_token as &dyn Fn() -> String)],
&mut dirty_paths,
)
.await?;
let scw_access_fn = || scw_config("access-key");
let scw_secret_fn = || scw_config("secret-key");
let scaleway_s3 = get_or_create(
&bao,
"scaleway-s3",
&[
("access-key-id", &scw_access_fn as &dyn Fn() -> String),
("secret-access-key", &scw_secret_fn),
],
&mut dirty_paths,
)
.await?;
// ── Write dirty paths ───────────────────────────────────────────────
if dirty_paths.is_empty() {
ok("All OpenBao KV secrets already present -- skipping writes.");
} else {
let mut sorted_paths: Vec<&String> = dirty_paths.iter().collect();
sorted_paths.sort();
ok(&format!(
"Writing new secrets to OpenBao KV ({})...",
sorted_paths
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
));
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 {
if dirty_paths.contains(*path) {
bao.kv_patch("secret", path, data).await?;
}
}
}
// ── Kubernetes auth for VSO ─────────────────────────────────────────
ok("Configuring Kubernetes auth for VSO...");
let _ = bao.auth_enable("kubernetes", "kubernetes").await;
bao.write(
"auth/kubernetes/config",
&serde_json::json!({
"kubernetes_host": "https://kubernetes.default.svc.cluster.local"
}),
)
.await?;
let policy_hcl = concat!(
"path \"secret/data/*\" { capabilities = [\"read\"] }\n",
"path \"secret/metadata/*\" { capabilities = [\"read\", \"list\"] }\n",
"path \"database/static-creds/*\" { capabilities = [\"read\"] }\n",
);
bao.write_policy("vso-reader", policy_hcl).await?;
bao.write(
"auth/kubernetes/role/vso",
&serde_json::json!({
"bound_service_account_names": "default",
"bound_service_account_namespaces": "ory,devtools,storage,lasuite,matrix,media,data,monitoring",
"policies": "vso-reader",
"ttl": "1h"
}),
)
.await?;
// 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(),
);
}
Ok(Some(SeedResult {
creds,
ob_pod,
root_token,
}))
}