rust-rewrite #2

Merged
siennathesane merged 39 commits from rust-rewrite into mainline 2026-03-21 14:40:42 +00:00
67 changed files with 36099 additions and 236 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ __pycache__/
dist/ dist/
build/ build/
.eggs/ .eggs/
# Rust
/target/

4882
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["sunbeam-sdk", "sunbeam"]
resolver = "3"

132
build.rs Normal file
View File

@@ -0,0 +1,132 @@
use flate2::read::GzDecoder;
use std::env;
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use std::process::Command;
use tar::Archive;
const KUSTOMIZE_VERSION: &str = "v5.8.1";
const HELM_VERSION: &str = "v4.1.0";
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let target = env::var("TARGET").unwrap_or_default();
let (os, arch) = parse_target(&target);
download_and_embed("kustomize", KUSTOMIZE_VERSION, &os, &arch, &out_dir);
download_and_embed("helm", HELM_VERSION, &os, &arch, &out_dir);
// Set version info from git
let commit = git_commit_sha();
println!("cargo:rustc-env=SUNBEAM_COMMIT={commit}");
// Build target triple and build date
println!("cargo:rustc-env=SUNBEAM_TARGET={target}");
let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
println!("cargo:rustc-env=SUNBEAM_BUILD_DATE={date}");
// Rebuild if git HEAD changes
println!("cargo:rerun-if-changed=.git/HEAD");
}
fn parse_target(target: &str) -> (String, String) {
let os = if target.contains("darwin") {
"darwin"
} else if target.contains("linux") {
"linux"
} else if cfg!(target_os = "macos") {
"darwin"
} else {
"linux"
};
let arch = if target.contains("aarch64") || target.contains("arm64") {
"arm64"
} else if target.contains("x86_64") || target.contains("amd64") {
"amd64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"amd64"
};
(os.to_string(), arch.to_string())
}
fn download_and_embed(tool: &str, version: &str, os: &str, arch: &str, out_dir: &PathBuf) {
let dest = out_dir.join(tool);
if dest.exists() {
return;
}
let url = match tool {
"kustomize" => format!(
"https://github.com/kubernetes-sigs/kustomize/releases/download/\
kustomize%2F{version}/kustomize_{version}_{os}_{arch}.tar.gz"
),
"helm" => format!(
"https://get.helm.sh/helm-{version}-{os}-{arch}.tar.gz"
),
_ => panic!("Unknown tool: {tool}"),
};
let extract_path = match tool {
"kustomize" => "kustomize".to_string(),
"helm" => format!("{os}-{arch}/helm"),
_ => unreachable!(),
};
eprintln!("cargo:warning=Downloading {tool} {version} for {os}/{arch}...");
let response = reqwest::blocking::get(&url)
.unwrap_or_else(|e| panic!("Failed to download {tool}: {e}"));
let bytes = response
.bytes()
.unwrap_or_else(|e| panic!("Failed to read {tool} response: {e}"));
let decoder = GzDecoder::new(&bytes[..]);
let mut archive = Archive::new(decoder);
for entry in archive.entries().expect("Failed to read tar entries") {
let mut entry = entry.expect("Failed to read tar entry");
let path = entry
.path()
.expect("Failed to read entry path")
.to_path_buf();
if path.to_string_lossy() == extract_path {
let mut data = Vec::new();
entry
.read_to_end(&mut data)
.expect("Failed to read binary");
fs::write(&dest, &data).expect("Failed to write binary");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dest, fs::Permissions::from_mode(0o755))
.expect("Failed to set permissions");
}
eprintln!("cargo:warning=Embedded {tool} ({} bytes)", data.len());
return;
}
}
panic!("Could not find {extract_path} in {tool} archive");
}
fn git_commit_sha() -> String {
Command::new("git")
.args(["rev-parse", "--short=8", "HEAD"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string())
}

952
src/auth.rs Normal file
View File

@@ -0,0 +1,952 @@
//! OAuth2 Authorization Code flow with PKCE for CLI authentication against Hydra.
use crate::error::{Result, ResultExt, SunbeamError};
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
// ---------------------------------------------------------------------------
// Token cache data
// ---------------------------------------------------------------------------
/// Cached OAuth2 tokens persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthTokens {
pub access_token: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
pub domain: String,
/// Gitea personal access token (created during auth login).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitea_token: Option<String>,
}
/// Default client ID when the K8s secret is unavailable.
const DEFAULT_CLIENT_ID: &str = "sunbeam-cli";
// ---------------------------------------------------------------------------
// Cache file helpers
// ---------------------------------------------------------------------------
/// Cache path for auth tokens — per-domain so multiple environments work.
fn cache_path_for_domain(domain: &str) -> PathBuf {
let dir = dirs::data_dir()
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".local/share")
})
.join("sunbeam")
.join("auth");
if domain.is_empty() {
dir.join("default.json")
} else {
// Sanitize domain for filename
let safe = domain.replace(['/', '\\', ':'], "_");
dir.join(format!("{safe}.json"))
}
}
fn cache_path() -> PathBuf {
let domain = crate::config::domain();
cache_path_for_domain(domain)
}
fn read_cache() -> Result<AuthTokens> {
let path = cache_path();
let content = std::fs::read_to_string(&path).map_err(|e| {
SunbeamError::Identity(format!("No cached auth tokens ({}): {e}", path.display()))
})?;
let tokens: AuthTokens = serde_json::from_str(&content)
.ctx("Failed to parse cached auth tokens")?;
Ok(tokens)
}
fn write_cache(tokens: &AuthTokens) -> Result<()> {
let path = cache_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_ctx(|| format!("Failed to create auth cache dir: {}", parent.display()))?;
}
let content = serde_json::to_string_pretty(tokens)?;
std::fs::write(&path, &content)
.with_ctx(|| format!("Failed to write auth cache to {}", path.display()))?;
// Set 0600 permissions on unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms)
.with_ctx(|| format!("Failed to set permissions on {}", path.display()))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// PKCE
// ---------------------------------------------------------------------------
/// Generate a PKCE code_verifier and code_challenge (S256).
fn generate_pkce() -> (String, String) {
let verifier_bytes: [u8; 32] = rand::random();
let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
let challenge = {
let hash = Sha256::digest(verifier.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash)
};
(verifier, challenge)
}
/// Generate a random state parameter for OAuth2.
fn generate_state() -> String {
let bytes: [u8; 16] = rand::random();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
// ---------------------------------------------------------------------------
// OIDC discovery
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct OidcDiscovery {
authorization_endpoint: String,
token_endpoint: String,
}
/// Resolve the domain for authentication, trying multiple sources.
async fn resolve_domain(explicit: Option<&str>) -> Result<String> {
// 1. Explicit --domain flag
if let Some(d) = explicit {
if !d.is_empty() {
return Ok(d.to_string());
}
}
// 2. Active context domain (set by cli::dispatch from config)
let ctx_domain = crate::config::domain();
if !ctx_domain.is_empty() {
return Ok(ctx_domain.to_string());
}
// 3. Cached token domain (already logged in)
if let Ok(tokens) = read_cache() {
if !tokens.domain.is_empty() {
crate::output::ok(&format!("Using cached domain: {}", tokens.domain));
return Ok(tokens.domain);
}
}
// 4. Try cluster discovery (may fail if not connected)
match crate::kube::get_domain().await {
Ok(d) if !d.is_empty() && !d.starts_with('.') => return Ok(d),
_ => {}
}
Err(SunbeamError::config(
"Could not determine domain. Use --domain flag, or configure with:\n \
sunbeam config set --host user@your-server.example.com",
))
}
async fn discover_oidc(domain: &str) -> Result<OidcDiscovery> {
let url = format!("https://auth.{domain}/.well-known/openid-configuration");
let client = reqwest::Client::new();
let resp = client
.get(&url)
.send()
.await
.with_ctx(|| format!("Failed to fetch OIDC discovery from {url}"))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"OIDC discovery returned HTTP {}",
resp.status()
)));
}
let discovery: OidcDiscovery = resp
.json()
.await
.ctx("Failed to parse OIDC discovery response")?;
Ok(discovery)
}
// ---------------------------------------------------------------------------
// Token exchange / refresh
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<i64>,
#[serde(default)]
id_token: Option<String>,
}
async fn exchange_code(
token_endpoint: &str,
code: &str,
redirect_uri: &str,
client_id: &str,
code_verifier: &str,
) -> Result<TokenResponse> {
let client = reqwest::Client::new();
let resp = client
.post(token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("client_id", client_id),
("code_verifier", code_verifier),
])
.send()
.await
.ctx("Failed to exchange authorization code")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(SunbeamError::identity(format!(
"Token exchange failed (HTTP {status}): {body}"
)));
}
let token_resp: TokenResponse = resp.json().await.ctx("Failed to parse token response")?;
Ok(token_resp)
}
/// Refresh an access token using a refresh token.
async fn refresh_token(cached: &AuthTokens) -> Result<AuthTokens> {
let discovery = discover_oidc(&cached.domain).await?;
// Try to get client_id from K8s, fall back to default
let client_id = resolve_client_id().await;
let client = reqwest::Client::new();
let resp = client
.post(&discovery.token_endpoint)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &cached.refresh_token),
("client_id", &client_id),
])
.send()
.await
.ctx("Failed to refresh token")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(SunbeamError::identity(format!(
"Token refresh failed (HTTP {status}): {body}"
)));
}
let token_resp: TokenResponse = resp
.json()
.await
.ctx("Failed to parse refresh token response")?;
let expires_at = Utc::now()
+ chrono::Duration::seconds(token_resp.expires_in.unwrap_or(3600));
let new_tokens = AuthTokens {
access_token: token_resp.access_token,
refresh_token: token_resp
.refresh_token
.unwrap_or_else(|| cached.refresh_token.clone()),
expires_at,
id_token: token_resp.id_token.or_else(|| cached.id_token.clone()),
domain: cached.domain.clone(),
gitea_token: cached.gitea_token.clone(),
};
write_cache(&new_tokens)?;
Ok(new_tokens)
}
// ---------------------------------------------------------------------------
// Client ID resolution
// ---------------------------------------------------------------------------
/// Try to read the client_id from K8s secret `oidc-sunbeam-cli` in `ory` namespace.
/// Falls back to the default client ID.
async fn resolve_client_id() -> String {
// The OAuth2Client is pre-created with a known client_id matching
// DEFAULT_CLIENT_ID ("sunbeam-cli") via a pre-seeded K8s secret.
// No cluster access needed.
DEFAULT_CLIENT_ID.to_string()
}
// ---------------------------------------------------------------------------
// JWT payload decoding (minimal, no verification)
// ---------------------------------------------------------------------------
/// Decode the payload of a JWT (middle segment) without verification.
/// Returns the parsed JSON value.
fn decode_jwt_payload(token: &str) -> Result<serde_json::Value> {
let parts: Vec<&str> = token.splitn(3, '.').collect();
if parts.len() < 2 {
return Err(SunbeamError::identity("Invalid JWT: not enough segments"));
}
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[1])
.ctx("Failed to base64-decode JWT payload")?;
let payload: serde_json::Value =
serde_json::from_slice(&payload_bytes).ctx("Failed to parse JWT payload as JSON")?;
Ok(payload)
}
/// Extract the email claim from an id_token.
fn extract_email(id_token: &str) -> Option<String> {
let payload = decode_jwt_payload(id_token).ok()?;
payload
.get("email")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
// ---------------------------------------------------------------------------
// HTTP callback server
// ---------------------------------------------------------------------------
/// Parsed callback parameters from the OAuth2 redirect.
struct CallbackParams {
code: String,
#[allow(dead_code)]
state: String,
}
/// Bind a TCP listener for the OAuth2 callback, preferring ports 9876-9880.
async fn bind_callback_listener() -> Result<(tokio::net::TcpListener, u16)> {
for port in 9876..=9880 {
if let Ok(listener) = tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
return Ok((listener, port));
}
}
// Fall back to ephemeral port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.ctx("Failed to bind callback listener")?;
let port = listener.local_addr().ctx("No local address")?.port();
Ok((listener, port))
}
/// Wait for a single HTTP callback request, extract code and state, send HTML response.
async fn wait_for_callback(
listener: tokio::net::TcpListener,
expected_state: &str,
redirect_url: Option<&str>,
) -> Result<CallbackParams> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Wait up to 5 minutes for the callback, or until Ctrl+C
let accept_result = tokio::time::timeout(
std::time::Duration::from_secs(300),
listener.accept(),
)
.await
.map_err(|_| SunbeamError::identity("Login timed out (5 min). Try again with `sunbeam auth login`."))?;
let (mut stream, _) = accept_result.ctx("Failed to accept callback connection")?;
let mut buf = vec![0u8; 4096];
let n = stream
.read(&mut buf)
.await
.ctx("Failed to read callback request")?;
let request = String::from_utf8_lossy(&buf[..n]);
// Parse the GET request line: "GET /callback?code=...&state=... HTTP/1.1"
let request_line = request
.lines()
.next()
.ctx("Empty callback request")?;
let path = request_line
.split_whitespace()
.nth(1)
.ctx("No path in callback request")?;
// Parse query params
let query = path
.split('?')
.nth(1)
.ctx("No query params in callback")?;
let mut code = None;
let mut state = None;
for param in query.split('&') {
let mut kv = param.splitn(2, '=');
match (kv.next(), kv.next()) {
(Some("code"), Some(v)) => code = Some(v.to_string()),
(Some("state"), Some(v)) => state = Some(v.to_string()),
_ => {}
}
}
let code = code.ok_or_else(|| SunbeamError::identity("No 'code' in callback"))?;
let state = state.ok_or_else(|| SunbeamError::identity("No 'state' in callback"))?;
if state != expected_state {
return Err(SunbeamError::identity(
"OAuth2 state mismatch -- possible CSRF attack",
));
}
// Send success response — redirect to next step if provided, otherwise show done page
let response = if let Some(next_url) = redirect_url {
let html = format!(
"<!DOCTYPE html><html><head>\
<meta http-equiv='refresh' content='1;url={next_url}'>\
<style>\
body{{font-family:system-ui,sans-serif;display:flex;justify-content:center;\
align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}}\
.card{{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}}\
h2{{margin:0 0 1rem}}p{{color:#9ca3af}}a{{color:#d97706}}\
</style></head><body><div class='card'>\
<h2>SSO login successful</h2>\
<p>Redirecting to Gitea token setup...</p>\
<p><a href='{next_url}'>Click here if not redirected</a></p>\
</div></body></html>"
);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
)
} else {
let html = "\
<!DOCTYPE html><html><head><style>\
body{font-family:system-ui,sans-serif;display:flex;justify-content:center;\
align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}\
.card{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}\
h2{margin:0 0 1rem}p{color:#9ca3af}\
</style></head><body><div class='card'>\
<h2>Authentication successful</h2>\
<p>You can close this tab and return to the terminal.</p>\
</div></body></html>";
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
)
};
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
Ok(CallbackParams { code, state })
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Get a valid access token, refreshing if needed.
///
/// Returns the access token string ready for use in Authorization headers.
/// If no cached token exists or refresh fails, returns an error prompting
/// the user to run `sunbeam auth login`.
pub async fn get_token() -> Result<String> {
let cached = match read_cache() {
Ok(tokens) => tokens,
Err(_) => {
return Err(SunbeamError::identity(
"Not logged in. Run `sunbeam auth login` to authenticate.",
));
}
};
// Check if access token is still valid (>60s remaining)
let now = Utc::now();
if cached.expires_at > now + chrono::Duration::seconds(60) {
return Ok(cached.access_token);
}
// Try to refresh
if !cached.refresh_token.is_empty() {
match refresh_token(&cached).await {
Ok(new_tokens) => return Ok(new_tokens.access_token),
Err(e) => {
crate::output::warn(&format!("Token refresh failed: {e}"));
}
}
}
Err(SunbeamError::identity(
"Session expired. Run `sunbeam auth login` to re-authenticate.",
))
}
/// Interactive browser-based OAuth2 login.
/// SSO login — Hydra OIDC authorization code flow with PKCE.
/// `gitea_redirect`: if Some, the browser callback page auto-redirects to Gitea token page.
pub async fn cmd_auth_sso_login_with_redirect(
domain_override: Option<&str>,
gitea_redirect: Option<&str>,
) -> Result<()> {
crate::output::step("Authenticating with Hydra");
// Resolve domain: explicit flag > cached token domain > config > cluster discovery
let domain = resolve_domain(domain_override).await?;
crate::output::ok(&format!("Domain: {domain}"));
// OIDC discovery
let discovery = discover_oidc(&domain).await?;
// Resolve client_id
let client_id = resolve_client_id().await;
// Generate PKCE
let (code_verifier, code_challenge) = generate_pkce();
// Generate state
let state = generate_state();
// Bind callback listener
let (listener, port) = bind_callback_listener().await?;
let redirect_uri = format!("http://localhost:{port}/callback");
// Build authorization URL
let auth_url = format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&state={}",
discovery.authorization_endpoint,
urlencoding(&client_id),
urlencoding(&redirect_uri),
"openid%20email%20profile%20offline_access",
code_challenge,
state,
);
crate::output::ok("Opening browser for login...");
println!("\n {auth_url}\n");
// Try to open the browser
let _open_result = open_browser(&auth_url);
// Wait for callback
crate::output::ok("Waiting for authentication callback...");
let callback = wait_for_callback(listener, &state, gitea_redirect).await?;
// Exchange code for tokens
crate::output::ok("Exchanging authorization code for tokens...");
let token_resp = exchange_code(
&discovery.token_endpoint,
&callback.code,
&redirect_uri,
&client_id,
&code_verifier,
)
.await?;
let expires_at = Utc::now()
+ chrono::Duration::seconds(token_resp.expires_in.unwrap_or(3600));
let tokens = AuthTokens {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token.unwrap_or_default(),
expires_at,
id_token: token_resp.id_token.clone(),
domain: domain.clone(),
gitea_token: None,
};
// Print success with email if available
let email = tokens
.id_token
.as_ref()
.and_then(|t| extract_email(t));
if let Some(ref email) = email {
crate::output::ok(&format!("Logged in as {email}"));
} else {
crate::output::ok("Logged in successfully");
}
write_cache(&tokens)?;
Ok(())
}
/// SSO login — standalone (no redirect after callback).
pub async fn cmd_auth_sso_login(domain_override: Option<&str>) -> Result<()> {
cmd_auth_sso_login_with_redirect(domain_override, None).await
}
/// Gitea token login — opens the PAT creation page and prompts for the token.
pub async fn cmd_auth_git_login(domain_override: Option<&str>) -> Result<()> {
crate::output::step("Setting up Gitea API access");
let domain = resolve_domain(domain_override).await?;
let url = format!("https://src.{domain}/user/settings/applications");
crate::output::ok("Opening Gitea token page in your browser...");
crate::output::ok("Create a token with all scopes selected, then paste it below.");
println!("\n {url}\n");
let _ = open_browser(&url);
// Prompt for the token
eprint!(" Gitea token: ");
let mut token = String::new();
std::io::stdin()
.read_line(&mut token)
.ctx("Failed to read token from stdin")?;
let token = token.trim().to_string();
if token.is_empty() {
return Err(SunbeamError::identity("No token provided."));
}
// Verify the token works
let client = reqwest::Client::new();
let resp = client
.get(format!("https://src.{domain}/api/v1/user"))
.header("Authorization", format!("token {token}"))
.send()
.await
.ctx("Failed to verify Gitea token")?;
if !resp.status().is_success() {
return Err(SunbeamError::identity(format!(
"Gitea token is invalid (HTTP {}). Check the token and try again.",
resp.status()
)));
}
let user: serde_json::Value = resp.json().await?;
let login = user
.get("login")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
// Save to cache
let mut tokens = read_cache().unwrap_or_else(|_| AuthTokens {
access_token: String::new(),
refresh_token: String::new(),
expires_at: Utc::now(),
id_token: None,
domain: domain.clone(),
gitea_token: None,
});
tokens.gitea_token = Some(token);
if tokens.domain.is_empty() {
tokens.domain = domain;
}
write_cache(&tokens)?;
crate::output::ok(&format!("Gitea authenticated as {login}"));
Ok(())
}
/// Combined login — SSO first, then Gitea.
pub async fn cmd_auth_login_all(domain_override: Option<&str>) -> Result<()> {
// Resolve domain early so we can build the Gitea redirect URL
let domain = resolve_domain(domain_override).await?;
let gitea_url = format!("https://src.{domain}/user/settings/applications");
cmd_auth_sso_login_with_redirect(Some(&domain), Some(&gitea_url)).await?;
cmd_auth_git_login(Some(&domain)).await?;
Ok(())
}
/// Get the Gitea API token (for use by pm.rs).
pub fn get_gitea_token() -> Result<String> {
let tokens = read_cache().map_err(|_| {
SunbeamError::identity("Not logged in. Run `sunbeam auth login` first.")
})?;
tokens.gitea_token.ok_or_else(|| {
SunbeamError::identity(
"No Gitea token. Run `sunbeam auth login` or `sunbeam auth set-gitea-token <token>`.",
)
})
}
/// Remove cached auth tokens.
pub async fn cmd_auth_logout() -> Result<()> {
let path = cache_path();
if path.exists() {
std::fs::remove_file(&path)
.with_ctx(|| format!("Failed to remove {}", path.display()))?;
crate::output::ok("Logged out (cached tokens removed)");
} else {
crate::output::ok("Not logged in (no cached tokens to remove)");
}
Ok(())
}
/// Print current auth status.
pub async fn cmd_auth_status() -> Result<()> {
match read_cache() {
Ok(tokens) => {
let now = Utc::now();
let expired = tokens.expires_at <= now;
// Try to get email from id_token
let identity = tokens
.id_token
.as_deref()
.and_then(extract_email)
.unwrap_or_else(|| "unknown".to_string());
if expired {
crate::output::ok(&format!(
"Logged in as {identity} (token expired at {})",
tokens.expires_at.format("%Y-%m-%d %H:%M:%S UTC")
));
if !tokens.refresh_token.is_empty() {
crate::output::ok("Token can be refreshed automatically on next use");
}
} else {
crate::output::ok(&format!(
"Logged in as {identity} (token valid until {})",
tokens.expires_at.format("%Y-%m-%d %H:%M:%S UTC")
));
}
crate::output::ok(&format!("Domain: {}", tokens.domain));
}
Err(_) => {
crate::output::ok("Not logged in. Run `sunbeam auth login` to authenticate.");
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Utility helpers
// ---------------------------------------------------------------------------
/// Minimal percent-encoding for URL query parameters.
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push_str(&format!("%{:02X}", b));
}
}
}
out
}
/// Try to open a URL in the default browser.
fn open_browser(url: &str) -> std::result::Result<(), std::io::Error> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open").arg(url).spawn()?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open").arg(url).spawn()?;
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = url;
// No-op on unsupported platforms; URL is printed to the terminal.
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_pkce_generation() {
let (verifier, challenge) = generate_pkce();
// Verifier should be base64url-encoded 32 bytes -> 43 chars
assert_eq!(verifier.len(), 43);
// Challenge should be base64url-encoded SHA256 -> 43 chars
assert_eq!(challenge.len(), 43);
// Verify the challenge matches the verifier
let expected_hash = Sha256::digest(verifier.as_bytes());
let expected_challenge =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(expected_hash);
assert_eq!(challenge, expected_challenge);
// Two calls should produce different values
let (v2, c2) = generate_pkce();
assert_ne!(verifier, v2);
assert_ne!(challenge, c2);
}
#[test]
fn test_token_cache_roundtrip() {
let tokens = AuthTokens {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: Utc::now() + Duration::hours(1),
id_token: Some("eyJhbGciOiJSUzI1NiJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.sig".to_string()),
domain: "sunbeam.pt".to_string(),
gitea_token: None,
};
let json = serde_json::to_string_pretty(&tokens).unwrap();
let deserialized: AuthTokens = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.access_token, "access_abc");
assert_eq!(deserialized.refresh_token, "refresh_xyz");
assert_eq!(deserialized.domain, "sunbeam.pt");
assert!(deserialized.id_token.is_some());
// Verify expires_at survives roundtrip (within 1 second tolerance)
let diff = (deserialized.expires_at - tokens.expires_at)
.num_milliseconds()
.abs();
assert!(diff < 1000, "expires_at drift: {diff}ms");
}
#[test]
fn test_token_cache_roundtrip_no_id_token() {
let tokens = AuthTokens {
access_token: "access".to_string(),
refresh_token: "refresh".to_string(),
expires_at: Utc::now() + Duration::hours(1),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let json = serde_json::to_string(&tokens).unwrap();
// id_token should be absent from the JSON when None
assert!(!json.contains("id_token"));
let deserialized: AuthTokens = serde_json::from_str(&json).unwrap();
assert!(deserialized.id_token.is_none());
}
#[test]
fn test_token_expiry_check_valid() {
let tokens = AuthTokens {
access_token: "valid".to_string(),
refresh_token: "refresh".to_string(),
expires_at: Utc::now() + Duration::hours(1),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let now = Utc::now();
// Token is valid: more than 60 seconds until expiry
assert!(tokens.expires_at > now + Duration::seconds(60));
}
#[test]
fn test_token_expiry_check_expired() {
let tokens = AuthTokens {
access_token: "expired".to_string(),
refresh_token: "refresh".to_string(),
expires_at: Utc::now() - Duration::hours(1),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let now = Utc::now();
// Token is expired
assert!(tokens.expires_at <= now + Duration::seconds(60));
}
#[test]
fn test_token_expiry_check_almost_expired() {
let tokens = AuthTokens {
access_token: "almost".to_string(),
refresh_token: "refresh".to_string(),
expires_at: Utc::now() + Duration::seconds(30),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let now = Utc::now();
// Token expires in 30s, which is within the 60s threshold
assert!(tokens.expires_at <= now + Duration::seconds(60));
}
#[test]
fn test_jwt_payload_decode() {
// Build a fake JWT: header.payload.signature
let payload_json = r#"{"email":"user@example.com","sub":"12345"}"#;
let encoded_payload =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let fake_jwt = format!("eyJhbGciOiJSUzI1NiJ9.{encoded_payload}.fakesig");
let payload = decode_jwt_payload(&fake_jwt).unwrap();
assert_eq!(payload["email"], "user@example.com");
assert_eq!(payload["sub"], "12345");
}
#[test]
fn test_extract_email() {
let payload_json = r#"{"email":"alice@sunbeam.pt","name":"Alice"}"#;
let encoded_payload =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let fake_jwt = format!("eyJhbGciOiJSUzI1NiJ9.{encoded_payload}.fakesig");
assert_eq!(extract_email(&fake_jwt), Some("alice@sunbeam.pt".to_string()));
}
#[test]
fn test_extract_email_missing() {
let payload_json = r#"{"sub":"12345","name":"Bob"}"#;
let encoded_payload =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let fake_jwt = format!("eyJhbGciOiJSUzI1NiJ9.{encoded_payload}.fakesig");
assert_eq!(extract_email(&fake_jwt), None);
}
#[test]
fn test_urlencoding() {
assert_eq!(urlencoding("hello"), "hello");
assert_eq!(urlencoding("hello world"), "hello%20world");
assert_eq!(
urlencoding("http://localhost:9876/callback"),
"http%3A%2F%2Flocalhost%3A9876%2Fcallback"
);
}
#[test]
fn test_generate_state() {
let s1 = generate_state();
let s2 = generate_state();
assert_ne!(s1, s2);
// 16 bytes base64url -> 22 chars
assert_eq!(s1.len(), 22);
}
#[test]
fn test_cache_path_is_under_sunbeam() {
let path = cache_path_for_domain("sunbeam.pt");
let path_str = path.to_string_lossy();
assert!(path_str.contains("sunbeam"));
assert!(path_str.contains("auth"));
assert!(path_str.ends_with("sunbeam.pt.json"));
}
#[test]
fn test_cache_path_default_domain() {
let path = cache_path_for_domain("");
assert!(path.to_string_lossy().ends_with("default.json"));
}
}

1214
src/checks.rs Normal file

File diff suppressed because it is too large Load Diff

1097
src/cli.rs Normal file

File diff suppressed because it is too large Load Diff

461
src/cluster.rs Normal file
View File

@@ -0,0 +1,461 @@
//! Cluster lifecycle — cert-manager, Linkerd, TLS, core service readiness.
//!
//! Pure K8s implementation: no Lima VM operations.
use crate::constants::GITEA_ADMIN_USER;
use crate::error::{Result, ResultExt, SunbeamError};
use std::path::PathBuf;
const CERT_MANAGER_URL: &str =
"https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml";
const GATEWAY_API_CRDS_URL: &str =
"https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml";
fn secrets_dir() -> PathBuf {
crate::config::get_infra_dir()
.join("secrets")
.join("local")
}
// ---------------------------------------------------------------------------
// cert-manager
// ---------------------------------------------------------------------------
async fn ensure_cert_manager() -> Result<()> {
crate::output::step("cert-manager...");
if crate::kube::ns_exists("cert-manager").await? {
crate::output::ok("Already installed.");
return Ok(());
}
crate::output::ok("Installing...");
// Download and apply cert-manager YAML
let body = reqwest::get(CERT_MANAGER_URL)
.await
.ctx("Failed to download cert-manager manifest")?
.text()
.await
.ctx("Failed to read cert-manager manifest body")?;
crate::kube::kube_apply(&body).await?;
// Wait for rollout
for dep in &[
"cert-manager",
"cert-manager-webhook",
"cert-manager-cainjector",
] {
crate::output::ok(&format!("Waiting for {dep}..."));
wait_rollout("cert-manager", dep, 120).await?;
}
crate::output::ok("Installed.");
Ok(())
}
// ---------------------------------------------------------------------------
// Linkerd
// ---------------------------------------------------------------------------
async fn ensure_linkerd() -> Result<()> {
crate::output::step("Linkerd...");
if crate::kube::ns_exists("linkerd").await? {
crate::output::ok("Already installed.");
return Ok(());
}
// Gateway API CRDs
crate::output::ok("Installing Gateway API CRDs...");
let gateway_body = reqwest::get(GATEWAY_API_CRDS_URL)
.await
.ctx("Failed to download Gateway API CRDs")?
.text()
.await?;
// Gateway API CRDs require server-side apply; kube_apply already does SSA
crate::kube::kube_apply(&gateway_body).await?;
// Linkerd CRDs via subprocess (no pure HTTP source for linkerd manifests)
crate::output::ok("Installing Linkerd CRDs...");
let crds_output = tokio::process::Command::new("linkerd")
.args(["install", "--crds"])
.output()
.await
.ctx("Failed to run `linkerd install --crds`")?;
if !crds_output.status.success() {
let stderr = String::from_utf8_lossy(&crds_output.stderr);
return Err(SunbeamError::tool("linkerd", format!("install --crds failed: {stderr}")));
}
let crds = String::from_utf8_lossy(&crds_output.stdout);
crate::kube::kube_apply(&crds).await?;
// Linkerd control plane
crate::output::ok("Installing Linkerd control plane...");
let cp_output = tokio::process::Command::new("linkerd")
.args(["install"])
.output()
.await
.ctx("Failed to run `linkerd install`")?;
if !cp_output.status.success() {
let stderr = String::from_utf8_lossy(&cp_output.stderr);
return Err(SunbeamError::tool("linkerd", format!("install failed: {stderr}")));
}
let cp = String::from_utf8_lossy(&cp_output.stdout);
crate::kube::kube_apply(&cp).await?;
for dep in &[
"linkerd-identity",
"linkerd-destination",
"linkerd-proxy-injector",
] {
crate::output::ok(&format!("Waiting for {dep}..."));
wait_rollout("linkerd", dep, 120).await?;
}
crate::output::ok("Installed.");
Ok(())
}
// ---------------------------------------------------------------------------
// TLS certificate (rcgen)
// ---------------------------------------------------------------------------
async fn ensure_tls_cert(domain: &str) -> Result<()> {
crate::output::step("TLS certificate...");
let dir = secrets_dir();
let cert_path = dir.join("tls.crt");
let key_path = dir.join("tls.key");
if cert_path.exists() {
crate::output::ok(&format!("Cert exists. Domain: {domain}"));
return Ok(());
}
crate::output::ok(&format!("Generating wildcard cert for *.{domain}..."));
std::fs::create_dir_all(&dir)
.with_ctx(|| format!("Failed to create secrets dir: {}", dir.display()))?;
let subject_alt_names = vec![format!("*.{domain}")];
let mut params = rcgen::CertificateParams::new(subject_alt_names)
.map_err(|e| SunbeamError::kube(format!("Failed to create certificate params: {e}")))?;
params
.distinguished_name
.push(rcgen::DnType::CommonName, format!("*.{domain}"));
let key_pair = rcgen::KeyPair::generate()
.map_err(|e| SunbeamError::kube(format!("Failed to generate key pair: {e}")))?;
let cert = params
.self_signed(&key_pair)
.map_err(|e| SunbeamError::kube(format!("Failed to generate self-signed certificate: {e}")))?;
std::fs::write(&cert_path, cert.pem())
.with_ctx(|| format!("Failed to write {}", cert_path.display()))?;
std::fs::write(&key_path, key_pair.serialize_pem())
.with_ctx(|| format!("Failed to write {}", key_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?;
}
crate::output::ok(&format!("Cert generated. Domain: {domain}"));
Ok(())
}
// ---------------------------------------------------------------------------
// TLS secret
// ---------------------------------------------------------------------------
async fn ensure_tls_secret(domain: &str) -> Result<()> {
crate::output::step("TLS secret...");
let _ = domain; // domain used contextually above; secret uses files
crate::kube::ensure_ns("ingress").await?;
let dir = secrets_dir();
let cert_pem =
std::fs::read_to_string(dir.join("tls.crt")).ctx("Failed to read tls.crt")?;
let key_pem =
std::fs::read_to_string(dir.join("tls.key")).ctx("Failed to read tls.key")?;
// Create TLS secret via kube-rs
let client = crate::kube::get_client().await?;
let api: kube::api::Api<k8s_openapi::api::core::v1::Secret> =
kube::api::Api::namespaced(client.clone(), "ingress");
let b64_cert = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
cert_pem.as_bytes(),
);
let b64_key = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
key_pem.as_bytes(),
);
let secret_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "pingora-tls",
"namespace": "ingress",
},
"type": "kubernetes.io/tls",
"data": {
"tls.crt": b64_cert,
"tls.key": b64_key,
},
});
let pp = kube::api::PatchParams::apply("sunbeam").force();
api.patch("pingora-tls", &pp, &kube::api::Patch::Apply(secret_obj))
.await
.ctx("Failed to create TLS secret")?;
crate::output::ok("Done.");
Ok(())
}
// ---------------------------------------------------------------------------
// Wait for core
// ---------------------------------------------------------------------------
async fn wait_for_core() -> Result<()> {
crate::output::step("Waiting for core services...");
for (ns, dep) in &[("data", "valkey"), ("ory", "kratos"), ("ory", "hydra")] {
let _ = wait_rollout(ns, dep, 120).await;
}
crate::output::ok("Core services ready.");
Ok(())
}
// ---------------------------------------------------------------------------
// Print URLs
// ---------------------------------------------------------------------------
fn print_urls(domain: &str, _gitea_admin_pass: &str) {
let sep = "\u{2500}".repeat(60);
println!("\n{sep}");
println!(" Stack is up. Domain: {domain}");
println!("{sep}");
let urls: &[(&str, String)] = &[
("Auth", format!("https://auth.{domain}/")),
("Docs", format!("https://docs.{domain}/")),
("Meet", format!("https://meet.{domain}/")),
("Drive", format!("https://drive.{domain}/")),
("Chat", format!("https://chat.{domain}/")),
("Mail", format!("https://mail.{domain}/")),
("People", format!("https://people.{domain}/")),
(
"Gitea",
format!(
"https://src.{domain}/ ({GITEA_ADMIN_USER} / <from openbao>)"
),
),
];
for (name, url) in urls {
println!(" {name:<10} {url}");
}
println!();
println!(" OpenBao UI:");
println!(" kubectl --context=sunbeam -n data port-forward svc/openbao 8200:8200");
println!(" http://localhost:8200");
println!(
" token: kubectl --context=sunbeam -n data get secret openbao-keys \
-o jsonpath='{{.data.root-token}}' | base64 -d"
);
println!("{sep}\n");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Poll deployment rollout status (approximate: check Available condition).
async fn wait_rollout(ns: &str, deployment: &str, timeout_secs: u64) -> Result<()> {
use k8s_openapi::api::apps::v1::Deployment;
use std::time::{Duration, Instant};
let client = crate::kube::get_client().await?;
let api: kube::api::Api<Deployment> = kube::api::Api::namespaced(client.clone(), ns);
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop {
if Instant::now() > deadline {
return Err(SunbeamError::kube(format!("Timed out waiting for deployment {ns}/{deployment}")));
}
match api.get_opt(deployment).await? {
Some(dep) => {
if let Some(status) = &dep.status {
if let Some(conditions) = &status.conditions {
let available = conditions.iter().any(|c| {
c.type_ == "Available" && c.status == "True"
});
if available {
return Ok(());
}
}
}
}
None => {
// Deployment doesn't exist yet — keep waiting
}
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/// Full cluster bring-up (pure K8s — no Lima VM operations).
pub async fn cmd_up() -> Result<()> {
// Resolve domain from cluster state
let domain = crate::kube::get_domain().await?;
ensure_cert_manager().await?;
ensure_linkerd().await?;
ensure_tls_cert(&domain).await?;
ensure_tls_secret(&domain).await?;
// Apply manifests
crate::manifests::cmd_apply("local", &domain, "", "").await?;
// Seed secrets
crate::secrets::cmd_seed().await?;
// Gitea bootstrap
crate::gitea::cmd_bootstrap().await?;
// Mirror amd64-only images
crate::images::cmd_mirror().await?;
// Wait for core services
wait_for_core().await?;
// Get gitea admin password for URL display
let admin_pass = crate::kube::kube_get_secret_field(
"devtools",
"gitea-admin-credentials",
"password",
)
.await
.unwrap_or_default();
print_urls(&domain, &admin_pass);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cert_manager_url_points_to_github_release() {
assert!(CERT_MANAGER_URL.starts_with("https://github.com/cert-manager/cert-manager/"));
assert!(CERT_MANAGER_URL.contains("/releases/download/"));
assert!(CERT_MANAGER_URL.ends_with(".yaml"));
}
#[test]
fn cert_manager_url_has_version() {
// Verify the URL contains a version tag like v1.x.x
assert!(
CERT_MANAGER_URL.contains("/v1."),
"CERT_MANAGER_URL should reference a v1.x release"
);
}
#[test]
fn gateway_api_crds_url_points_to_github_release() {
assert!(GATEWAY_API_CRDS_URL
.starts_with("https://github.com/kubernetes-sigs/gateway-api/"));
assert!(GATEWAY_API_CRDS_URL.contains("/releases/download/"));
assert!(GATEWAY_API_CRDS_URL.ends_with(".yaml"));
}
#[test]
fn gateway_api_crds_url_has_version() {
assert!(
GATEWAY_API_CRDS_URL.contains("/v1."),
"GATEWAY_API_CRDS_URL should reference a v1.x release"
);
}
#[test]
fn secrets_dir_ends_with_secrets_local() {
let dir = secrets_dir();
assert!(
dir.ends_with("secrets/local"),
"secrets_dir() should end with secrets/local, got: {}",
dir.display()
);
}
#[test]
fn secrets_dir_has_at_least_three_components() {
let dir = secrets_dir();
let components: Vec<_> = dir.components().collect();
assert!(
components.len() >= 3,
"secrets_dir() should have at least 3 path components (base/secrets/local), got: {}",
dir.display()
);
}
#[test]
fn gitea_admin_user_constant() {
assert_eq!(GITEA_ADMIN_USER, "gitea_admin");
}
#[test]
fn print_urls_contains_expected_services() {
// Capture print_urls output by checking the URL construction logic.
// We can't easily capture stdout in unit tests, but we can verify
// the URL format matches expectations.
let domain = "test.local";
let expected_urls = [
format!("https://auth.{domain}/"),
format!("https://docs.{domain}/"),
format!("https://meet.{domain}/"),
format!("https://drive.{domain}/"),
format!("https://chat.{domain}/"),
format!("https://mail.{domain}/"),
format!("https://people.{domain}/"),
format!("https://src.{domain}/"),
];
// Verify URL patterns are valid
for url in &expected_urls {
assert!(url.starts_with("https://"));
assert!(url.contains(domain));
}
}
#[test]
fn print_urls_gitea_includes_credentials() {
let domain = "example.local";
let gitea_url = format!(
"https://src.{domain}/ ({GITEA_ADMIN_USER} / <from openbao>)"
);
assert!(gitea_url.contains(GITEA_ADMIN_USER));
assert!(gitea_url.contains("<from openbao>"));
assert!(gitea_url.contains(&format!("src.{domain}")));
}
}

404
src/config.rs Normal file
View File

@@ -0,0 +1,404 @@
use crate::error::{Result, ResultExt, SunbeamError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
// ---------------------------------------------------------------------------
// Config data model
// ---------------------------------------------------------------------------
/// Sunbeam configuration stored at ~/.sunbeam.json.
///
/// Supports kubectl-style named contexts. Each context bundles a domain,
/// kube context, SSH host, and infrastructure directory.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SunbeamConfig {
/// The active context name. If empty, uses "default".
#[serde(default, rename = "current-context")]
pub current_context: String,
/// Named contexts.
#[serde(default)]
pub contexts: HashMap<String, Context>,
// --- Legacy fields (migrated on load) ---
#[serde(default, skip_serializing_if = "String::is_empty")]
pub production_host: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub infra_directory: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub acme_email: String,
}
/// A named context — everything needed to target a specific environment.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Context {
/// The domain suffix (e.g. "sunbeam.pt", "192.168.105.3.sslip.io").
#[serde(default)]
pub domain: String,
/// Kubernetes context name (e.g. "production", "sunbeam").
#[serde(default, rename = "kube-context")]
pub kube_context: String,
/// SSH host for production tunnel (e.g. "sienna@62.210.145.138").
#[serde(default, rename = "ssh-host")]
pub ssh_host: String,
/// Infrastructure directory root.
#[serde(default, rename = "infra-dir")]
pub infra_dir: String,
/// ACME email for cert-manager.
#[serde(default, rename = "acme-email")]
pub acme_email: String,
}
// ---------------------------------------------------------------------------
// Active context (set once at startup, read everywhere)
// ---------------------------------------------------------------------------
static ACTIVE_CONTEXT: OnceLock<Context> = OnceLock::new();
/// Initialize the active context. Called once from cli::dispatch().
pub fn set_active_context(ctx: Context) {
let _ = ACTIVE_CONTEXT.set(ctx);
}
/// Get the active context. Panics if not initialized (should never happen
/// after dispatch starts).
pub fn active_context() -> &'static Context {
ACTIVE_CONTEXT.get().expect("active context not initialized")
}
/// Get the domain from the active context. Returns empty string if not set.
pub fn domain() -> &'static str {
ACTIVE_CONTEXT
.get()
.map(|c| c.domain.as_str())
.unwrap_or("")
}
// ---------------------------------------------------------------------------
// Config file I/O
// ---------------------------------------------------------------------------
fn config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".sunbeam.json")
}
/// Load configuration from ~/.sunbeam.json, return default if not found.
/// Migrates legacy flat config to context-based format.
pub fn load_config() -> SunbeamConfig {
let path = config_path();
if !path.exists() {
return SunbeamConfig::default();
}
let mut config: SunbeamConfig = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
crate::output::warn(&format!(
"Failed to parse config from {}: {e}",
path.display()
));
SunbeamConfig::default()
}),
Err(e) => {
crate::output::warn(&format!(
"Failed to read config from {}: {e}",
path.display()
));
SunbeamConfig::default()
}
};
// Migrate legacy flat fields into a "production" context
if !config.production_host.is_empty() && !config.contexts.contains_key("production") {
let domain = derive_domain_from_host(&config.production_host);
config.contexts.insert(
"production".to_string(),
Context {
domain,
kube_context: "production".to_string(),
ssh_host: config.production_host.clone(),
infra_dir: config.infra_directory.clone(),
acme_email: config.acme_email.clone(),
},
);
if config.current_context.is_empty() {
config.current_context = "production".to_string();
}
}
config
}
/// Save configuration to ~/.sunbeam.json.
pub fn save_config(config: &SunbeamConfig) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_ctx(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let content = serde_json::to_string_pretty(config)?;
std::fs::write(&path, content)
.with_ctx(|| format!("Failed to save config to {}", path.display()))?;
crate::output::ok(&format!("Configuration saved to {}", path.display()));
Ok(())
}
/// Resolve the context to use, given CLI flags and config.
///
/// Priority (same as kubectl):
/// 1. `--context` flag (explicit context name)
/// 2. `current-context` from config
/// 3. Default to "local"
pub fn resolve_context(
config: &SunbeamConfig,
_env_flag: &str,
context_override: Option<&str>,
domain_override: &str,
) -> Context {
let context_name = if let Some(explicit) = context_override {
explicit.to_string()
} else if !config.current_context.is_empty() {
config.current_context.clone()
} else {
"local".to_string()
};
let mut ctx = config
.contexts
.get(&context_name)
.cloned()
.unwrap_or_else(|| {
// Synthesize defaults for well-known names
match context_name.as_str() {
"local" => Context {
kube_context: "sunbeam".to_string(),
..Default::default()
},
"production" => Context {
kube_context: "production".to_string(),
ssh_host: config.production_host.clone(),
infra_dir: config.infra_directory.clone(),
acme_email: config.acme_email.clone(),
domain: derive_domain_from_host(&config.production_host),
..Default::default()
},
_ => Default::default(),
}
});
// CLI flags override context values
if !domain_override.is_empty() {
ctx.domain = domain_override.to_string();
}
ctx
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Derive a domain from an SSH host (e.g. "user@admin.sunbeam.pt" → "sunbeam.pt").
fn derive_domain_from_host(host: &str) -> String {
let raw = host.split('@').last().unwrap_or(host);
let raw = raw.split(':').next().unwrap_or(raw);
let parts: Vec<&str> = raw.split('.').collect();
if parts.len() >= 2 {
format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1])
} else {
String::new()
}
}
/// Get production host from config or SUNBEAM_SSH_HOST environment variable.
pub fn get_production_host() -> String {
let config = load_config();
// Check active context first
if let Some(ctx) = ACTIVE_CONTEXT.get() {
if !ctx.ssh_host.is_empty() {
return ctx.ssh_host.clone();
}
}
if !config.production_host.is_empty() {
return config.production_host;
}
std::env::var("SUNBEAM_SSH_HOST").unwrap_or_default()
}
/// Infrastructure manifests directory as a Path.
pub fn get_infra_dir() -> PathBuf {
// Check active context
if let Some(ctx) = ACTIVE_CONTEXT.get() {
if !ctx.infra_dir.is_empty() {
return PathBuf::from(&ctx.infra_dir);
}
}
let configured = load_config().infra_directory;
if !configured.is_empty() {
return PathBuf::from(configured);
}
// Dev fallback
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
.and_then(|p| {
let mut dir = p.as_path();
for _ in 0..10 {
dir = dir.parent()?;
if dir.join("infrastructure").is_dir() {
return Some(dir.join("infrastructure"));
}
}
None
})
.unwrap_or_else(|| PathBuf::from("infrastructure"))
}
/// Monorepo root directory (parent of the infrastructure directory).
pub fn get_repo_root() -> PathBuf {
get_infra_dir()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
/// Clear configuration file.
pub fn clear_config() -> Result<()> {
let path = config_path();
if path.exists() {
std::fs::remove_file(&path)
.with_ctx(|| format!("Failed to remove {}", path.display()))?;
crate::output::ok(&format!("Configuration cleared from {}", path.display()));
} else {
crate::output::warn("No configuration file found to clear");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = SunbeamConfig::default();
assert!(config.current_context.is_empty());
assert!(config.contexts.is_empty());
}
#[test]
fn test_derive_domain_from_host() {
assert_eq!(derive_domain_from_host("sienna@admin.sunbeam.pt"), "sunbeam.pt");
assert_eq!(derive_domain_from_host("user@62.210.145.138"), "145.138");
assert_eq!(derive_domain_from_host("sunbeam.pt"), "sunbeam.pt");
assert_eq!(derive_domain_from_host("localhost"), "");
}
#[test]
fn test_legacy_migration() {
let json = r#"{
"production_host": "sienna@62.210.145.138",
"infra_directory": "/path/to/infra",
"acme_email": "ops@sunbeam.pt"
}"#;
let config: SunbeamConfig = serde_json::from_str(json).unwrap();
// After load_config migration, contexts would be populated.
// Here we just test the struct deserializes legacy fields.
assert_eq!(config.production_host, "sienna@62.210.145.138");
assert!(config.contexts.is_empty()); // migration happens in load_config()
}
#[test]
fn test_context_roundtrip() {
let mut config = SunbeamConfig::default();
config.current_context = "production".to_string();
config.contexts.insert(
"production".to_string(),
Context {
domain: "sunbeam.pt".to_string(),
kube_context: "production".to_string(),
ssh_host: "sienna@server.sunbeam.pt".to_string(),
infra_dir: "/home/infra".to_string(),
acme_email: "ops@sunbeam.pt".to_string(),
},
);
let json = serde_json::to_string(&config).unwrap();
let loaded: SunbeamConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.current_context, "production");
let ctx = loaded.contexts.get("production").unwrap();
assert_eq!(ctx.domain, "sunbeam.pt");
assert_eq!(ctx.ssh_host, "sienna@server.sunbeam.pt");
}
#[test]
fn test_resolve_context_explicit_flag() {
let mut config = SunbeamConfig::default();
config.contexts.insert(
"production".to_string(),
Context {
domain: "sunbeam.pt".to_string(),
kube_context: "production".to_string(),
..Default::default()
},
);
// --context production explicitly selects the named context
let ctx = resolve_context(&config, "", Some("production"), "");
assert_eq!(ctx.domain, "sunbeam.pt");
assert_eq!(ctx.kube_context, "production");
}
#[test]
fn test_resolve_context_current_context() {
let mut config = SunbeamConfig::default();
config.current_context = "staging".to_string();
config.contexts.insert(
"staging".to_string(),
Context {
domain: "staging.example.com".to_string(),
..Default::default()
},
);
// No --context flag, uses current-context
let ctx = resolve_context(&config, "", None, "");
assert_eq!(ctx.domain, "staging.example.com");
}
#[test]
fn test_resolve_context_domain_override() {
let config = SunbeamConfig::default();
let ctx = resolve_context(&config, "", None, "custom.example.com");
assert_eq!(ctx.domain, "custom.example.com");
}
#[test]
fn test_resolve_context_defaults_local() {
let config = SunbeamConfig::default();
// No current-context, no --context flag → defaults to "local"
let ctx = resolve_context(&config, "", None, "");
assert_eq!(ctx.kube_context, "sunbeam");
}
#[test]
fn test_resolve_context_flag_overrides_current() {
let mut config = SunbeamConfig::default();
config.current_context = "staging".to_string();
config.contexts.insert(
"staging".to_string(),
Context { domain: "staging.example.com".to_string(), ..Default::default() },
);
config.contexts.insert(
"prod".to_string(),
Context { domain: "prod.example.com".to_string(), ..Default::default() },
);
// --context prod overrides current-context "staging"
let ctx = resolve_context(&config, "", Some("prod"), "");
assert_eq!(ctx.domain, "prod.example.com");
}
}

16
src/constants.rs Normal file
View File

@@ -0,0 +1,16 @@
//! Shared constants used across multiple modules.
pub const GITEA_ADMIN_USER: &str = "gitea_admin";
pub const MANAGED_NS: &[&str] = &[
"data",
"devtools",
"ingress",
"lasuite",
"matrix",
"media",
"monitoring",
"ory",
"storage",
"vault-secrets-operator",
];

365
src/error.rs Normal file
View File

@@ -0,0 +1,365 @@
//! Unified error tree for the sunbeam CLI.
//!
//! Every module returns `Result<T, SunbeamError>`. Errors bubble up to `main`,
//! which maps them to exit codes and log output.
/// Exit codes for the sunbeam CLI.
#[allow(dead_code)]
pub mod exit {
pub const SUCCESS: i32 = 0;
pub const GENERAL: i32 = 1;
pub const USAGE: i32 = 2;
pub const KUBE: i32 = 3;
pub const CONFIG: i32 = 4;
pub const NETWORK: i32 = 5;
pub const SECRETS: i32 = 6;
pub const BUILD: i32 = 7;
pub const IDENTITY: i32 = 8;
pub const EXTERNAL_TOOL: i32 = 9;
}
/// Top-level error type for the sunbeam CLI.
///
/// Each variant maps to a logical error category with its own exit code.
/// Leaf errors (io, json, yaml, kube, reqwest, etc.) are converted via `From` impls.
#[derive(Debug, thiserror::Error)]
pub enum SunbeamError {
/// Kubernetes API or cluster-related error.
#[error("{context}")]
Kube {
context: String,
#[source]
source: Option<kube::Error>,
},
/// Configuration error (missing config, invalid config, bad arguments).
#[error("{0}")]
Config(String),
/// Network/HTTP error.
#[error("{context}")]
Network {
context: String,
#[source]
source: Option<reqwest::Error>,
},
/// OpenBao / Vault error.
#[error("{0}")]
Secrets(String),
/// Image build error.
#[error("{0}")]
Build(String),
/// Identity / user management error (Kratos, Hydra).
#[error("{0}")]
Identity(String),
/// External tool error (kustomize, linkerd, buildctl, yarn, etc.).
#[error("{tool}: {detail}")]
ExternalTool { tool: String, detail: String },
/// IO error.
#[error("{context}: {source}")]
Io {
context: String,
source: std::io::Error,
},
/// JSON serialization/deserialization error.
#[error("{0}")]
Json(#[from] serde_json::Error),
/// YAML serialization/deserialization error.
#[error("{0}")]
Yaml(#[from] serde_yaml::Error),
/// Catch-all for errors that don't fit a specific category.
#[error("{0}")]
Other(String),
}
/// Convenience type alias used throughout the codebase.
pub type Result<T> = std::result::Result<T, SunbeamError>;
impl SunbeamError {
/// Map this error to a process exit code.
pub fn exit_code(&self) -> i32 {
match self {
SunbeamError::Config(_) => exit::CONFIG,
SunbeamError::Kube { .. } => exit::KUBE,
SunbeamError::Network { .. } => exit::NETWORK,
SunbeamError::Secrets(_) => exit::SECRETS,
SunbeamError::Build(_) => exit::BUILD,
SunbeamError::Identity(_) => exit::IDENTITY,
SunbeamError::ExternalTool { .. } => exit::EXTERNAL_TOOL,
SunbeamError::Io { .. } => exit::GENERAL,
SunbeamError::Json(_) => exit::GENERAL,
SunbeamError::Yaml(_) => exit::GENERAL,
SunbeamError::Other(_) => exit::GENERAL,
}
}
}
// ---------------------------------------------------------------------------
// From impls for automatic conversion
// ---------------------------------------------------------------------------
impl From<kube::Error> for SunbeamError {
fn from(e: kube::Error) -> Self {
SunbeamError::Kube {
context: e.to_string(),
source: Some(e),
}
}
}
impl From<reqwest::Error> for SunbeamError {
fn from(e: reqwest::Error) -> Self {
SunbeamError::Network {
context: e.to_string(),
source: Some(e),
}
}
}
impl From<std::io::Error> for SunbeamError {
fn from(e: std::io::Error) -> Self {
SunbeamError::Io {
context: "IO error".into(),
source: e,
}
}
}
impl From<lettre::transport::smtp::Error> for SunbeamError {
fn from(e: lettre::transport::smtp::Error) -> Self {
SunbeamError::Network {
context: format!("SMTP error: {e}"),
source: None,
}
}
}
impl From<lettre::error::Error> for SunbeamError {
fn from(e: lettre::error::Error) -> Self {
SunbeamError::Other(format!("Email error: {e}"))
}
}
impl From<base64::DecodeError> for SunbeamError {
fn from(e: base64::DecodeError) -> Self {
SunbeamError::Other(format!("Base64 decode error: {e}"))
}
}
impl From<std::string::FromUtf8Error> for SunbeamError {
fn from(e: std::string::FromUtf8Error) -> Self {
SunbeamError::Other(format!("UTF-8 error: {e}"))
}
}
// ---------------------------------------------------------------------------
// Context extension trait (replaces anyhow's .context())
// ---------------------------------------------------------------------------
/// Extension trait that adds `.ctx()` to `Result<T, E>` for adding context strings.
/// Replaces `anyhow::Context`.
pub trait ResultExt<T> {
/// Add context to an error, converting it to `SunbeamError`.
fn ctx(self, context: &str) -> Result<T>;
/// Add lazy context to an error.
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T>;
}
impl<T, E: Into<SunbeamError>> ResultExt<T> for std::result::Result<T, E> {
fn ctx(self, context: &str) -> Result<T> {
self.map_err(|e| {
let inner = e.into();
match inner {
SunbeamError::Kube { source, .. } => SunbeamError::Kube {
context: context.to_string(),
source,
},
SunbeamError::Network { source, .. } => SunbeamError::Network {
context: context.to_string(),
source,
},
SunbeamError::Io { source, .. } => SunbeamError::Io {
context: context.to_string(),
source,
},
SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")),
SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")),
SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")),
SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")),
SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool {
tool,
detail: format!("{context}: {detail}"),
},
other => SunbeamError::Other(format!("{context}: {other}")),
}
})
}
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
self.map_err(|e| {
let context = f();
let inner = e.into();
match inner {
SunbeamError::Kube { source, .. } => SunbeamError::Kube {
context,
source,
},
SunbeamError::Network { source, .. } => SunbeamError::Network {
context,
source,
},
SunbeamError::Io { source, .. } => SunbeamError::Io {
context,
source,
},
SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")),
SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")),
SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")),
SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")),
SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool {
tool,
detail: format!("{context}: {detail}"),
},
other => SunbeamError::Other(format!("{context}: {other}")),
}
})
}
}
impl<T> ResultExt<T> for Option<T> {
fn ctx(self, context: &str) -> Result<T> {
self.ok_or_else(|| SunbeamError::Other(context.to_string()))
}
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
self.ok_or_else(|| SunbeamError::Other(f()))
}
}
// ---------------------------------------------------------------------------
// Convenience constructors
// ---------------------------------------------------------------------------
impl SunbeamError {
pub fn kube(context: impl Into<String>) -> Self {
SunbeamError::Kube {
context: context.into(),
source: None,
}
}
pub fn config(msg: impl Into<String>) -> Self {
SunbeamError::Config(msg.into())
}
pub fn network(context: impl Into<String>) -> Self {
SunbeamError::Network {
context: context.into(),
source: None,
}
}
pub fn secrets(msg: impl Into<String>) -> Self {
SunbeamError::Secrets(msg.into())
}
pub fn build(msg: impl Into<String>) -> Self {
SunbeamError::Build(msg.into())
}
pub fn identity(msg: impl Into<String>) -> Self {
SunbeamError::Identity(msg.into())
}
pub fn tool(tool: impl Into<String>, detail: impl Into<String>) -> Self {
SunbeamError::ExternalTool {
tool: tool.into(),
detail: detail.into(),
}
}
}
// ---------------------------------------------------------------------------
// bail! macro replacement
// ---------------------------------------------------------------------------
/// Like anyhow::bail! but produces a SunbeamError::Other.
#[macro_export]
macro_rules! bail {
($($arg:tt)*) => {
return Err($crate::error::SunbeamError::Other(format!($($arg)*)))
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exit_codes() {
assert_eq!(SunbeamError::config("bad").exit_code(), exit::CONFIG);
assert_eq!(SunbeamError::kube("fail").exit_code(), exit::KUBE);
assert_eq!(SunbeamError::network("fail").exit_code(), exit::NETWORK);
assert_eq!(SunbeamError::secrets("fail").exit_code(), exit::SECRETS);
assert_eq!(SunbeamError::build("fail").exit_code(), exit::BUILD);
assert_eq!(SunbeamError::identity("fail").exit_code(), exit::IDENTITY);
assert_eq!(
SunbeamError::tool("kustomize", "not found").exit_code(),
exit::EXTERNAL_TOOL
);
assert_eq!(SunbeamError::Other("oops".into()).exit_code(), exit::GENERAL);
}
#[test]
fn test_display_formatting() {
let e = SunbeamError::tool("kustomize", "build failed");
assert_eq!(e.to_string(), "kustomize: build failed");
let e = SunbeamError::config("missing --domain");
assert_eq!(e.to_string(), "missing --domain");
}
#[test]
fn test_kube_from() {
// Just verify the From impl compiles and categorizes correctly
let e = SunbeamError::kube("test");
assert!(matches!(e, SunbeamError::Kube { .. }));
}
#[test]
fn test_context_extension() {
let result: std::result::Result<(), std::io::Error> =
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
let mapped = result.ctx("reading config");
assert!(mapped.is_err());
let e = mapped.unwrap_err();
assert!(e.to_string().starts_with("reading config"));
assert_eq!(e.exit_code(), exit::GENERAL); // IO maps to general
}
#[test]
fn test_option_context() {
let val: Option<i32> = None;
let result = val.ctx("value not found");
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "value not found");
}
#[test]
fn test_bail_macro() {
fn failing() -> Result<()> {
bail!("something went wrong: {}", 42);
}
let e = failing().unwrap_err();
assert_eq!(e.to_string(), "something went wrong: 42");
}
}

429
src/gitea.rs Normal file
View File

@@ -0,0 +1,429 @@
//! Gitea bootstrap -- admin setup, org creation, OIDC auth source configuration.
use crate::error::Result;
use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, ListParams};
use serde_json::Value;
use crate::kube::{get_client, get_domain, kube_exec, kube_get_secret_field};
use crate::output::{ok, step, warn};
const GITEA_ADMIN_USER: &str = "gitea_admin";
const GITEA_ADMIN_EMAIL: &str = "gitea@local.domain";
/// Bootstrap Gitea: set admin password, create orgs, configure OIDC.
pub async fn cmd_bootstrap() -> Result<()> {
let domain = get_domain().await?;
// Retrieve gitea admin password from cluster secret
let gitea_admin_pass = kube_get_secret_field("devtools", "gitea-admin-credentials", "password")
.await
.unwrap_or_default();
if gitea_admin_pass.is_empty() {
warn("gitea-admin-credentials password not found -- cannot bootstrap.");
return Ok(());
}
step("Bootstrapping Gitea...");
// Wait for a Running + Ready Gitea pod
let pod_name = wait_for_gitea_pod().await?;
let Some(pod) = pod_name else {
warn("Gitea pod not ready after 3 min -- skipping bootstrap.");
return Ok(());
};
// Set admin password
set_admin_password(&pod, &gitea_admin_pass).await?;
// Mark admin as private
mark_admin_private(&pod, &gitea_admin_pass).await?;
// Create orgs
create_orgs(&pod, &gitea_admin_pass).await?;
// Configure OIDC auth source
configure_oidc(&pod, &gitea_admin_pass).await?;
ok(&format!(
"Gitea ready -- https://src.{domain} ({GITEA_ADMIN_USER} / <from openbao>)"
));
Ok(())
}
/// Wait for a Running + Ready Gitea pod (up to 3 minutes).
async fn wait_for_gitea_pod() -> Result<Option<String>> {
let client = get_client().await?;
let pods: Api<Pod> = Api::namespaced(client.clone(), "devtools");
for _ in 0..60 {
let lp = ListParams::default().labels("app.kubernetes.io/name=gitea");
if let Ok(pod_list) = pods.list(&lp).await {
for pod in &pod_list.items {
let phase = pod
.status
.as_ref()
.and_then(|s| s.phase.as_deref())
.unwrap_or("");
if phase != "Running" {
continue;
}
let ready = pod
.status
.as_ref()
.and_then(|s| s.container_statuses.as_ref())
.and_then(|cs| cs.first())
.map(|c| c.ready)
.unwrap_or(false);
if ready {
let name = pod
.metadata
.name
.as_deref()
.unwrap_or("")
.to_string();
if !name.is_empty() {
return Ok(Some(name));
}
}
}
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
Ok(None)
}
/// Set the admin password via gitea CLI exec.
async fn set_admin_password(pod: &str, password: &str) -> Result<()> {
let (code, output) = kube_exec(
"devtools",
pod,
&[
"gitea",
"admin",
"user",
"change-password",
"--username",
GITEA_ADMIN_USER,
"--password",
password,
"--must-change-password=false",
],
Some("gitea"),
)
.await?;
if code == 0 || output.to_lowercase().contains("password") {
ok(&format!("Admin '{GITEA_ADMIN_USER}' password set."));
} else {
warn(&format!("change-password: {output}"));
}
Ok(())
}
/// Call Gitea API via kubectl exec + curl inside the pod.
async fn gitea_api(
pod: &str,
method: &str,
path: &str,
password: &str,
data: Option<&Value>,
) -> Result<Value> {
let url = format!("http://localhost:3000/api/v1{path}");
let auth = format!("{GITEA_ADMIN_USER}:{password}");
let mut args = vec![
"curl", "-s", "-X", method, &url, "-H", "Content-Type: application/json", "-u", &auth,
];
let data_str;
if let Some(d) = data {
data_str = serde_json::to_string(d)?;
args.push("-d");
args.push(&data_str);
}
let (_, stdout) = kube_exec("devtools", pod, &args, Some("gitea")).await?;
Ok(serde_json::from_str(&stdout).unwrap_or(Value::Object(Default::default())))
}
/// Mark the admin account as private.
async fn mark_admin_private(pod: &str, password: &str) -> Result<()> {
let data = serde_json::json!({
"source_id": 0,
"login_name": GITEA_ADMIN_USER,
"email": GITEA_ADMIN_EMAIL,
"visibility": "private",
});
let result = gitea_api(
pod,
"PATCH",
&format!("/admin/users/{GITEA_ADMIN_USER}"),
password,
Some(&data),
)
.await?;
if result.get("login").and_then(|v| v.as_str()) == Some(GITEA_ADMIN_USER) {
ok(&format!("Admin '{GITEA_ADMIN_USER}' marked as private."));
} else {
warn(&format!("Could not set admin visibility: {result}"));
}
Ok(())
}
/// Create the studio and internal organizations.
async fn create_orgs(pod: &str, password: &str) -> Result<()> {
let orgs = [
("studio", "public", "Public source code"),
("internal", "private", "Internal tools and services"),
];
for (org_name, visibility, desc) in &orgs {
let data = serde_json::json!({
"username": org_name,
"visibility": visibility,
"description": desc,
});
let result = gitea_api(pod, "POST", "/orgs", password, Some(&data)).await?;
if result.get("id").is_some() {
ok(&format!("Created org '{org_name}'."));
} else if result
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase()
.contains("already")
{
ok(&format!("Org '{org_name}' already exists."));
} else {
let msg = result
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{result}"));
warn(&format!("Org '{org_name}': {msg}"));
}
}
Ok(())
}
/// Configure Hydra as the OIDC authentication source.
async fn configure_oidc(pod: &str, _password: &str) -> Result<()> {
// List existing auth sources
let (_, auth_list_output) =
kube_exec("devtools", pod, &["gitea", "admin", "auth", "list"], Some("gitea")).await?;
let mut existing_id: Option<String> = None;
let mut exact_ok = false;
for line in auth_list_output.lines().skip(1) {
// Tab-separated: ID\tName\tType\tEnabled
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 2 {
continue;
}
let src_id = parts[0].trim();
let src_name = parts[1].trim();
if src_name == "Sunbeam" {
exact_ok = true;
break;
}
let src_type = if parts.len() > 2 {
parts[2].trim()
} else {
""
};
if src_name == "Sunbeam Auth"
|| (src_name.starts_with("Sunbeam") && src_type == "OAuth2")
{
existing_id = Some(src_id.to_string());
}
}
if exact_ok {
ok("OIDC auth source 'Sunbeam' already present.");
return Ok(());
}
if let Some(eid) = existing_id {
// Wrong name -- rename in-place
let (code, stderr) = kube_exec(
"devtools",
pod,
&[
"gitea",
"admin",
"auth",
"update-oauth",
"--id",
&eid,
"--name",
"Sunbeam",
],
Some("gitea"),
)
.await?;
if code == 0 {
ok(&format!(
"Renamed OIDC auth source (id={eid}) to 'Sunbeam'."
));
} else {
warn(&format!("Rename failed: {stderr}"));
}
return Ok(());
}
// Create new OIDC auth source
let oidc_id = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_ID").await;
let oidc_secret = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_SECRET").await;
match (oidc_id, oidc_secret) {
(Ok(oidc_id), Ok(oidc_sec)) => {
let discover_url =
"http://hydra-public.ory.svc.cluster.local:4444/.well-known/openid-configuration";
let (code, stderr) = kube_exec(
"devtools",
pod,
&[
"gitea",
"admin",
"auth",
"add-oauth",
"--name",
"Sunbeam",
"--provider",
"openidConnect",
"--key",
&oidc_id,
"--secret",
&oidc_sec,
"--auto-discover-url",
discover_url,
"--scopes",
"openid",
"--scopes",
"email",
"--scopes",
"profile",
],
Some("gitea"),
)
.await?;
if code == 0 {
ok("OIDC auth source 'Sunbeam' configured.");
} else {
warn(&format!("OIDC auth source config failed: {stderr}"));
}
}
_ => {
warn("oidc-gitea secret not found -- OIDC auth source not configured.");
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_constants() {
assert_eq!(GITEA_ADMIN_USER, "gitea_admin");
assert_eq!(GITEA_ADMIN_EMAIL, "gitea@local.domain");
}
#[test]
fn test_org_definitions() {
// Verify the org configs match the Python version
let orgs = [
("studio", "public", "Public source code"),
("internal", "private", "Internal tools and services"),
];
assert_eq!(orgs[0].0, "studio");
assert_eq!(orgs[0].1, "public");
assert_eq!(orgs[1].0, "internal");
assert_eq!(orgs[1].1, "private");
}
#[test]
fn test_parse_auth_list_output() {
let output = "ID\tName\tType\tEnabled\n1\tSunbeam\tOAuth2\ttrue\n";
let mut found = false;
for line in output.lines().skip(1) {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 && parts[1].trim() == "Sunbeam" {
found = true;
}
}
assert!(found);
}
#[test]
fn test_parse_auth_list_rename_needed() {
let output = "ID\tName\tType\tEnabled\n5\tSunbeam Auth\tOAuth2\ttrue\n";
let mut rename_id: Option<String> = None;
for line in output.lines().skip(1) {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let name = parts[1].trim();
let typ = parts[2].trim();
if name == "Sunbeam Auth" || (name.starts_with("Sunbeam") && typ == "OAuth2") {
rename_id = Some(parts[0].trim().to_string());
}
}
}
assert_eq!(rename_id, Some("5".to_string()));
}
#[test]
fn test_gitea_api_response_parsing() {
// Simulate a successful org creation response
let json_str = r#"{"id": 1, "username": "studio"}"#;
let val: Value = serde_json::from_str(json_str).unwrap();
assert!(val.get("id").is_some());
// Simulate an "already exists" response
let json_str = r#"{"message": "organization already exists"}"#;
let val: Value = serde_json::from_str(json_str).unwrap();
assert!(val
.get("message")
.unwrap()
.as_str()
.unwrap()
.to_lowercase()
.contains("already"));
}
#[test]
fn test_admin_visibility_patch_body() {
let data = serde_json::json!({
"source_id": 0,
"login_name": GITEA_ADMIN_USER,
"email": GITEA_ADMIN_EMAIL,
"visibility": "private",
});
assert_eq!(data["login_name"], "gitea_admin");
assert_eq!(data["visibility"], "private");
}
}

1809
src/images.rs Normal file

File diff suppressed because it is too large Load Diff

758
src/kube.rs Normal file
View File

@@ -0,0 +1,758 @@
use crate::error::{Result, SunbeamError, ResultExt};
use base64::Engine;
use k8s_openapi::api::apps::v1::Deployment;
use k8s_openapi::api::core::v1::{Namespace, Secret};
use kube::api::{Api, ApiResource, DynamicObject, ListParams, Patch, PatchParams};
use kube::config::{KubeConfigOptions, Kubeconfig};
use kube::discovery::{self, Scope};
use kube::{Client, Config};
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::sync::{Mutex, OnceLock};
use tokio::sync::OnceCell;
static CONTEXT: OnceLock<String> = OnceLock::new();
static SSH_HOST: OnceLock<String> = OnceLock::new();
static KUBE_CLIENT: OnceCell<Client> = OnceCell::const_new();
static SSH_TUNNEL: Mutex<Option<tokio::process::Child>> = Mutex::new(None);
static API_DISCOVERY: OnceCell<kube::discovery::Discovery> = OnceCell::const_new();
/// Set the active kubectl context and optional SSH host for production tunnel.
pub fn set_context(ctx: &str, ssh_host: &str) {
let _ = CONTEXT.set(ctx.to_string());
let _ = SSH_HOST.set(ssh_host.to_string());
}
/// Get the active context.
pub fn context() -> &'static str {
CONTEXT.get().map(|s| s.as_str()).unwrap_or("sunbeam")
}
/// Get the SSH host (empty for local).
pub fn ssh_host() -> &'static str {
SSH_HOST.get().map(|s| s.as_str()).unwrap_or("")
}
// ---------------------------------------------------------------------------
// SSH tunnel management
// ---------------------------------------------------------------------------
/// Ensure SSH tunnel is open for production (forwards localhost:16443 -> remote:6443).
/// For local dev (empty ssh_host), this is a no-op.
#[allow(dead_code)]
pub async fn ensure_tunnel() -> Result<()> {
let host = ssh_host();
if host.is_empty() {
return Ok(());
}
// Check if tunnel is already open
if tokio::net::TcpStream::connect("127.0.0.1:16443")
.await
.is_ok()
{
return Ok(());
}
crate::output::ok(&format!("Opening SSH tunnel to {host}..."));
let child = tokio::process::Command::new("ssh")
.args([
"-p",
"2222",
"-L",
"16443:127.0.0.1:6443",
"-N",
"-o",
"ExitOnForwardFailure=yes",
"-o",
"StrictHostKeyChecking=no",
host,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ctx("Failed to spawn SSH tunnel")?;
// Store child so it lives for the process lifetime (and can be killed on cleanup)
if let Ok(mut guard) = SSH_TUNNEL.lock() {
*guard = Some(child);
}
// Wait for tunnel to become available
for _ in 0..20 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if tokio::net::TcpStream::connect("127.0.0.1:16443")
.await
.is_ok()
{
return Ok(());
}
}
bail!("SSH tunnel to {host} did not open in time")
}
// ---------------------------------------------------------------------------
// Client initialization
// ---------------------------------------------------------------------------
/// Get or create a kube::Client configured for the active context.
/// Opens SSH tunnel first if needed for production.
pub async fn get_client() -> Result<&'static Client> {
KUBE_CLIENT
.get_or_try_init(|| async {
ensure_tunnel().await?;
let kubeconfig = Kubeconfig::read().map_err(|e| SunbeamError::kube(format!("Failed to read kubeconfig: {e}")))?;
let options = KubeConfigOptions {
context: Some(context().to_string()),
..Default::default()
};
let config = Config::from_custom_kubeconfig(kubeconfig, &options)
.await
.map_err(|e| SunbeamError::kube(format!("Failed to build kube config from kubeconfig: {e}")))?;
Client::try_from(config).ctx("Failed to create kube client")
})
.await
}
// ---------------------------------------------------------------------------
// Core Kubernetes operations
// ---------------------------------------------------------------------------
/// Server-side apply a multi-document YAML manifest.
#[allow(dead_code)]
pub async fn kube_apply(manifest: &str) -> Result<()> {
let client = get_client().await?;
let ssapply = PatchParams::apply("sunbeam").force();
for doc in manifest.split("\n---") {
let doc = doc.trim();
if doc.is_empty() || doc == "---" {
continue;
}
// Parse the YAML to a DynamicObject so we can route it
let obj: serde_yaml::Value =
serde_yaml::from_str(doc).ctx("Failed to parse YAML document")?;
let api_version = obj
.get("apiVersion")
.and_then(|v| v.as_str())
.unwrap_or("");
let kind = obj.get("kind").and_then(|v| v.as_str()).unwrap_or("");
let metadata = obj.get("metadata");
let name = metadata
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("");
let namespace = metadata
.and_then(|m| m.get("namespace"))
.and_then(|v| v.as_str());
if name.is_empty() || kind.is_empty() {
continue; // skip incomplete documents
}
// Use discovery to find the right API resource
let (ar, scope) = resolve_api_resource(client, api_version, kind).await?;
let api: Api<DynamicObject> = if let Some(ns) = namespace {
Api::namespaced_with(client.clone(), ns, &ar)
} else if scope == Scope::Namespaced {
// Namespaced resource without a namespace specified; use default
Api::default_namespaced_with(client.clone(), &ar)
} else {
Api::all_with(client.clone(), &ar)
};
let patch: serde_json::Value =
serde_yaml::from_str(doc).ctx("Failed to parse YAML to JSON value")?;
api.patch(name, &ssapply, &Patch::Apply(patch))
.await
.with_ctx(|| format!("Failed to apply {kind}/{name}"))?;
}
Ok(())
}
/// Resolve an API resource from apiVersion and kind using discovery.
async fn resolve_api_resource(
client: &Client,
api_version: &str,
kind: &str,
) -> Result<(ApiResource, Scope)> {
// Split apiVersion into group and version
let (group, version) = if api_version.contains('/') {
let parts: Vec<&str> = api_version.splitn(2, '/').collect();
(parts[0], parts[1])
} else {
("", api_version) // core API group
};
let disc = API_DISCOVERY
.get_or_try_init(|| async {
discovery::Discovery::new(client.clone())
.run()
.await
.ctx("API discovery failed")
})
.await?;
for api_group in disc.groups() {
if api_group.name() == group {
for (ar, caps) in api_group.resources_by_stability() {
if ar.kind == kind && ar.version == version {
return Ok((ar, caps.scope));
}
}
}
}
bail!("Could not discover API resource for {api_version}/{kind}")
}
/// Get a Kubernetes Secret object.
#[allow(dead_code)]
pub async fn kube_get_secret(ns: &str, name: &str) -> Result<Option<Secret>> {
let client = get_client().await?;
let api: Api<Secret> = Api::namespaced(client.clone(), ns);
match api.get_opt(name).await {
Ok(secret) => Ok(secret),
Err(e) => Err(e).with_ctx(|| format!("Failed to get secret {ns}/{name}")),
}
}
/// Get a specific base64-decoded field from a Kubernetes secret.
#[allow(dead_code)]
pub async fn kube_get_secret_field(ns: &str, name: &str, key: &str) -> Result<String> {
let secret = kube_get_secret(ns, name)
.await?
.with_ctx(|| format!("Secret {ns}/{name} not found"))?;
let data = secret.data.as_ref().ctx("Secret has no data")?;
let bytes = data
.get(key)
.with_ctx(|| format!("Key {key:?} not found in secret {ns}/{name}"))?;
String::from_utf8(bytes.0.clone())
.with_ctx(|| format!("Key {key:?} in secret {ns}/{name} is not valid UTF-8"))
}
/// Check if a namespace exists.
#[allow(dead_code)]
pub async fn ns_exists(ns: &str) -> Result<bool> {
let client = get_client().await?;
let api: Api<Namespace> = Api::all(client.clone());
match api.get_opt(ns).await {
Ok(Some(_)) => Ok(true),
Ok(None) => Ok(false),
Err(e) => Err(e).with_ctx(|| format!("Failed to check namespace {ns}")),
}
}
/// Create namespace if it does not exist.
#[allow(dead_code)]
pub async fn ensure_ns(ns: &str) -> Result<()> {
if ns_exists(ns).await? {
return Ok(());
}
let client = get_client().await?;
let api: Api<Namespace> = Api::all(client.clone());
let ns_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Namespace",
"metadata": { "name": ns }
});
let pp = PatchParams::apply("sunbeam").force();
api.patch(ns, &pp, &Patch::Apply(ns_obj))
.await
.with_ctx(|| format!("Failed to create namespace {ns}"))?;
Ok(())
}
/// Create or update a generic Kubernetes secret via server-side apply.
#[allow(dead_code)]
pub async fn create_secret(ns: &str, name: &str, data: HashMap<String, String>) -> Result<()> {
let client = get_client().await?;
let api: Api<Secret> = Api::namespaced(client.clone(), ns);
// Encode values as base64
let mut encoded: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
for (k, v) in &data {
let b64 = base64::engine::general_purpose::STANDARD.encode(v.as_bytes());
encoded.insert(k.clone(), serde_json::Value::String(b64));
}
let secret_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": name,
"namespace": ns,
},
"type": "Opaque",
"data": encoded,
});
let pp = PatchParams::apply("sunbeam").force();
api.patch(name, &pp, &Patch::Apply(secret_obj))
.await
.with_ctx(|| format!("Failed to create/update secret {ns}/{name}"))?;
Ok(())
}
/// Execute a command in a pod and return (exit_code, stdout).
#[allow(dead_code)]
pub async fn kube_exec(
ns: &str,
pod: &str,
cmd: &[&str],
container: Option<&str>,
) -> Result<(i32, String)> {
let client = get_client().await?;
let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::namespaced(client.clone(), ns);
let mut ep = kube::api::AttachParams::default();
ep.stdout = true;
ep.stderr = true;
ep.stdin = false;
if let Some(c) = container {
ep.container = Some(c.to_string());
}
let cmd_strings: Vec<String> = cmd.iter().map(|s| s.to_string()).collect();
let mut attached = pods
.exec(pod, cmd_strings, &ep)
.await
.with_ctx(|| format!("Failed to exec in pod {ns}/{pod}"))?;
let stdout = {
let mut stdout_reader = attached
.stdout()
.ctx("No stdout stream from exec")?;
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut stdout_reader, &mut buf).await?;
String::from_utf8_lossy(&buf).to_string()
};
let status = attached
.take_status()
.ctx("No status channel from exec")?;
// Wait for the status
let exit_code = if let Some(status) = status.await {
status
.status
.map(|s| if s == "Success" { 0 } else { 1 })
.unwrap_or(1)
} else {
1
};
Ok((exit_code, stdout.trim().to_string()))
}
/// Patch a deployment to trigger a rollout restart.
#[allow(dead_code)]
pub async fn kube_rollout_restart(ns: &str, deployment: &str) -> Result<()> {
let client = get_client().await?;
let api: Api<Deployment> = Api::namespaced(client.clone(), ns);
let now = chrono::Utc::now().to_rfc3339();
let patch = serde_json::json!({
"spec": {
"template": {
"metadata": {
"annotations": {
"kubectl.kubernetes.io/restartedAt": now
}
}
}
}
});
api.patch(deployment, &PatchParams::default(), &Patch::Strategic(patch))
.await
.with_ctx(|| format!("Failed to restart deployment {ns}/{deployment}"))?;
Ok(())
}
/// Discover the active domain from cluster state.
///
/// Tries the gitea-inline-config secret first (DOMAIN=src.<domain>),
/// falls back to lasuite-oidc-provider configmap, then Lima VM IP.
#[allow(dead_code)]
pub async fn get_domain() -> Result<String> {
// 1. Gitea inline-config secret
if let Ok(Some(secret)) = kube_get_secret("devtools", "gitea-inline-config").await {
if let Some(data) = &secret.data {
if let Some(server_bytes) = data.get("server") {
let server_ini = String::from_utf8_lossy(&server_bytes.0);
for line in server_ini.lines() {
if let Some(rest) = line.strip_prefix("DOMAIN=src.") {
return Ok(rest.trim().to_string());
}
}
}
}
}
// 2. Fallback: lasuite-oidc-provider configmap
{
let client = get_client().await?;
let api: Api<k8s_openapi::api::core::v1::ConfigMap> =
Api::namespaced(client.clone(), "lasuite");
if let Ok(Some(cm)) = api.get_opt("lasuite-oidc-provider").await {
if let Some(data) = &cm.data {
if let Some(endpoint) = data.get("OIDC_OP_JWKS_ENDPOINT") {
if let Some(rest) = endpoint.split("https://auth.").nth(1) {
if let Some(domain) = rest.split('/').next() {
return Ok(domain.to_string());
}
}
}
}
}
}
// 3. Local dev fallback: Lima VM IP
let ip = get_lima_ip().await;
Ok(format!("{ip}.sslip.io"))
}
/// Get the socket_vmnet IP of the Lima sunbeam VM.
async fn get_lima_ip() -> String {
let output = tokio::process::Command::new("limactl")
.args(["shell", "sunbeam", "ip", "-4", "addr", "show", "eth1"])
.output()
.await;
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if line.contains("inet ") {
if let Some(addr) = line.trim().split_whitespace().nth(1) {
if let Some(ip) = addr.split('/').next() {
return ip.to_string();
}
}
}
}
}
// Fallback: hostname -I
let output2 = tokio::process::Command::new("limactl")
.args(["shell", "sunbeam", "hostname", "-I"])
.output()
.await;
if let Ok(out) = output2 {
let stdout = String::from_utf8_lossy(&out.stdout);
let ips: Vec<&str> = stdout.trim().split_whitespace().collect();
if ips.len() >= 2 {
return ips[ips.len() - 1].to_string();
} else if !ips.is_empty() {
return ips[0].to_string();
}
}
String::new()
}
// ---------------------------------------------------------------------------
// kustomize build
// ---------------------------------------------------------------------------
/// Run kustomize build --enable-helm and apply domain/email substitution.
#[allow(dead_code)]
pub async fn kustomize_build(overlay: &Path, domain: &str, email: &str) -> Result<String> {
let kustomize_path = crate::tools::ensure_kustomize()?;
let helm_path = crate::tools::ensure_helm()?;
// Ensure helm's parent dir is on PATH so kustomize can find it
let helm_dir = helm_path
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let mut env_path = helm_dir.clone();
if let Ok(existing) = std::env::var("PATH") {
env_path = format!("{helm_dir}:{existing}");
}
let output = tokio::process::Command::new(&kustomize_path)
.args(["build", "--enable-helm"])
.arg(overlay)
.env("PATH", &env_path)
.output()
.await
.ctx("Failed to run kustomize")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("kustomize build failed: {stderr}");
}
let mut text = String::from_utf8(output.stdout).ctx("kustomize output not UTF-8")?;
// Domain substitution
text = domain_replace(&text, domain);
// ACME email substitution
if !email.is_empty() {
text = text.replace("ACME_EMAIL", email);
}
// Registry host IP resolution
if text.contains("REGISTRY_HOST_IP") {
let registry_ip = resolve_registry_ip(domain).await;
text = text.replace("REGISTRY_HOST_IP", &registry_ip);
}
// Strip null annotations artifact
text = text.replace("\n annotations: null", "");
Ok(text)
}
/// Resolve the registry host IP for REGISTRY_HOST_IP substitution.
async fn resolve_registry_ip(domain: &str) -> String {
// Try DNS for src.<domain>
let hostname = format!("src.{domain}:443");
if let Ok(mut addrs) = tokio::net::lookup_host(&hostname).await {
if let Some(addr) = addrs.next() {
return addr.ip().to_string();
}
}
// Fallback: derive from production host config
let ssh_host = crate::config::get_production_host();
if !ssh_host.is_empty() {
let raw = ssh_host
.split('@')
.last()
.unwrap_or(&ssh_host)
.split(':')
.next()
.unwrap_or(&ssh_host);
let host_lookup = format!("{raw}:443");
if let Ok(mut addrs) = tokio::net::lookup_host(&host_lookup).await {
if let Some(addr) = addrs.next() {
return addr.ip().to_string();
}
}
// raw is likely already an IP
return raw.to_string();
}
String::new()
}
// ---------------------------------------------------------------------------
// kubectl / bao passthrough
// ---------------------------------------------------------------------------
/// Transparent kubectl passthrough for the active context.
pub async fn cmd_k8s(kubectl_args: &[String]) -> Result<()> {
ensure_tunnel().await?;
let status = tokio::process::Command::new("kubectl")
.arg(format!("--context={}", context()))
.args(kubectl_args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await
.ctx("Failed to run kubectl")?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
/// Run bao CLI inside the OpenBao pod with the root token.
pub async fn cmd_bao(bao_args: &[String]) -> Result<()> {
// Find the openbao pod
let client = get_client().await?;
let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::namespaced(client.clone(), "data");
let lp = ListParams::default().labels("app.kubernetes.io/name=openbao");
let pod_list = pods.list(&lp).await.ctx("Failed to list OpenBao pods")?;
let ob_pod = pod_list
.items
.first()
.and_then(|p| p.metadata.name.as_deref())
.ctx("OpenBao pod not found -- is the cluster running?")?
.to_string();
// Get root token
let root_token = kube_get_secret_field("data", "openbao-keys", "root-token")
.await
.ctx("root-token not found in openbao-keys secret")?;
// Build the exec command using env to set VAULT_TOKEN without shell interpretation
let vault_token_env = format!("VAULT_TOKEN={root_token}");
let mut kubectl_args = vec![
format!("--context={}", context()),
"-n".to_string(),
"data".to_string(),
"exec".to_string(),
ob_pod,
"-c".to_string(),
"openbao".to_string(),
"--".to_string(),
"env".to_string(),
vault_token_env,
"bao".to_string(),
];
kubectl_args.extend(bao_args.iter().cloned());
// Use kubectl for full TTY support
let status = tokio::process::Command::new("kubectl")
.args(&kubectl_args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await
.ctx("Failed to run bao in OpenBao pod")?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Parse target and domain_replace (already tested)
// ---------------------------------------------------------------------------
/// Parse 'ns/name' -> (Some(ns), Some(name)), 'ns' -> (Some(ns), None), None -> (None, None).
pub fn parse_target(s: Option<&str>) -> Result<(Option<&str>, Option<&str>)> {
match s {
None => Ok((None, None)),
Some(s) => {
let parts: Vec<&str> = s.splitn(3, '/').collect();
match parts.len() {
1 => Ok((Some(parts[0]), None)),
2 => Ok((Some(parts[0]), Some(parts[1]))),
_ => bail!("Invalid target {s:?}: expected 'namespace' or 'namespace/name'"),
}
}
}
}
/// Replace all occurrences of DOMAIN_SUFFIX with domain.
pub fn domain_replace(text: &str, domain: &str) -> String {
text.replace("DOMAIN_SUFFIX", domain)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_target_none() {
let (ns, name) = parse_target(None).unwrap();
assert!(ns.is_none());
assert!(name.is_none());
}
#[test]
fn test_parse_target_namespace_only() {
let (ns, name) = parse_target(Some("ory")).unwrap();
assert_eq!(ns, Some("ory"));
assert!(name.is_none());
}
#[test]
fn test_parse_target_namespace_and_name() {
let (ns, name) = parse_target(Some("ory/kratos")).unwrap();
assert_eq!(ns, Some("ory"));
assert_eq!(name, Some("kratos"));
}
#[test]
fn test_parse_target_too_many_parts() {
assert!(parse_target(Some("too/many/parts")).is_err());
}
#[test]
fn test_parse_target_empty_string() {
let (ns, name) = parse_target(Some("")).unwrap();
assert_eq!(ns, Some(""));
assert!(name.is_none());
}
#[test]
fn test_domain_replace_single() {
let result = domain_replace("src.DOMAIN_SUFFIX/foo", "192.168.1.1.sslip.io");
assert_eq!(result, "src.192.168.1.1.sslip.io/foo");
}
#[test]
fn test_domain_replace_multiple() {
let result = domain_replace("DOMAIN_SUFFIX and DOMAIN_SUFFIX", "x.sslip.io");
assert_eq!(result, "x.sslip.io and x.sslip.io");
}
#[test]
fn test_domain_replace_none() {
let result = domain_replace("no match here", "x.sslip.io");
assert_eq!(result, "no match here");
}
#[tokio::test]
async fn test_ensure_tunnel_noop_when_ssh_host_empty() {
// When ssh_host is empty (local dev), ensure_tunnel should return Ok
// immediately without spawning any SSH process.
// SSH_HOST OnceLock may already be set from another test, but the
// default (unset) value is "" which is what we want. If it was set
// to a non-empty value by a prior test in the same process, this
// test would attempt a real SSH connection and fail — that is acceptable
// as a signal that test isolation changed.
//
// In a fresh test binary SSH_HOST is unset, so ssh_host() returns "".
let result = ensure_tunnel().await;
assert!(result.is_ok(), "ensure_tunnel should be a no-op when ssh_host is empty");
}
#[test]
fn test_create_secret_data_encoding() {
// Test that we can build the expected JSON structure for secret creation
let mut data = HashMap::new();
data.insert("username".to_string(), "admin".to_string());
data.insert("password".to_string(), "s3cret".to_string());
let mut encoded: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
for (k, v) in &data {
let b64 = base64::engine::general_purpose::STANDARD.encode(v.as_bytes());
encoded.insert(k.clone(), serde_json::Value::String(b64));
}
let secret_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "test-secret",
"namespace": "default",
},
"type": "Opaque",
"data": encoded,
});
let json_str = serde_json::to_string(&secret_obj).unwrap();
assert!(json_str.contains("YWRtaW4=")); // base64("admin")
assert!(json_str.contains("czNjcmV0")); // base64("s3cret")
}
}

59
src/main.rs Normal file
View File

@@ -0,0 +1,59 @@
#[macro_use]
mod error;
mod auth;
mod checks;
mod cli;
mod cluster;
mod constants;
mod config;
mod gitea;
mod images;
mod kube;
mod manifests;
mod openbao;
mod output;
mod pm;
mod secrets;
mod services;
mod tools;
mod update;
mod users;
#[tokio::main]
async fn main() {
// Install rustls crypto provider (ring) before any TLS operations.
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
// Initialize tracing subscriber.
// Respects RUST_LOG env var (e.g. RUST_LOG=debug, RUST_LOG=sunbeam=trace).
// Default: warn for dependencies, info for sunbeam.
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("sunbeam=info,warn")
}),
)
.with_target(false)
.with_writer(std::io::stderr)
.init();
match cli::dispatch().await {
Ok(()) => {}
Err(e) => {
let code = e.exit_code();
tracing::error!("{e}");
// Print source chain for non-trivial errors
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
tracing::debug!("caused by: {cause}");
source = std::error::Error::source(cause);
}
std::process::exit(code);
}
}
}

880
src/manifests.rs Normal file
View File

@@ -0,0 +1,880 @@
use crate::error::Result;
use crate::constants::MANAGED_NS;
/// Return only the YAML documents that belong to the given namespace.
pub fn filter_by_namespace(manifests: &str, namespace: &str) -> String {
let mut kept = Vec::new();
for doc in manifests.split("\n---") {
let doc = doc.trim();
if doc.is_empty() {
continue;
}
let has_ns = doc.contains(&format!("namespace: {namespace}"));
let is_ns_resource =
doc.contains("kind: Namespace") && doc.contains(&format!("name: {namespace}"));
if has_ns || is_ns_resource {
kept.push(doc);
}
}
if kept.is_empty() {
return String::new();
}
format!("---\n{}\n", kept.join("\n---\n"))
}
/// Build kustomize overlay for env, substitute domain/email, apply via kube-rs.
///
/// Runs a second convergence pass if cert-manager is present in the overlay —
/// cert-manager registers a ValidatingWebhook that must be running before
/// ClusterIssuer / Certificate resources can be created.
pub async fn cmd_apply(env: &str, domain: &str, email: &str, namespace: &str) -> Result<()> {
// Fall back to config for ACME email if not provided via CLI flag.
let email = if email.is_empty() {
crate::config::load_config().acme_email
} else {
email.to_string()
};
let infra_dir = crate::config::get_infra_dir();
let (resolved_domain, overlay) = if env == "production" {
let d = if domain.is_empty() {
crate::kube::get_domain().await?
} else {
domain.to_string()
};
if d.is_empty() {
bail!("--domain is required for production apply on first deploy");
}
let overlay = infra_dir.join("overlays").join("production");
(d, overlay)
} else {
// Local: discover domain from Lima IP
let d = crate::kube::get_domain().await?;
let overlay = infra_dir.join("overlays").join("local");
(d, overlay)
};
let scope = if namespace.is_empty() {
String::new()
} else {
format!(" [{namespace}]")
};
crate::output::step(&format!(
"Applying manifests (env: {env}, domain: {resolved_domain}){scope}..."
));
if env == "local" {
apply_mkcert_ca_configmap().await;
}
let ns_list = if namespace.is_empty() {
None
} else {
Some(vec![namespace.to_string()])
};
pre_apply_cleanup(ns_list.as_deref()).await;
let before = snapshot_configmaps().await;
let mut manifests =
crate::kube::kustomize_build(&overlay, &resolved_domain, &email).await?;
if !namespace.is_empty() {
manifests = filter_by_namespace(&manifests, namespace);
if manifests.trim().is_empty() {
crate::output::warn(&format!(
"No resources found for namespace '{namespace}' -- check the name and try again."
));
return Ok(());
}
}
// First pass: may emit errors for resources that depend on webhooks not yet running
if let Err(e) = crate::kube::kube_apply(&manifests).await {
crate::output::warn(&format!("First apply pass had errors (may be expected): {e}"));
}
// If cert-manager is in the overlay, wait for its webhook then re-apply
let cert_manager_present = overlay
.join("../../base/cert-manager")
.exists();
if cert_manager_present && namespace.is_empty() {
if wait_for_webhook("cert-manager", "cert-manager-webhook", 120).await {
crate::output::ok("Running convergence pass for cert-manager resources...");
let manifests2 =
crate::kube::kustomize_build(&overlay, &resolved_domain, &email).await?;
crate::kube::kube_apply(&manifests2).await?;
}
}
restart_for_changed_configmaps(&before, &snapshot_configmaps().await).await;
// Post-apply hooks
if namespace.is_empty() || namespace == "matrix" {
patch_tuwunel_oauth2_redirect(&resolved_domain).await;
inject_opensearch_model_id().await;
}
if namespace.is_empty() || namespace == "data" {
ensure_opensearch_ml().await;
}
crate::output::ok("Applied.");
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Delete immutable resources that must be re-created on each apply.
async fn pre_apply_cleanup(namespaces: Option<&[String]>) {
let ns_list: Vec<&str> = match namespaces {
Some(ns) => ns.iter().map(|s| s.as_str()).collect(),
None => MANAGED_NS.to_vec(),
};
crate::output::ok("Cleaning up immutable Jobs and test Pods...");
// Prune stale VaultStaticSecrets that share a name with VaultDynamicSecrets
prune_stale_vault_static_secrets(&ns_list).await;
for ns in &ns_list {
// Delete all jobs
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(e) => {
crate::output::warn(&format!("Failed to get kube client: {e}"));
return;
}
};
let jobs: kube::api::Api<k8s_openapi::api::batch::v1::Job> =
kube::api::Api::namespaced(client.clone(), ns);
if let Ok(job_list) = jobs.list(&kube::api::ListParams::default()).await {
for job in job_list.items {
if let Some(name) = &job.metadata.name {
let dp = kube::api::DeleteParams::default();
let _ = jobs.delete(name, &dp).await;
}
}
}
// Delete test pods
let pods: kube::api::Api<k8s_openapi::api::core::v1::Pod> =
kube::api::Api::namespaced(client.clone(), ns);
if let Ok(pod_list) = pods.list(&kube::api::ListParams::default()).await {
for pod in pod_list.items {
if let Some(name) = &pod.metadata.name {
if name.ends_with("-test-connection")
|| name.ends_with("-server-test")
|| name.ends_with("-test")
{
let dp = kube::api::DeleteParams::default();
let _ = pods.delete(name, &dp).await;
}
}
}
}
}
}
/// Prune VaultStaticSecrets that share a name with VaultDynamicSecrets in the same namespace.
async fn prune_stale_vault_static_secrets(namespaces: &[&str]) {
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(e) => {
crate::output::warn(&format!("Failed to get kube client for VSS pruning: {e}"));
return;
}
};
let vss_ar = kube::api::ApiResource {
group: "secrets.hashicorp.com".into(),
version: "v1beta1".into(),
api_version: "secrets.hashicorp.com/v1beta1".into(),
kind: "VaultStaticSecret".into(),
plural: "vaultstaticsecrets".into(),
};
let vds_ar = kube::api::ApiResource {
group: "secrets.hashicorp.com".into(),
version: "v1beta1".into(),
api_version: "secrets.hashicorp.com/v1beta1".into(),
kind: "VaultDynamicSecret".into(),
plural: "vaultdynamicsecrets".into(),
};
for ns in namespaces {
let vss_api: kube::api::Api<kube::api::DynamicObject> =
kube::api::Api::namespaced_with(client.clone(), ns, &vss_ar);
let vds_api: kube::api::Api<kube::api::DynamicObject> =
kube::api::Api::namespaced_with(client.clone(), ns, &vds_ar);
let vss_list = match vss_api.list(&kube::api::ListParams::default()).await {
Ok(l) => l,
Err(_) => continue,
};
let vds_list = match vds_api.list(&kube::api::ListParams::default()).await {
Ok(l) => l,
Err(_) => continue,
};
let vds_names: std::collections::HashSet<String> = vds_list
.items
.iter()
.filter_map(|o| o.metadata.name.clone())
.collect();
for vss in &vss_list.items {
if let Some(name) = &vss.metadata.name {
if vds_names.contains(name) {
crate::output::ok(&format!(
"Pruning stale VaultStaticSecret {ns}/{name} (replaced by VaultDynamicSecret)"
));
let dp = kube::api::DeleteParams::default();
let _ = vss_api.delete(name, &dp).await;
}
}
}
}
}
/// Snapshot ConfigMap resourceVersions across managed namespaces.
async fn snapshot_configmaps() -> std::collections::HashMap<String, String> {
let mut result = std::collections::HashMap::new();
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(_) => return result,
};
for ns in MANAGED_NS {
let cms: kube::api::Api<k8s_openapi::api::core::v1::ConfigMap> =
kube::api::Api::namespaced(client.clone(), ns);
if let Ok(cm_list) = cms.list(&kube::api::ListParams::default()).await {
for cm in cm_list.items {
if let (Some(name), Some(rv)) = (
&cm.metadata.name,
&cm.metadata.resource_version,
) {
result.insert(format!("{ns}/{name}"), rv.clone());
}
}
}
}
result
}
/// Restart deployments that mount any ConfigMap whose resourceVersion changed.
async fn restart_for_changed_configmaps(
before: &std::collections::HashMap<String, String>,
after: &std::collections::HashMap<String, String>,
) {
let mut changed_by_ns: std::collections::HashMap<&str, std::collections::HashSet<&str>> =
std::collections::HashMap::new();
for (key, rv) in after {
if before.get(key) != Some(rv) {
if let Some((ns, name)) = key.split_once('/') {
changed_by_ns.entry(ns).or_default().insert(name);
}
}
}
if changed_by_ns.is_empty() {
return;
}
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(_) => return,
};
for (ns, cm_names) in &changed_by_ns {
let deps: kube::api::Api<k8s_openapi::api::apps::v1::Deployment> =
kube::api::Api::namespaced(client.clone(), ns);
if let Ok(dep_list) = deps.list(&kube::api::ListParams::default()).await {
for dep in dep_list.items {
let dep_name = dep.metadata.name.as_deref().unwrap_or("");
// Check if this deployment mounts any changed ConfigMap
let volumes = dep
.spec
.as_ref()
.and_then(|s| s.template.spec.as_ref())
.and_then(|s| s.volumes.as_ref());
if let Some(vols) = volumes {
let mounts_changed = vols.iter().any(|v| {
if let Some(cm) = &v.config_map {
cm_names.contains(cm.name.as_str())
} else {
false
}
});
if mounts_changed {
crate::output::ok(&format!(
"Restarting {ns}/{dep_name} (ConfigMap updated)..."
));
let _ = crate::kube::kube_rollout_restart(ns, dep_name).await;
}
}
}
}
}
}
/// Wait for a webhook endpoint to become ready.
async fn wait_for_webhook(ns: &str, svc: &str, timeout_secs: u64) -> bool {
crate::output::ok(&format!(
"Waiting for {ns}/{svc} webhook (up to {timeout_secs}s)..."
));
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(_) => return false,
};
let eps: kube::api::Api<k8s_openapi::api::core::v1::Endpoints> =
kube::api::Api::namespaced(client.clone(), ns);
loop {
if std::time::Instant::now() > deadline {
crate::output::warn(&format!(
" {ns}/{svc} not ready after {timeout_secs}s -- continuing anyway."
));
return false;
}
if let Ok(Some(ep)) = eps.get_opt(svc).await {
let has_addr = ep
.subsets
.as_ref()
.and_then(|ss| ss.first())
.and_then(|s| s.addresses.as_ref())
.is_some_and(|a| !a.is_empty());
if has_addr {
crate::output::ok(&format!(" {ns}/{svc} ready."));
return true;
}
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
/// Create/update gitea-mkcert-ca ConfigMap from the local mkcert root CA.
async fn apply_mkcert_ca_configmap() {
let caroot = tokio::process::Command::new("mkcert")
.arg("-CAROOT")
.output()
.await;
let caroot_path = match caroot {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
_ => {
crate::output::warn("mkcert not found -- skipping gitea-mkcert-ca ConfigMap.");
return;
}
};
let ca_pem_path = std::path::Path::new(&caroot_path).join("rootCA.pem");
let ca_pem = match std::fs::read_to_string(&ca_pem_path) {
Ok(s) => s,
Err(_) => {
crate::output::warn(&format!(
"mkcert root CA not found at {} -- skipping.",
ca_pem_path.display()
));
return;
}
};
let cm = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "gitea-mkcert-ca", "namespace": "devtools"},
"data": {"ca.crt": ca_pem},
});
let manifest = serde_json::to_string(&cm).unwrap_or_default();
if let Err(e) = crate::kube::kube_apply(&manifest).await {
crate::output::warn(&format!("Failed to apply gitea-mkcert-ca: {e}"));
} else {
crate::output::ok("gitea-mkcert-ca ConfigMap applied.");
}
}
/// Patch the tuwunel OAuth2Client redirect URI with the actual client_id.
async fn patch_tuwunel_oauth2_redirect(domain: &str) {
let client_id = match crate::kube::kube_get_secret_field("matrix", "oidc-tuwunel", "CLIENT_ID")
.await
{
Ok(id) if !id.is_empty() => id,
_ => {
crate::output::warn(
"oidc-tuwunel secret not yet available -- skipping redirect URI patch.",
);
return;
}
};
let redirect_uri = format!(
"https://messages.{domain}/_matrix/client/unstable/login/sso/callback/{client_id}"
);
// Patch the OAuth2Client CRD via kube-rs
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(_) => return,
};
let ar = kube::api::ApiResource {
group: "hydra.ory.sh".into(),
version: "v1alpha1".into(),
api_version: "hydra.ory.sh/v1alpha1".into(),
kind: "OAuth2Client".into(),
plural: "oauth2clients".into(),
};
let api: kube::api::Api<kube::api::DynamicObject> =
kube::api::Api::namespaced_with(client.clone(), "matrix", &ar);
let patch = serde_json::json!({
"spec": {
"redirectUris": [redirect_uri]
}
});
let pp = kube::api::PatchParams::default();
if let Err(e) = api
.patch("tuwunel", &pp, &kube::api::Patch::Merge(patch))
.await
{
crate::output::warn(&format!("Failed to patch tuwunel OAuth2Client: {e}"));
} else {
crate::output::ok("Patched tuwunel OAuth2Client redirect URI.");
}
}
// ---------------------------------------------------------------------------
// OpenSearch helpers (kube exec + curl inside pod)
// ---------------------------------------------------------------------------
/// Call OpenSearch API via kube exec curl inside the opensearch pod.
async fn os_api(path: &str, method: &str, body: Option<&str>) -> Option<String> {
let url = format!("http://localhost:9200{path}");
let mut curl_args: Vec<&str> = vec!["curl", "-sf", &url];
if method != "GET" {
curl_args.extend_from_slice(&["-X", method]);
}
let body_string;
if let Some(b) = body {
body_string = b.to_string();
curl_args.extend_from_slice(&["-H", "Content-Type: application/json", "-d", &body_string]);
}
// Build the full exec command: exec deploy/opensearch -n data -c opensearch -- curl ...
let exec_cmd = curl_args;
match crate::kube::kube_exec("data", "opensearch-0", &exec_cmd, Some("opensearch")).await {
Ok((0, out)) if !out.is_empty() => Some(out),
_ => None,
}
}
/// Inject OpenSearch model_id into matrix/opensearch-ml-config ConfigMap.
async fn inject_opensearch_model_id() {
let pipe_resp =
match os_api("/_ingest/pipeline/tuwunel_embedding_pipeline", "GET", None).await {
Some(r) => r,
None => {
crate::output::warn(
"OpenSearch ingest pipeline not found -- skipping model_id injection.",
);
return;
}
};
let model_id = serde_json::from_str::<serde_json::Value>(&pipe_resp)
.ok()
.and_then(|v| {
v.get("tuwunel_embedding_pipeline")?
.get("processors")?
.as_array()?
.iter()
.find_map(|p| {
p.get("text_embedding")?
.get("model_id")?
.as_str()
.map(String::from)
})
});
let Some(model_id) = model_id else {
crate::output::warn(
"No model_id in ingest pipeline -- tuwunel hybrid search unavailable.",
);
return;
};
// Check if ConfigMap already has this value
if let Ok(current) =
crate::kube::kube_get_secret_field("matrix", "opensearch-ml-config", "model_id").await
{
if current == model_id {
return;
}
}
let cm = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "opensearch-ml-config", "namespace": "matrix"},
"data": {"model_id": &model_id},
});
let manifest = serde_json::to_string(&cm).unwrap_or_default();
if let Err(e) = crate::kube::kube_apply(&manifest).await {
crate::output::warn(&format!("Failed to inject OpenSearch model_id: {e}"));
} else {
crate::output::ok(&format!(
"Injected OpenSearch model_id ({model_id}) into matrix/opensearch-ml-config."
));
}
}
/// Configure OpenSearch ML Commons for neural search.
///
/// 1. Sets cluster settings to allow ML on data nodes.
/// 2. Registers and deploys all-mpnet-base-v2 (pre-trained, 384-dim).
/// 3. Creates ingest + search pipelines for hybrid BM25+neural scoring.
async fn ensure_opensearch_ml() {
if os_api("/_cluster/health", "GET", None).await.is_none() {
crate::output::warn("OpenSearch not reachable -- skipping ML setup.");
return;
}
// 1. ML Commons cluster settings
let settings = serde_json::json!({
"persistent": {
"plugins.ml_commons.only_run_on_ml_node": false,
"plugins.ml_commons.native_memory_threshold": 90,
"plugins.ml_commons.model_access_control_enabled": false,
"plugins.ml_commons.allow_registering_model_via_url": true,
}
});
os_api(
"/_cluster/settings",
"PUT",
Some(&serde_json::to_string(&settings).unwrap()),
)
.await;
// 2. Check if model already registered and deployed
let search_body =
r#"{"query":{"match":{"name":"huggingface/sentence-transformers/all-mpnet-base-v2"}}}"#;
let search_resp = match os_api("/_plugins/_ml/models/_search", "POST", Some(search_body)).await
{
Some(r) => r,
None => {
crate::output::warn("OpenSearch ML search API failed -- skipping ML setup.");
return;
}
};
let resp: serde_json::Value = match serde_json::from_str(&search_resp) {
Ok(v) => v,
Err(_) => return,
};
let hits = resp
.get("hits")
.and_then(|h| h.get("hits"))
.and_then(|h| h.as_array())
.cloned()
.unwrap_or_default();
let mut model_id: Option<String> = None;
let mut already_deployed = false;
for hit in &hits {
let state = hit
.get("_source")
.and_then(|s| s.get("model_state"))
.and_then(|v| v.as_str())
.unwrap_or("");
let id = hit.get("_id").and_then(|v| v.as_str()).unwrap_or("");
match state {
"DEPLOYED" => {
model_id = Some(id.to_string());
already_deployed = true;
break;
}
"REGISTERED" | "DEPLOYING" => {
model_id = Some(id.to_string());
}
_ => {}
}
}
if !already_deployed {
if let Some(ref mid) = model_id {
// Registered but not deployed -- deploy it
crate::output::ok("Deploying OpenSearch ML model...");
os_api(
&format!("/_plugins/_ml/models/{mid}/_deploy"),
"POST",
None,
)
.await;
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(r) =
os_api(&format!("/_plugins/_ml/models/{mid}"), "GET", None).await
{
if r.contains("\"DEPLOYED\"") {
break;
}
}
}
} else {
// Register from pre-trained hub
crate::output::ok("Registering OpenSearch ML model (all-mpnet-base-v2)...");
let reg_body = serde_json::json!({
"name": "huggingface/sentence-transformers/all-mpnet-base-v2",
"version": "1.0.1",
"model_format": "TORCH_SCRIPT",
});
let reg_resp = match os_api(
"/_plugins/_ml/models/_register",
"POST",
Some(&serde_json::to_string(&reg_body).unwrap()),
)
.await
{
Some(r) => r,
None => {
crate::output::warn("Failed to register ML model -- skipping.");
return;
}
};
let task_id = serde_json::from_str::<serde_json::Value>(&reg_resp)
.ok()
.and_then(|v| v.get("task_id")?.as_str().map(String::from))
.unwrap_or_default();
if task_id.is_empty() {
crate::output::warn("No task_id from model registration -- skipping.");
return;
}
crate::output::ok("Waiting for model registration...");
let mut registered_id = None;
for _ in 0..60 {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
if let Some(task_resp) =
os_api(&format!("/_plugins/_ml/tasks/{task_id}"), "GET", None).await
{
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&task_resp) {
match task.get("state").and_then(|v| v.as_str()).unwrap_or("") {
"COMPLETED" => {
registered_id = task
.get("model_id")
.and_then(|v| v.as_str())
.map(String::from);
break;
}
"FAILED" => {
crate::output::warn(&format!(
"ML model registration failed: {task_resp}"
));
return;
}
_ => {}
}
}
}
}
let Some(mid) = registered_id else {
crate::output::warn("ML model registration timed out.");
return;
};
crate::output::ok("Deploying ML model...");
os_api(
&format!("/_plugins/_ml/models/{mid}/_deploy"),
"POST",
None,
)
.await;
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(r) =
os_api(&format!("/_plugins/_ml/models/{mid}"), "GET", None).await
{
if r.contains("\"DEPLOYED\"") {
break;
}
}
}
model_id = Some(mid);
}
}
let Some(model_id) = model_id else {
crate::output::warn("No ML model available -- skipping pipeline setup.");
return;
};
// 3. Ingest pipeline
let ingest = serde_json::json!({
"description": "Tuwunel message embedding pipeline",
"processors": [{"text_embedding": {
"model_id": &model_id,
"field_map": {"body": "embedding"},
}}],
});
os_api(
"/_ingest/pipeline/tuwunel_embedding_pipeline",
"PUT",
Some(&serde_json::to_string(&ingest).unwrap()),
)
.await;
// 4. Search pipeline
let search = serde_json::json!({
"description": "Tuwunel hybrid BM25+neural search pipeline",
"phase_results_processors": [{"normalization-processor": {
"normalization": {"technique": "min_max"},
"combination": {
"technique": "arithmetic_mean",
"parameters": {"weights": [0.3, 0.7]},
},
}}],
});
os_api(
"/_search/pipeline/tuwunel_hybrid_pipeline",
"PUT",
Some(&serde_json::to_string(&search).unwrap()),
)
.await;
crate::output::ok(&format!("OpenSearch ML ready (model: {model_id})."));
}
#[cfg(test)]
mod tests {
use super::*;
const MULTI_DOC: &str = "\
---
apiVersion: v1
kind: ConfigMap
metadata:
name: meet-config
namespace: lasuite
data:
FOO: bar
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: meet-backend
namespace: lasuite
spec:
replicas: 1
---
apiVersion: v1
kind: Namespace
metadata:
name: lasuite
---
apiVersion: v1
kind: ConfigMap
metadata:
name: pingora-config
namespace: ingress
data:
config.toml: |
hello
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: pingora
namespace: ingress
spec:
replicas: 1
";
#[test]
fn test_keeps_matching_namespace() {
let result = filter_by_namespace(MULTI_DOC, "lasuite");
assert!(result.contains("name: meet-config"));
assert!(result.contains("name: meet-backend"));
}
#[test]
fn test_excludes_other_namespaces() {
let result = filter_by_namespace(MULTI_DOC, "lasuite");
assert!(!result.contains("namespace: ingress"));
assert!(!result.contains("name: pingora-config"));
assert!(!result.contains("name: pingora\n"));
}
#[test]
fn test_includes_namespace_resource_itself() {
let result = filter_by_namespace(MULTI_DOC, "lasuite");
assert!(result.contains("kind: Namespace"));
}
#[test]
fn test_ingress_filter() {
let result = filter_by_namespace(MULTI_DOC, "ingress");
assert!(result.contains("name: pingora-config"));
assert!(result.contains("name: pingora"));
assert!(!result.contains("namespace: lasuite"));
}
#[test]
fn test_unknown_namespace_returns_empty() {
let result = filter_by_namespace(MULTI_DOC, "nonexistent");
assert!(result.trim().is_empty());
}
#[test]
fn test_empty_input_returns_empty() {
let result = filter_by_namespace("", "lasuite");
assert!(result.trim().is_empty());
}
#[test]
fn test_result_starts_with_separator() {
let result = filter_by_namespace(MULTI_DOC, "lasuite");
assert!(result.starts_with("---"));
}
#[test]
fn test_does_not_include_namespace_resource_for_wrong_ns() {
let result = filter_by_namespace(MULTI_DOC, "ingress");
assert!(!result.contains("kind: Namespace"));
}
#[test]
fn test_single_doc_matching() {
let doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n";
let result = filter_by_namespace(doc, "ory");
assert!(result.contains("name: x"));
}
#[test]
fn test_single_doc_not_matching() {
let doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n";
let result = filter_by_namespace(doc, "lasuite");
assert!(result.trim().is_empty());
}
}

498
src/openbao.rs Normal file
View File

@@ -0,0 +1,498 @@
//! Lightweight OpenBao/Vault HTTP API client.
//!
//! Replaces all `kubectl exec openbao-0 -- sh -c "bao ..."` calls from the
//! Python version with direct HTTP API calls via port-forward to openbao:8200.
use crate::error::{Result, ResultExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// OpenBao HTTP client wrapping a base URL and optional root token.
#[derive(Clone)]
pub struct BaoClient {
pub base_url: String,
pub token: Option<String>,
http: reqwest::Client,
}
// ── API response types ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct InitResponse {
pub unseal_keys_b64: Vec<String>,
pub root_token: String,
}
#[derive(Debug, Deserialize)]
pub struct SealStatusResponse {
#[serde(default)]
pub initialized: bool,
#[serde(default)]
pub sealed: bool,
#[serde(default)]
pub progress: u32,
#[serde(default)]
pub t: u32,
#[serde(default)]
pub n: u32,
}
#[derive(Debug, Deserialize)]
pub struct UnsealResponse {
#[serde(default)]
pub sealed: bool,
#[serde(default)]
pub progress: u32,
}
/// KV v2 read response wrapper.
#[derive(Debug, Deserialize)]
struct KvReadResponse {
data: Option<KvReadData>,
}
#[derive(Debug, Deserialize)]
struct KvReadData {
data: Option<HashMap<String, serde_json::Value>>,
}
// ── Client implementation ───────────────────────────────────────────────────
impl BaoClient {
/// Create a new client pointing at `base_url` (e.g. `http://localhost:8200`).
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
token: None,
http: reqwest::Client::new(),
}
}
/// Create a client with an authentication token.
pub fn with_token(base_url: &str, token: &str) -> Self {
let mut client = Self::new(base_url);
client.token = Some(token.to_string());
client
}
fn url(&self, path: &str) -> String {
format!("{}/v1/{}", self.base_url, path.trim_start_matches('/'))
}
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let mut req = self.http.request(method, self.url(path));
if let Some(ref token) = self.token {
req = req.header("X-Vault-Token", token);
}
req
}
// ── System operations ───────────────────────────────────────────────
/// Get the seal status of the OpenBao instance.
pub async fn seal_status(&self) -> Result<SealStatusResponse> {
let resp = self
.http
.get(format!("{}/v1/sys/seal-status", self.base_url))
.send()
.await
.ctx("Failed to connect to OpenBao")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("OpenBao seal-status returned {status}: {body}");
}
resp.json().await.ctx("Failed to parse seal status")
}
/// Initialize OpenBao with the given number of key shares and threshold.
pub async fn init(&self, key_shares: u32, key_threshold: u32) -> Result<InitResponse> {
#[derive(Serialize)]
struct InitRequest {
secret_shares: u32,
secret_threshold: u32,
}
let resp = self
.http
.put(format!("{}/v1/sys/init", self.base_url))
.json(&InitRequest {
secret_shares: key_shares,
secret_threshold: key_threshold,
})
.send()
.await
.ctx("Failed to initialize OpenBao")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("OpenBao init returned {status}: {body}");
}
resp.json().await.ctx("Failed to parse init response")
}
/// Unseal OpenBao with one key share.
pub async fn unseal(&self, key: &str) -> Result<UnsealResponse> {
#[derive(Serialize)]
struct UnsealRequest<'a> {
key: &'a str,
}
let resp = self
.http
.put(format!("{}/v1/sys/unseal", self.base_url))
.json(&UnsealRequest { key })
.send()
.await
.ctx("Failed to unseal OpenBao")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("OpenBao unseal returned {status}: {body}");
}
resp.json().await.ctx("Failed to parse unseal response")
}
// ── Secrets engine management ───────────────────────────────────────
/// Enable a secrets engine at the given path.
/// Returns Ok(()) even if already enabled (400 is tolerated).
pub async fn enable_secrets_engine(&self, path: &str, engine_type: &str) -> Result<()> {
#[derive(Serialize)]
struct EnableRequest<'a> {
r#type: &'a str,
}
let resp = self
.request(reqwest::Method::POST, &format!("sys/mounts/{path}"))
.json(&EnableRequest {
r#type: engine_type,
})
.send()
.await
.ctx("Failed to enable secrets engine")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 400 {
// 400 = "path is already in use" — idempotent
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
bail!("Enable secrets engine {path} returned {status}: {body}");
}
}
// ── KV v2 operations ────────────────────────────────────────────────
/// Read all fields from a KV v2 secret path.
/// Returns None if the path doesn't exist (404).
pub async fn kv_get(&self, mount: &str, path: &str) -> Result<Option<HashMap<String, String>>> {
let resp = self
.request(reqwest::Method::GET, &format!("{mount}/data/{path}"))
.send()
.await
.ctx("Failed to read KV secret")?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("KV get {mount}/{path} returned {status}: {body}");
}
let kv_resp: KvReadResponse = resp.json().await.ctx("Failed to parse KV response")?;
let data = kv_resp
.data
.and_then(|d| d.data)
.unwrap_or_default();
// Convert all values to strings
let result: HashMap<String, String> = data
.into_iter()
.map(|(k, v)| {
let s = match v {
serde_json::Value::String(s) => s,
other => other.to_string(),
};
(k, s)
})
.collect();
Ok(Some(result))
}
/// Read a single field from a KV v2 secret path.
/// Returns empty string if path or field doesn't exist.
pub async fn kv_get_field(&self, mount: &str, path: &str, field: &str) -> Result<String> {
match self.kv_get(mount, path).await? {
Some(data) => Ok(data.get(field).cloned().unwrap_or_default()),
None => Ok(String::new()),
}
}
/// Write (create or overwrite) all fields in a KV v2 secret path.
pub async fn kv_put(
&self,
mount: &str,
path: &str,
data: &HashMap<String, String>,
) -> Result<()> {
#[derive(Serialize)]
struct KvWriteRequest<'a> {
data: &'a HashMap<String, String>,
}
let resp = self
.request(reqwest::Method::POST, &format!("{mount}/data/{path}"))
.json(&KvWriteRequest { data })
.send()
.await
.ctx("Failed to write KV secret")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("KV put {mount}/{path} returned {status}: {body}");
}
Ok(())
}
/// Patch (merge) fields into an existing KV v2 secret path.
pub async fn kv_patch(
&self,
mount: &str,
path: &str,
data: &HashMap<String, String>,
) -> Result<()> {
#[derive(Serialize)]
struct KvWriteRequest<'a> {
data: &'a HashMap<String, String>,
}
let resp = self
.request(reqwest::Method::PATCH, &format!("{mount}/data/{path}"))
.header("Content-Type", "application/merge-patch+json")
.json(&KvWriteRequest { data })
.send()
.await
.ctx("Failed to patch KV secret")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("KV patch {mount}/{path} returned {status}: {body}");
}
Ok(())
}
/// Delete a KV v2 secret path (soft delete — deletes latest version).
pub async fn kv_delete(&self, mount: &str, path: &str) -> Result<()> {
let resp = self
.request(reqwest::Method::DELETE, &format!("{mount}/data/{path}"))
.send()
.await
.ctx("Failed to delete KV secret")?;
// 404 is fine (already deleted)
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("KV delete {mount}/{path} returned {status}: {body}");
}
Ok(())
}
// ── Auth operations ─────────────────────────────────────────────────
/// Enable an auth method at the given path.
/// Tolerates "already enabled" (400/409).
pub async fn auth_enable(&self, path: &str, method_type: &str) -> Result<()> {
#[derive(Serialize)]
struct AuthEnableRequest<'a> {
r#type: &'a str,
}
let resp = self
.request(reqwest::Method::POST, &format!("sys/auth/{path}"))
.json(&AuthEnableRequest {
r#type: method_type,
})
.send()
.await
.ctx("Failed to enable auth method")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 400 {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
bail!("Enable auth {path} returned {status}: {body}");
}
}
/// Write a policy.
pub async fn write_policy(&self, name: &str, policy_hcl: &str) -> Result<()> {
#[derive(Serialize)]
struct PolicyRequest<'a> {
policy: &'a str,
}
let resp = self
.request(
reqwest::Method::PUT,
&format!("sys/policies/acl/{name}"),
)
.json(&PolicyRequest { policy: policy_hcl })
.send()
.await
.ctx("Failed to write policy")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("Write policy {name} returned {status}: {body}");
}
Ok(())
}
/// Write to an arbitrary API path (for auth config, roles, database config, etc.).
pub async fn write(
&self,
path: &str,
data: &serde_json::Value,
) -> Result<serde_json::Value> {
let resp = self
.request(reqwest::Method::POST, path)
.json(data)
.send()
.await
.with_ctx(|| format!("Failed to write to {path}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("Write {path} returned {status}: {body}");
}
let body = resp.text().await.unwrap_or_default();
if body.is_empty() {
Ok(serde_json::Value::Null)
} else {
serde_json::from_str(&body).ctx("Failed to parse write response")
}
}
/// Read from an arbitrary API path.
pub async fn read(&self, path: &str) -> Result<Option<serde_json::Value>> {
let resp = self
.request(reqwest::Method::GET, path)
.send()
.await
.with_ctx(|| format!("Failed to read {path}"))?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("Read {path} returned {status}: {body}");
}
let body = resp.text().await.unwrap_or_default();
if body.is_empty() {
Ok(Some(serde_json::Value::Null))
} else {
Ok(Some(serde_json::from_str(&body)?))
}
}
// ── Database secrets engine ─────────────────────────────────────────
/// Configure the database secrets engine connection.
pub async fn write_db_config(
&self,
name: &str,
plugin: &str,
connection_url: &str,
username: &str,
password: &str,
allowed_roles: &str,
) -> Result<()> {
let data = serde_json::json!({
"plugin_name": plugin,
"connection_url": connection_url,
"username": username,
"password": password,
"allowed_roles": allowed_roles,
});
self.write(&format!("database/config/{name}"), &data).await?;
Ok(())
}
/// Create a database static role.
pub async fn write_db_static_role(
&self,
name: &str,
db_name: &str,
username: &str,
rotation_period: u64,
rotation_statements: &[&str],
) -> Result<()> {
let data = serde_json::json!({
"db_name": db_name,
"username": username,
"rotation_period": rotation_period,
"rotation_statements": rotation_statements,
});
self.write(&format!("database/static-roles/{name}"), &data)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_url_construction() {
let client = BaoClient::new("http://localhost:8200");
assert_eq!(client.url("sys/seal-status"), "http://localhost:8200/v1/sys/seal-status");
assert_eq!(client.url("/sys/seal-status"), "http://localhost:8200/v1/sys/seal-status");
}
#[test]
fn test_client_url_strips_trailing_slash() {
let client = BaoClient::new("http://localhost:8200/");
assert_eq!(client.base_url, "http://localhost:8200");
}
#[test]
fn test_with_token() {
let client = BaoClient::with_token("http://localhost:8200", "mytoken");
assert_eq!(client.token, Some("mytoken".to_string()));
}
#[test]
fn test_new_has_no_token() {
let client = BaoClient::new("http://localhost:8200");
assert!(client.token.is_none());
}
#[tokio::test]
async fn test_seal_status_error_on_nonexistent_server() {
// Connecting to a port where nothing is listening should produce an
// error (connection refused), not a panic or hang.
let client = BaoClient::new("http://127.0.0.1:19999");
let result = client.seal_status().await;
assert!(
result.is_err(),
"seal_status should return an error when the server is unreachable"
);
}
}

92
src/output.rs Normal file
View File

@@ -0,0 +1,92 @@
/// Print a step header.
pub fn step(msg: &str) {
println!("\n==> {msg}");
}
/// Print a success/info line.
pub fn ok(msg: &str) {
println!(" {msg}");
}
/// Print a warning to stderr.
pub fn warn(msg: &str) {
eprintln!(" WARN: {msg}");
}
/// Return an aligned text table. Columns padded to max width.
pub fn table(rows: &[Vec<String>], headers: &[&str]) -> String {
if headers.is_empty() {
return String::new();
}
let mut col_widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < col_widths.len() {
col_widths[i] = col_widths[i].max(cell.len());
}
}
}
let header_line: String = headers
.iter()
.enumerate()
.map(|(i, h)| format!("{:<width$}", h, width = col_widths[i]))
.collect::<Vec<_>>()
.join(" ");
let separator: String = col_widths
.iter()
.map(|&w| "-".repeat(w))
.collect::<Vec<_>>()
.join(" ");
let mut lines = vec![header_line, separator];
for row in rows {
let cells: Vec<String> = (0..headers.len())
.map(|i| {
let val = row.get(i).map(|s| s.as_str()).unwrap_or("");
format!("{:<width$}", val, width = col_widths[i])
})
.collect();
lines.push(cells.join(" "));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_table_basic() {
let rows = vec![
vec!["abc".to_string(), "def".to_string()],
vec!["x".to_string(), "longer".to_string()],
];
let result = table(&rows, &["Col1", "Col2"]);
assert!(result.contains("Col1"));
assert!(result.contains("Col2"));
assert!(result.contains("abc"));
assert!(result.contains("longer"));
}
#[test]
fn test_table_empty_headers() {
let result = table(&[], &[]);
assert!(result.is_empty());
}
#[test]
fn test_table_column_widths() {
let rows = vec![vec!["short".to_string(), "x".to_string()]];
let result = table(&rows, &["LongHeader", "H2"]);
// Header should set minimum width
for line in result.lines().skip(2) {
// Data row: "short" should be padded to "LongHeader" width
assert!(line.starts_with("short "));
}
}
}

1664
src/pm.rs Normal file

File diff suppressed because it is too large Load Diff

1727
src/secrets.rs Normal file

File diff suppressed because it is too large Load Diff

573
src/services.rs Normal file
View File

@@ -0,0 +1,573 @@
//! Service management — status, logs, restart.
use crate::error::{Result, SunbeamError};
use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, DynamicObject, ListParams, LogParams};
use kube::ResourceExt;
use std::collections::BTreeMap;
use crate::constants::MANAGED_NS;
use crate::kube::{get_client, kube_rollout_restart, parse_target};
use crate::output::{ok, step, warn};
/// Services that can be rollout-restarted, as (namespace, deployment) pairs.
pub const SERVICES_TO_RESTART: &[(&str, &str)] = &[
("ory", "hydra"),
("ory", "kratos"),
("ory", "login-ui"),
("devtools", "gitea"),
("storage", "seaweedfs-filer"),
("lasuite", "hive"),
("lasuite", "people-backend"),
("lasuite", "people-frontend"),
("lasuite", "people-celery-worker"),
("lasuite", "people-celery-beat"),
("lasuite", "projects"),
("matrix", "tuwunel"),
("media", "livekit-server"),
];
// ---------------------------------------------------------------------------
// Status helpers
// ---------------------------------------------------------------------------
/// Parsed pod row for display.
struct PodRow {
ns: String,
name: String,
ready: String,
status: String,
}
fn icon_for_status(status: &str) -> &'static str {
match status {
"Running" | "Completed" | "Succeeded" => "\u{2713}",
"Pending" => "\u{25cb}",
"Failed" => "\u{2717}",
_ => "?",
}
}
fn is_unhealthy(pod: &Pod) -> bool {
let status = pod.status.as_ref();
let phase = status
.and_then(|s| s.phase.as_deref())
.unwrap_or("Unknown");
match phase {
"Running" => {
// Check all containers are ready.
let container_statuses = status
.and_then(|s| s.container_statuses.as_ref());
if let Some(cs) = container_statuses {
let total = cs.len();
let ready = cs.iter().filter(|c| c.ready).count();
ready != total
} else {
true
}
}
"Succeeded" | "Completed" => false,
_ => true,
}
}
fn pod_phase(pod: &Pod) -> String {
pod.status
.as_ref()
.and_then(|s| s.phase.clone())
.unwrap_or_else(|| "Unknown".to_string())
}
fn pod_ready_str(pod: &Pod) -> String {
let cs = pod
.status
.as_ref()
.and_then(|s| s.container_statuses.as_ref());
match cs {
Some(cs) => {
let total = cs.len();
let ready = cs.iter().filter(|c| c.ready).count();
format!("{ready}/{total}")
}
None => "0/0".to_string(),
}
}
// ---------------------------------------------------------------------------
// VSO sync status
// ---------------------------------------------------------------------------
async fn vso_sync_status() -> Result<()> {
step("VSO secret sync status...");
let client = get_client().await?;
let mut all_ok = true;
// --- VaultStaticSecrets ---
{
let ar = kube::api::ApiResource {
group: "secrets.hashicorp.com".into(),
version: "v1beta1".into(),
api_version: "secrets.hashicorp.com/v1beta1".into(),
kind: "VaultStaticSecret".into(),
plural: "vaultstaticsecrets".into(),
};
let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
let list = api.list(&ListParams::default()).await;
if let Ok(list) = list {
// Group by namespace and sort
let mut grouped: BTreeMap<String, Vec<(String, bool)>> = BTreeMap::new();
for obj in &list.items {
let ns = obj.namespace().unwrap_or_default();
let name = obj.name_any();
let mac = obj
.data
.get("status")
.and_then(|s| s.get("secretMAC"))
.and_then(|v| v.as_str())
.unwrap_or("");
let synced = !mac.is_empty() && mac != "<none>";
if !synced {
all_ok = false;
}
grouped.entry(ns).or_default().push((name, synced));
}
for (ns, mut items) in grouped {
println!(" {ns} (VSS):");
items.sort();
for (name, synced) in items {
let icon = if synced { "\u{2713}" } else { "\u{2717}" };
println!(" {icon} {name}");
}
}
}
}
// --- VaultDynamicSecrets ---
{
let ar = kube::api::ApiResource {
group: "secrets.hashicorp.com".into(),
version: "v1beta1".into(),
api_version: "secrets.hashicorp.com/v1beta1".into(),
kind: "VaultDynamicSecret".into(),
plural: "vaultdynamicsecrets".into(),
};
let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
let list = api.list(&ListParams::default()).await;
if let Ok(list) = list {
let mut grouped: BTreeMap<String, Vec<(String, bool)>> = BTreeMap::new();
for obj in &list.items {
let ns = obj.namespace().unwrap_or_default();
let name = obj.name_any();
let renewed = obj
.data
.get("status")
.and_then(|s| s.get("lastRenewalTime"))
.and_then(|v| v.as_str())
.unwrap_or("0");
let synced = !renewed.is_empty() && renewed != "0" && renewed != "<none>";
if !synced {
all_ok = false;
}
grouped.entry(ns).or_default().push((name, synced));
}
for (ns, mut items) in grouped {
println!(" {ns} (VDS):");
items.sort();
for (name, synced) in items {
let icon = if synced { "\u{2713}" } else { "\u{2717}" };
println!(" {icon} {name}");
}
}
}
}
println!();
if all_ok {
ok("All VSO secrets synced.");
} else {
warn("Some VSO secrets are not synced.");
}
Ok(())
}
// ---------------------------------------------------------------------------
// Public commands
// ---------------------------------------------------------------------------
/// Show pod health, optionally filtered by namespace or namespace/service.
pub async fn cmd_status(target: Option<&str>) -> Result<()> {
step("Pod health across all namespaces...");
let client = get_client().await?;
let (ns_filter, svc_filter) = parse_target(target)?;
let mut pods: Vec<PodRow> = Vec::new();
match (ns_filter, svc_filter) {
(None, _) => {
// All managed namespaces
let ns_set: std::collections::HashSet<&str> =
MANAGED_NS.iter().copied().collect();
for ns in MANAGED_NS {
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
let lp = ListParams::default();
if let Ok(list) = api.list(&lp).await {
for pod in list.items {
let pod_ns = pod.namespace().unwrap_or_default();
if !ns_set.contains(pod_ns.as_str()) {
continue;
}
pods.push(PodRow {
ns: pod_ns,
name: pod.name_any(),
ready: pod_ready_str(&pod),
status: pod_phase(&pod),
});
}
}
}
}
(Some(ns), None) => {
// All pods in a namespace
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
let lp = ListParams::default();
if let Ok(list) = api.list(&lp).await {
for pod in list.items {
pods.push(PodRow {
ns: ns.to_string(),
name: pod.name_any(),
ready: pod_ready_str(&pod),
status: pod_phase(&pod),
});
}
}
}
(Some(ns), Some(svc)) => {
// Specific service: filter by app label
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
let lp = ListParams::default().labels(&format!("app={svc}"));
if let Ok(list) = api.list(&lp).await {
for pod in list.items {
pods.push(PodRow {
ns: ns.to_string(),
name: pod.name_any(),
ready: pod_ready_str(&pod),
status: pod_phase(&pod),
});
}
}
}
}
if pods.is_empty() {
warn("No pods found in managed namespaces.");
return Ok(());
}
pods.sort_by(|a, b| (&a.ns, &a.name).cmp(&(&b.ns, &b.name)));
let mut all_ok = true;
let mut cur_ns: Option<&str> = None;
for row in &pods {
if cur_ns != Some(&row.ns) {
println!(" {}:", row.ns);
cur_ns = Some(&row.ns);
}
let icon = icon_for_status(&row.status);
let mut unhealthy = !matches!(
row.status.as_str(),
"Running" | "Completed" | "Succeeded"
);
// For Running pods, check ready ratio
if !unhealthy && row.status == "Running" && row.ready.contains('/') {
let parts: Vec<&str> = row.ready.split('/').collect();
if parts.len() == 2 && parts[0] != parts[1] {
unhealthy = true;
}
}
if unhealthy {
all_ok = false;
}
println!(" {icon} {:<50} {:<6} {}", row.name, row.ready, row.status);
}
println!();
if all_ok {
ok("All pods healthy.");
} else {
warn("Some pods are not ready.");
}
vso_sync_status().await?;
Ok(())
}
/// Stream logs for a service. Target must include service name (e.g. ory/kratos).
pub async fn cmd_logs(target: &str, follow: bool) -> Result<()> {
let (ns_opt, name_opt) = parse_target(Some(target))?;
let ns = ns_opt.unwrap_or("");
let name = match name_opt {
Some(n) => n,
None => bail!("Logs require a service name, e.g. 'ory/kratos'."),
};
let client = get_client().await?;
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
// Find pods matching the app label
let lp = ListParams::default().labels(&format!("app={name}"));
let pod_list = api.list(&lp).await?;
if pod_list.items.is_empty() {
bail!("No pods found for {ns}/{name}");
}
if follow {
// Stream logs from the first matching pod
let pod_name = pod_list.items[0].name_any();
let mut lp = LogParams::default();
lp.follow = true;
lp.tail_lines = Some(100);
// log_stream returns a futures::AsyncBufRead — use the futures crate to read it
use futures::AsyncBufReadExt;
let stream = api.log_stream(&pod_name, &lp).await?;
let reader = futures::io::BufReader::new(stream);
let mut lines = reader.lines();
use futures::StreamExt;
while let Some(line) = lines.next().await {
match line {
Ok(line) => println!("{line}"),
Err(e) => {
warn(&format!("Log stream error: {e}"));
break;
}
}
}
} else {
// Print logs from all matching pods
for pod in &pod_list.items {
let pod_name = pod.name_any();
let mut lp = LogParams::default();
lp.tail_lines = Some(100);
match api.logs(&pod_name, &lp).await {
Ok(logs) => print!("{logs}"),
Err(e) => warn(&format!("Failed to get logs for {pod_name}: {e}")),
}
}
}
Ok(())
}
/// Print raw pod output in YAML or JSON format.
pub async fn cmd_get(target: &str, output: &str) -> Result<()> {
let (ns_opt, name_opt) = parse_target(Some(target))?;
let ns = match ns_opt {
Some(n) if !n.is_empty() => n,
_ => bail!("get requires namespace/name, e.g. 'sunbeam get ory/kratos-abc'"),
};
let name = match name_opt {
Some(n) => n,
None => bail!("get requires namespace/name, e.g. 'sunbeam get ory/kratos-abc'"),
};
let client = get_client().await?;
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
let pod = api
.get_opt(name)
.await?
.ok_or_else(|| SunbeamError::kube(format!("Pod {ns}/{name} not found.")))?;
let text = match output {
"json" => serde_json::to_string_pretty(&pod)?,
_ => serde_yaml::to_string(&pod)?,
};
println!("{text}");
Ok(())
}
/// Restart deployments. None=all, 'ory'=namespace, 'ory/kratos'=specific.
pub async fn cmd_restart(target: Option<&str>) -> Result<()> {
step("Restarting services...");
let (ns_filter, svc_filter) = parse_target(target)?;
let matched: Vec<(&str, &str)> = match (ns_filter, svc_filter) {
(None, _) => SERVICES_TO_RESTART.to_vec(),
(Some(ns), None) => SERVICES_TO_RESTART
.iter()
.filter(|(n, _)| *n == ns)
.copied()
.collect(),
(Some(ns), Some(name)) => SERVICES_TO_RESTART
.iter()
.filter(|(n, d)| *n == ns && *d == name)
.copied()
.collect(),
};
if matched.is_empty() {
warn(&format!(
"No matching services for target: {}",
target.unwrap_or("(none)")
));
return Ok(());
}
for (ns, dep) in &matched {
if let Err(e) = kube_rollout_restart(ns, dep).await {
warn(&format!("Failed to restart {ns}/{dep}: {e}"));
}
}
ok("Done.");
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_managed_ns_contains_expected() {
assert!(MANAGED_NS.contains(&"ory"));
assert!(MANAGED_NS.contains(&"data"));
assert!(MANAGED_NS.contains(&"devtools"));
assert!(MANAGED_NS.contains(&"ingress"));
assert!(MANAGED_NS.contains(&"lasuite"));
assert!(MANAGED_NS.contains(&"matrix"));
assert!(MANAGED_NS.contains(&"media"));
assert!(MANAGED_NS.contains(&"storage"));
assert!(MANAGED_NS.contains(&"monitoring"));
assert!(MANAGED_NS.contains(&"vault-secrets-operator"));
assert_eq!(MANAGED_NS.len(), 10);
}
#[test]
fn test_services_to_restart_contains_expected() {
assert!(SERVICES_TO_RESTART.contains(&("ory", "hydra")));
assert!(SERVICES_TO_RESTART.contains(&("ory", "kratos")));
assert!(SERVICES_TO_RESTART.contains(&("ory", "login-ui")));
assert!(SERVICES_TO_RESTART.contains(&("devtools", "gitea")));
assert!(SERVICES_TO_RESTART.contains(&("storage", "seaweedfs-filer")));
assert!(SERVICES_TO_RESTART.contains(&("lasuite", "hive")));
assert!(SERVICES_TO_RESTART.contains(&("matrix", "tuwunel")));
assert!(SERVICES_TO_RESTART.contains(&("media", "livekit-server")));
assert_eq!(SERVICES_TO_RESTART.len(), 13);
}
#[test]
fn test_icon_for_status() {
assert_eq!(icon_for_status("Running"), "\u{2713}");
assert_eq!(icon_for_status("Completed"), "\u{2713}");
assert_eq!(icon_for_status("Succeeded"), "\u{2713}");
assert_eq!(icon_for_status("Pending"), "\u{25cb}");
assert_eq!(icon_for_status("Failed"), "\u{2717}");
assert_eq!(icon_for_status("Unknown"), "?");
assert_eq!(icon_for_status("CrashLoopBackOff"), "?");
}
#[test]
fn test_restart_filter_namespace() {
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART
.iter()
.filter(|(n, _)| *n == "ory")
.copied()
.collect();
assert_eq!(matched.len(), 3);
assert!(matched.contains(&("ory", "hydra")));
assert!(matched.contains(&("ory", "kratos")));
assert!(matched.contains(&("ory", "login-ui")));
}
#[test]
fn test_restart_filter_specific() {
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART
.iter()
.filter(|(n, d)| *n == "ory" && *d == "kratos")
.copied()
.collect();
assert_eq!(matched.len(), 1);
assert_eq!(matched[0], ("ory", "kratos"));
}
#[test]
fn test_restart_filter_no_match() {
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART
.iter()
.filter(|(n, d)| *n == "nonexistent" && *d == "nosuch")
.copied()
.collect();
assert!(matched.is_empty());
}
#[test]
fn test_restart_filter_all() {
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART.to_vec();
assert_eq!(matched.len(), 13);
}
#[test]
fn test_pod_ready_string_format() {
// Verify format: "N/M"
let ready = "2/3";
let parts: Vec<&str> = ready.split('/').collect();
assert_eq!(parts.len(), 2);
assert_ne!(parts[0], parts[1]); // unhealthy
}
#[test]
fn test_unhealthy_detection_by_ready_ratio() {
// Simulate the ready ratio check used in cmd_status
let ready = "1/2";
let status = "Running";
let mut unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
if !unhealthy && status == "Running" && ready.contains('/') {
let parts: Vec<&str> = ready.split('/').collect();
if parts.len() == 2 && parts[0] != parts[1] {
unhealthy = true;
}
}
assert!(unhealthy);
}
#[test]
fn test_healthy_detection_by_ready_ratio() {
let ready = "2/2";
let status = "Running";
let mut unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
if !unhealthy && status == "Running" && ready.contains('/') {
let parts: Vec<&str> = ready.split('/').collect();
if parts.len() == 2 && parts[0] != parts[1] {
unhealthy = true;
}
}
assert!(!unhealthy);
}
#[test]
fn test_completed_pods_are_healthy() {
let status = "Completed";
let unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
assert!(!unhealthy);
}
#[test]
fn test_pending_pods_are_unhealthy() {
let status = "Pending";
let unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
assert!(unhealthy);
}
}

180
src/tools.rs Normal file
View File

@@ -0,0 +1,180 @@
use crate::error::{Result, ResultExt};
use std::path::PathBuf;
static KUSTOMIZE_BIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/kustomize"));
static HELM_BIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/helm"));
fn cache_dir() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")))
.join("sunbeam")
.join("bin")
}
/// Extract an embedded binary to the cache directory if not already present.
fn extract_embedded(data: &[u8], name: &str) -> Result<PathBuf> {
let dir = cache_dir();
std::fs::create_dir_all(&dir)
.with_ctx(|| format!("Failed to create cache dir: {}", dir.display()))?;
let dest = dir.join(name);
// Skip if already extracted and same size
if dest.exists() {
if let Ok(meta) = std::fs::metadata(&dest) {
if meta.len() == data.len() as u64 {
return Ok(dest);
}
}
}
std::fs::write(&dest, data)
.with_ctx(|| format!("Failed to write {}", dest.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
}
Ok(dest)
}
/// Ensure kustomize is extracted and return its path.
pub fn ensure_kustomize() -> Result<PathBuf> {
extract_embedded(KUSTOMIZE_BIN, "kustomize")
}
/// Ensure helm is extracted and return its path.
pub fn ensure_helm() -> Result<PathBuf> {
extract_embedded(HELM_BIN, "helm")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kustomize_bin_is_non_empty() {
assert!(
KUSTOMIZE_BIN.len() > 0,
"Embedded kustomize binary should not be empty"
);
}
#[test]
fn helm_bin_is_non_empty() {
assert!(
HELM_BIN.len() > 0,
"Embedded helm binary should not be empty"
);
}
#[test]
fn kustomize_bin_has_reasonable_size() {
// kustomize binary should be at least 1 MB
assert!(
KUSTOMIZE_BIN.len() > 1_000_000,
"Embedded kustomize binary seems too small: {} bytes",
KUSTOMIZE_BIN.len()
);
}
#[test]
fn helm_bin_has_reasonable_size() {
// helm binary should be at least 1 MB
assert!(
HELM_BIN.len() > 1_000_000,
"Embedded helm binary seems too small: {} bytes",
HELM_BIN.len()
);
}
#[test]
fn cache_dir_ends_with_sunbeam_bin() {
let dir = cache_dir();
assert!(
dir.ends_with("sunbeam/bin"),
"cache_dir() should end with sunbeam/bin, got: {}",
dir.display()
);
}
#[test]
fn cache_dir_is_absolute() {
let dir = cache_dir();
assert!(
dir.is_absolute(),
"cache_dir() should return an absolute path, got: {}",
dir.display()
);
}
#[test]
fn ensure_kustomize_returns_valid_path() {
let path = ensure_kustomize().expect("ensure_kustomize should succeed");
assert!(
path.ends_with("kustomize"),
"ensure_kustomize path should end with 'kustomize', got: {}",
path.display()
);
assert!(path.exists(), "kustomize binary should exist at: {}", path.display());
}
#[test]
fn ensure_helm_returns_valid_path() {
let path = ensure_helm().expect("ensure_helm should succeed");
assert!(
path.ends_with("helm"),
"ensure_helm path should end with 'helm', got: {}",
path.display()
);
assert!(path.exists(), "helm binary should exist at: {}", path.display());
}
#[test]
fn ensure_kustomize_is_idempotent() {
let path1 = ensure_kustomize().expect("first call should succeed");
let path2 = ensure_kustomize().expect("second call should succeed");
assert_eq!(path1, path2, "ensure_kustomize should return the same path on repeated calls");
}
#[test]
fn ensure_helm_is_idempotent() {
let path1 = ensure_helm().expect("first call should succeed");
let path2 = ensure_helm().expect("second call should succeed");
assert_eq!(path1, path2, "ensure_helm should return the same path on repeated calls");
}
#[test]
fn extracted_kustomize_is_executable() {
let path = ensure_kustomize().expect("ensure_kustomize should succeed");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&path)
.expect("should read metadata")
.permissions();
assert!(
perms.mode() & 0o111 != 0,
"kustomize binary should be executable"
);
}
}
#[test]
fn extracted_helm_is_executable() {
let path = ensure_helm().expect("ensure_helm should succeed");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&path)
.expect("should read metadata")
.permissions();
assert!(
perms.mode() & 0o111 != 0,
"helm binary should be executable"
);
}
}
}

443
src/update.rs Normal file
View File

@@ -0,0 +1,443 @@
use crate::error::{Result, ResultExt};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
/// Compile-time commit SHA set by build.rs.
pub const COMMIT: &str = env!("SUNBEAM_COMMIT");
/// Compile-time build target triple set by build.rs.
pub const TARGET: &str = env!("SUNBEAM_TARGET");
/// Compile-time build date set by build.rs.
pub const BUILD_DATE: &str = env!("SUNBEAM_BUILD_DATE");
/// Artifact name prefix for this platform.
fn artifact_name() -> String {
format!("sunbeam-{TARGET}")
}
/// Resolve the forge URL (Gitea instance).
///
/// TODO: Once kube.rs exposes `get_domain()`, derive this automatically as
/// `https://src.{domain}`. For now we read the SUNBEAM_FORGE_URL environment
/// variable with a sensible fallback.
fn forge_url() -> String {
if let Ok(url) = std::env::var("SUNBEAM_FORGE_URL") {
return url.trim_end_matches('/').to_string();
}
// Derive from production_host domain in config
let config = crate::config::load_config();
if !config.production_host.is_empty() {
// production_host is like "user@server.example.com" — extract domain
let host = config
.production_host
.split('@')
.last()
.unwrap_or(&config.production_host);
// Strip any leading subdomain segments that look like a hostname to get the base domain.
// For a host like "admin.sunbeam.pt", the forge is "src.sunbeam.pt".
// Heuristic: use the last two segments as the domain.
let parts: Vec<&str> = host.split('.').collect();
if parts.len() >= 2 {
let domain = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
return format!("https://src.{domain}");
}
}
// Hard fallback — will fail at runtime if not configured, which is fine.
String::new()
}
/// Cache file location for background update checks.
fn update_cache_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".local/share"))
.join("sunbeam")
.join("update-check.json")
}
// ---------------------------------------------------------------------------
// Gitea API response types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct BranchResponse {
commit: BranchCommit,
}
#[derive(Debug, Deserialize)]
struct BranchCommit {
id: String,
}
#[derive(Debug, Deserialize)]
struct ArtifactListResponse {
artifacts: Vec<Artifact>,
}
#[derive(Debug, Deserialize)]
struct Artifact {
name: String,
id: u64,
}
// ---------------------------------------------------------------------------
// Update-check cache
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize)]
struct UpdateCache {
last_check: DateTime<Utc>,
latest_commit: String,
current_commit: String,
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Print version information.
pub fn cmd_version() {
println!("sunbeam {COMMIT}");
println!(" target: {TARGET}");
println!(" built: {BUILD_DATE}");
}
/// Self-update from the latest mainline commit via Gitea CI artifacts.
pub async fn cmd_update() -> Result<()> {
let base = forge_url();
if base.is_empty() {
bail!(
"Forge URL not configured. Set SUNBEAM_FORGE_URL or configure a \
production host via `sunbeam config set --host`."
);
}
crate::output::step("Checking for updates...");
let client = reqwest::Client::new();
// 1. Check latest commit on mainline
let latest_commit = fetch_latest_commit(&client, &base).await?;
let short_latest = &latest_commit[..std::cmp::min(8, latest_commit.len())];
crate::output::ok(&format!("Current: {COMMIT}"));
crate::output::ok(&format!("Latest: {short_latest}"));
if latest_commit.starts_with(COMMIT) || COMMIT.starts_with(&latest_commit[..std::cmp::min(COMMIT.len(), latest_commit.len())]) {
crate::output::ok("Already up to date.");
return Ok(());
}
// 2. Find the CI artifact for our platform
crate::output::step("Downloading update...");
let wanted = artifact_name();
let artifacts = fetch_artifacts(&client, &base).await?;
let binary_artifact = artifacts
.iter()
.find(|a| a.name == wanted)
.with_ctx(|| format!("No artifact found for platform '{wanted}'"))?;
let checksums_artifact = artifacts
.iter()
.find(|a| a.name == "checksums.txt" || a.name == "checksums");
// 3. Download the binary
let binary_url = format!(
"{base}/api/v1/repos/studio/cli/actions/artifacts/{id}",
id = binary_artifact.id
);
let binary_bytes = client
.get(&binary_url)
.send()
.await?
.error_for_status()
.ctx("Failed to download binary artifact")?
.bytes()
.await?;
crate::output::ok(&format!("Downloaded {} bytes", binary_bytes.len()));
// 4. Verify SHA256 if checksums artifact exists
if let Some(checksums) = checksums_artifact {
let checksums_url = format!(
"{base}/api/v1/repos/studio/cli/actions/artifacts/{id}",
id = checksums.id
);
let checksums_text = client
.get(&checksums_url)
.send()
.await?
.error_for_status()
.ctx("Failed to download checksums")?
.text()
.await?;
verify_checksum(&binary_bytes, &wanted, &checksums_text)?;
crate::output::ok("SHA256 checksum verified.");
} else {
crate::output::warn("No checksums artifact found; skipping verification.");
}
// 5. Atomic self-replace
crate::output::step("Installing update...");
let current_exe = std::env::current_exe().ctx("Failed to determine current executable path")?;
atomic_replace(&current_exe, &binary_bytes)?;
crate::output::ok(&format!(
"Updated sunbeam {COMMIT} -> {short_latest}"
));
// Update the cache so background check knows we are current
let _ = write_cache(&UpdateCache {
last_check: Utc::now(),
latest_commit: latest_commit.clone(),
current_commit: latest_commit,
});
Ok(())
}
/// Background update check. Returns a notification message if a newer version
/// is available, or None if up-to-date / on error / checked too recently.
///
/// This function never blocks for long and never returns errors — it silently
/// returns None on any failure.
pub async fn check_update_background() -> Option<String> {
// Read cache
let cache_path = update_cache_path();
if let Ok(data) = fs::read_to_string(&cache_path) {
if let Ok(cache) = serde_json::from_str::<UpdateCache>(&data) {
let age = Utc::now().signed_duration_since(cache.last_check);
if age.num_seconds() < 3600 {
// Checked recently — just compare cached values
if cache.latest_commit.starts_with(COMMIT)
|| COMMIT.starts_with(&cache.latest_commit[..std::cmp::min(COMMIT.len(), cache.latest_commit.len())])
{
return None; // up to date
}
let short = &cache.latest_commit[..std::cmp::min(8, cache.latest_commit.len())];
return Some(format!(
"A newer version of sunbeam is available ({short}). Run `sunbeam update` to upgrade."
));
}
}
}
// Time to check again
let base = forge_url();
if base.is_empty() {
return None;
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.ok()?;
let latest = fetch_latest_commit(&client, &base).await.ok()?;
let cache = UpdateCache {
last_check: Utc::now(),
latest_commit: latest.clone(),
current_commit: COMMIT.to_string(),
};
let _ = write_cache(&cache);
if latest.starts_with(COMMIT)
|| COMMIT.starts_with(&latest[..std::cmp::min(COMMIT.len(), latest.len())])
{
return None;
}
let short = &latest[..std::cmp::min(8, latest.len())];
Some(format!(
"A newer version of sunbeam is available ({short}). Run `sunbeam update` to upgrade."
))
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Fetch the latest commit SHA on the mainline branch.
async fn fetch_latest_commit(client: &reqwest::Client, forge_url: &str) -> Result<String> {
let url = format!("{forge_url}/api/v1/repos/studio/cli/branches/mainline");
let resp: BranchResponse = client
.get(&url)
.send()
.await?
.error_for_status()
.ctx("Failed to query mainline branch")?
.json()
.await?;
Ok(resp.commit.id)
}
/// Fetch the list of CI artifacts for the repo.
async fn fetch_artifacts(client: &reqwest::Client, forge_url: &str) -> Result<Vec<Artifact>> {
let url = format!("{forge_url}/api/v1/repos/studio/cli/actions/artifacts");
let resp: ArtifactListResponse = client
.get(&url)
.send()
.await?
.error_for_status()
.ctx("Failed to query CI artifacts")?
.json()
.await?;
Ok(resp.artifacts)
}
/// Verify that the downloaded binary matches the expected SHA256 from checksums text.
///
/// Checksums file format (one per line):
/// <hex-sha256> <filename>
fn verify_checksum(binary: &[u8], artifact_name: &str, checksums_text: &str) -> Result<()> {
let actual = {
let mut hasher = Sha256::new();
hasher.update(binary);
format!("{:x}", hasher.finalize())
};
for line in checksums_text.lines() {
// Split on whitespace — format is "<hash> <name>" or "<hash> <name>"
let mut parts = line.split_whitespace();
if let (Some(expected_hash), Some(name)) = (parts.next(), parts.next()) {
if name == artifact_name {
if actual != expected_hash {
bail!(
"Checksum mismatch for {artifact_name}:\n expected: {expected_hash}\n actual: {actual}"
);
}
return Ok(());
}
}
}
bail!("No checksum entry found for '{artifact_name}' in checksums file");
}
/// Atomically replace the binary at `target` with `new_bytes`.
///
/// Writes to a temp file in the same directory, sets executable permissions,
/// then renames over the original.
fn atomic_replace(target: &std::path::Path, new_bytes: &[u8]) -> Result<()> {
let parent = target
.parent()
.ctx("Cannot determine parent directory of current executable")?;
let tmp_path = parent.join(".sunbeam-update.tmp");
// Write new binary
fs::write(&tmp_path, new_bytes).ctx("Failed to write temporary update file")?;
// Set executable permissions (unix)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755))
.ctx("Failed to set executable permissions")?;
}
// Atomic rename
fs::rename(&tmp_path, target).ctx("Failed to replace current executable")?;
Ok(())
}
/// Write the update-check cache to disk.
fn write_cache(cache: &UpdateCache) -> Result<()> {
let path = update_cache_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(cache)?;
fs::write(&path, json)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_consts() {
// COMMIT, TARGET, BUILD_DATE are set at compile time
assert!(!COMMIT.is_empty());
assert!(!TARGET.is_empty());
assert!(!BUILD_DATE.is_empty());
}
#[test]
fn test_artifact_name() {
let name = artifact_name();
assert!(name.starts_with("sunbeam-"));
assert!(name.contains(TARGET));
}
#[test]
fn test_verify_checksum_ok() {
let data = b"hello world";
let mut hasher = Sha256::new();
hasher.update(data);
let hash = format!("{:x}", hasher.finalize());
let checksums = format!("{hash} sunbeam-test");
assert!(verify_checksum(data, "sunbeam-test", &checksums).is_ok());
}
#[test]
fn test_verify_checksum_mismatch() {
let checksums = "0000000000000000000000000000000000000000000000000000000000000000 sunbeam-test";
assert!(verify_checksum(b"hello", "sunbeam-test", checksums).is_err());
}
#[test]
fn test_verify_checksum_missing_entry() {
let checksums = "abcdef1234567890 sunbeam-other";
assert!(verify_checksum(b"hello", "sunbeam-test", checksums).is_err());
}
#[test]
fn test_update_cache_path() {
let path = update_cache_path();
assert!(path.to_string_lossy().contains("sunbeam"));
assert!(path.to_string_lossy().ends_with("update-check.json"));
}
#[test]
fn test_cache_roundtrip() {
let cache = UpdateCache {
last_check: Utc::now(),
latest_commit: "abc12345".to_string(),
current_commit: "def67890".to_string(),
};
let json = serde_json::to_string(&cache).unwrap();
let loaded: UpdateCache = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.latest_commit, "abc12345");
assert_eq!(loaded.current_commit, "def67890");
}
#[tokio::test]
async fn test_check_update_background_returns_none_when_forge_url_empty() {
// When SUNBEAM_FORGE_URL is unset and there is no production_host config,
// forge_url() returns "" and check_update_background should return None
// without making any network requests.
// Clear the env var to ensure we hit the empty-URL path.
// SAFETY: This test is not run concurrently with other tests that depend on this env var.
unsafe { std::env::remove_var("SUNBEAM_FORGE_URL") };
// Note: this test assumes no production_host is configured in the test
// environment, which is the default for CI/dev. If forge_url() returns
// a non-empty string (e.g. from config), the test may still pass because
// the background check silently returns None on network errors.
let result = check_update_background().await;
// Either None (empty forge URL or network error) — never panics.
// The key property: this completes quickly without hanging.
drop(result);
}
}

1157
src/users.rs Normal file

File diff suppressed because it is too large Load Diff

65
sunbeam-sdk/Cargo.toml Normal file
View File

@@ -0,0 +1,65 @@
[package]
name = "sunbeam-sdk"
version = "0.1.0"
edition = "2024"
description = "Sunbeam SDK — reusable library for cluster management"
[features]
default = []
cli = ["clap"]
[dependencies]
# Core
thiserror = "2"
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive"], optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
tracing = "0.1"
# Kubernetes
kube = { version = "0.99", features = ["client", "runtime", "derive", "ws"] }
k8s-openapi = { version = "0.24", features = ["v1_32"] }
# HTTP + TLS
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] }
# SSH
russh = "0.46"
russh-keys = "0.46"
# Crypto
rsa = "0.9"
pkcs8 = { version = "0.10", features = ["pem"] }
pkcs1 = { version = "0.7", features = ["pem"] }
sha2 = "0.10"
hmac = "0.12"
base64 = "0.22"
rand = "0.8"
# Certificate generation
rcgen = "0.14"
# SMTP
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
# Archive handling
flate2 = "1"
tar = "0.4"
# Async
futures = "0.3"
tokio-stream = "0.1"
# Utility
tempfile = "3"
dirs = "5"
chrono = { version = "0.4", features = ["serde"] }
[build-dependencies]
reqwest = { version = "0.12", features = ["blocking", "rustls-tls"] }
sha2 = "0.10"
flate2 = "1"
tar = "0.4"
chrono = "0.4"

132
sunbeam-sdk/build.rs Normal file
View File

@@ -0,0 +1,132 @@
use flate2::read::GzDecoder;
use std::env;
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use std::process::Command;
use tar::Archive;
const KUSTOMIZE_VERSION: &str = "v5.8.1";
const HELM_VERSION: &str = "v4.1.0";
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let target = env::var("TARGET").unwrap_or_default();
let (os, arch) = parse_target(&target);
download_and_embed("kustomize", KUSTOMIZE_VERSION, &os, &arch, &out_dir);
download_and_embed("helm", HELM_VERSION, &os, &arch, &out_dir);
// Set version info from git
let commit = git_commit_sha();
println!("cargo:rustc-env=SUNBEAM_COMMIT={commit}");
// Build target triple and build date
println!("cargo:rustc-env=SUNBEAM_TARGET={target}");
let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
println!("cargo:rustc-env=SUNBEAM_BUILD_DATE={date}");
// Rebuild if git HEAD changes (workspace root is two levels up)
println!("cargo:rerun-if-changed=../../.git/HEAD");
}
fn parse_target(target: &str) -> (String, String) {
let os = if target.contains("darwin") {
"darwin"
} else if target.contains("linux") {
"linux"
} else if cfg!(target_os = "macos") {
"darwin"
} else {
"linux"
};
let arch = if target.contains("aarch64") || target.contains("arm64") {
"arm64"
} else if target.contains("x86_64") || target.contains("amd64") {
"amd64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"amd64"
};
(os.to_string(), arch.to_string())
}
fn download_and_embed(tool: &str, version: &str, os: &str, arch: &str, out_dir: &PathBuf) {
let dest = out_dir.join(tool);
if dest.exists() {
return;
}
let url = match tool {
"kustomize" => format!(
"https://github.com/kubernetes-sigs/kustomize/releases/download/\
kustomize%2F{version}/kustomize_{version}_{os}_{arch}.tar.gz"
),
"helm" => format!(
"https://get.helm.sh/helm-{version}-{os}-{arch}.tar.gz"
),
_ => panic!("Unknown tool: {tool}"),
};
let extract_path = match tool {
"kustomize" => "kustomize".to_string(),
"helm" => format!("{os}-{arch}/helm"),
_ => unreachable!(),
};
eprintln!("cargo:warning=Downloading {tool} {version} for {os}/{arch}...");
let response = reqwest::blocking::get(&url)
.unwrap_or_else(|e| panic!("Failed to download {tool}: {e}"));
let bytes = response
.bytes()
.unwrap_or_else(|e| panic!("Failed to read {tool} response: {e}"));
let decoder = GzDecoder::new(&bytes[..]);
let mut archive = Archive::new(decoder);
for entry in archive.entries().expect("Failed to read tar entries") {
let mut entry = entry.expect("Failed to read tar entry");
let path = entry
.path()
.expect("Failed to read entry path")
.to_path_buf();
if path.to_string_lossy() == extract_path {
let mut data = Vec::new();
entry
.read_to_end(&mut data)
.expect("Failed to read binary");
fs::write(&dest, &data).expect("Failed to write binary");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dest, fs::Permissions::from_mode(0o755))
.expect("Failed to set permissions");
}
eprintln!("cargo:warning=Embedded {tool} ({} bytes)", data.len());
return;
}
}
panic!("Could not find {extract_path} in {tool} archive");
}
fn git_commit_sha() -> String {
Command::new("git")
.args(["rev-parse", "--short=8", "HEAD"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string())
}

952
sunbeam-sdk/src/auth/mod.rs Normal file
View File

@@ -0,0 +1,952 @@
//! OAuth2 Authorization Code flow with PKCE for CLI authentication against Hydra.
use crate::error::{Result, ResultExt, SunbeamError};
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
// ---------------------------------------------------------------------------
// Token cache data
// ---------------------------------------------------------------------------
/// Cached OAuth2 tokens persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthTokens {
pub access_token: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
pub domain: String,
/// Gitea personal access token (created during auth login).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitea_token: Option<String>,
}
/// Default client ID when the K8s secret is unavailable.
const DEFAULT_CLIENT_ID: &str = "sunbeam-cli";
// ---------------------------------------------------------------------------
// Cache file helpers
// ---------------------------------------------------------------------------
/// Cache path for auth tokens — per-domain so multiple environments work.
fn cache_path_for_domain(domain: &str) -> PathBuf {
let dir = dirs::data_dir()
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".local/share")
})
.join("sunbeam")
.join("auth");
if domain.is_empty() {
dir.join("default.json")
} else {
// Sanitize domain for filename
let safe = domain.replace(['/', '\\', ':'], "_");
dir.join(format!("{safe}.json"))
}
}
fn cache_path() -> PathBuf {
let domain = crate::config::domain();
cache_path_for_domain(domain)
}
fn read_cache() -> Result<AuthTokens> {
let path = cache_path();
let content = std::fs::read_to_string(&path).map_err(|e| {
SunbeamError::Identity(format!("No cached auth tokens ({}): {e}", path.display()))
})?;
let tokens: AuthTokens = serde_json::from_str(&content)
.ctx("Failed to parse cached auth tokens")?;
Ok(tokens)
}
fn write_cache(tokens: &AuthTokens) -> Result<()> {
let path = cache_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_ctx(|| format!("Failed to create auth cache dir: {}", parent.display()))?;
}
let content = serde_json::to_string_pretty(tokens)?;
std::fs::write(&path, &content)
.with_ctx(|| format!("Failed to write auth cache to {}", path.display()))?;
// Set 0600 permissions on unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms)
.with_ctx(|| format!("Failed to set permissions on {}", path.display()))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// PKCE
// ---------------------------------------------------------------------------
/// Generate a PKCE code_verifier and code_challenge (S256).
fn generate_pkce() -> (String, String) {
let verifier_bytes: [u8; 32] = rand::random();
let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
let challenge = {
let hash = Sha256::digest(verifier.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash)
};
(verifier, challenge)
}
/// Generate a random state parameter for OAuth2.
fn generate_state() -> String {
let bytes: [u8; 16] = rand::random();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
// ---------------------------------------------------------------------------
// OIDC discovery
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct OidcDiscovery {
authorization_endpoint: String,
token_endpoint: String,
}
/// Resolve the domain for authentication, trying multiple sources.
async fn resolve_domain(explicit: Option<&str>) -> Result<String> {
// 1. Explicit --domain flag
if let Some(d) = explicit {
if !d.is_empty() {
return Ok(d.to_string());
}
}
// 2. Active context domain (set by cli::dispatch from config)
let ctx_domain = crate::config::domain();
if !ctx_domain.is_empty() {
return Ok(ctx_domain.to_string());
}
// 3. Cached token domain (already logged in)
if let Ok(tokens) = read_cache() {
if !tokens.domain.is_empty() {
crate::output::ok(&format!("Using cached domain: {}", tokens.domain));
return Ok(tokens.domain);
}
}
// 4. Try cluster discovery (may fail if not connected)
match crate::kube::get_domain().await {
Ok(d) if !d.is_empty() && !d.starts_with('.') => return Ok(d),
_ => {}
}
Err(SunbeamError::config(
"Could not determine domain. Use --domain flag, or configure with:\n \
sunbeam config set --host user@your-server.example.com",
))
}
async fn discover_oidc(domain: &str) -> Result<OidcDiscovery> {
let url = format!("https://auth.{domain}/.well-known/openid-configuration");
let client = reqwest::Client::new();
let resp = client
.get(&url)
.send()
.await
.with_ctx(|| format!("Failed to fetch OIDC discovery from {url}"))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"OIDC discovery returned HTTP {}",
resp.status()
)));
}
let discovery: OidcDiscovery = resp
.json()
.await
.ctx("Failed to parse OIDC discovery response")?;
Ok(discovery)
}
// ---------------------------------------------------------------------------
// Token exchange / refresh
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<i64>,
#[serde(default)]
id_token: Option<String>,
}
async fn exchange_code(
token_endpoint: &str,
code: &str,
redirect_uri: &str,
client_id: &str,
code_verifier: &str,
) -> Result<TokenResponse> {
let client = reqwest::Client::new();
let resp = client
.post(token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("client_id", client_id),
("code_verifier", code_verifier),
])
.send()
.await
.ctx("Failed to exchange authorization code")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(SunbeamError::identity(format!(
"Token exchange failed (HTTP {status}): {body}"
)));
}
let token_resp: TokenResponse = resp.json().await.ctx("Failed to parse token response")?;
Ok(token_resp)
}
/// Refresh an access token using a refresh token.
async fn refresh_token(cached: &AuthTokens) -> Result<AuthTokens> {
let discovery = discover_oidc(&cached.domain).await?;
// Try to get client_id from K8s, fall back to default
let client_id = resolve_client_id().await;
let client = reqwest::Client::new();
let resp = client
.post(&discovery.token_endpoint)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &cached.refresh_token),
("client_id", &client_id),
])
.send()
.await
.ctx("Failed to refresh token")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(SunbeamError::identity(format!(
"Token refresh failed (HTTP {status}): {body}"
)));
}
let token_resp: TokenResponse = resp
.json()
.await
.ctx("Failed to parse refresh token response")?;
let expires_at = Utc::now()
+ chrono::Duration::seconds(token_resp.expires_in.unwrap_or(3600));
let new_tokens = AuthTokens {
access_token: token_resp.access_token,
refresh_token: token_resp
.refresh_token
.unwrap_or_else(|| cached.refresh_token.clone()),
expires_at,
id_token: token_resp.id_token.or_else(|| cached.id_token.clone()),
domain: cached.domain.clone(),
gitea_token: cached.gitea_token.clone(),
};
write_cache(&new_tokens)?;
Ok(new_tokens)
}
// ---------------------------------------------------------------------------
// Client ID resolution
// ---------------------------------------------------------------------------
/// Try to read the client_id from K8s secret `oidc-sunbeam-cli` in `ory` namespace.
/// Falls back to the default client ID.
async fn resolve_client_id() -> String {
// The OAuth2Client is pre-created with a known client_id matching
// DEFAULT_CLIENT_ID ("sunbeam-cli") via a pre-seeded K8s secret.
// No cluster access needed.
DEFAULT_CLIENT_ID.to_string()
}
// ---------------------------------------------------------------------------
// JWT payload decoding (minimal, no verification)
// ---------------------------------------------------------------------------
/// Decode the payload of a JWT (middle segment) without verification.
/// Returns the parsed JSON value.
fn decode_jwt_payload(token: &str) -> Result<serde_json::Value> {
let parts: Vec<&str> = token.splitn(3, '.').collect();
if parts.len() < 2 {
return Err(SunbeamError::identity("Invalid JWT: not enough segments"));
}
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[1])
.ctx("Failed to base64-decode JWT payload")?;
let payload: serde_json::Value =
serde_json::from_slice(&payload_bytes).ctx("Failed to parse JWT payload as JSON")?;
Ok(payload)
}
/// Extract the email claim from an id_token.
fn extract_email(id_token: &str) -> Option<String> {
let payload = decode_jwt_payload(id_token).ok()?;
payload
.get("email")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
// ---------------------------------------------------------------------------
// HTTP callback server
// ---------------------------------------------------------------------------
/// Parsed callback parameters from the OAuth2 redirect.
struct CallbackParams {
code: String,
#[allow(dead_code)]
state: String,
}
/// Bind a TCP listener for the OAuth2 callback, preferring ports 9876-9880.
async fn bind_callback_listener() -> Result<(tokio::net::TcpListener, u16)> {
for port in 9876..=9880 {
if let Ok(listener) = tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
return Ok((listener, port));
}
}
// Fall back to ephemeral port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.ctx("Failed to bind callback listener")?;
let port = listener.local_addr().ctx("No local address")?.port();
Ok((listener, port))
}
/// Wait for a single HTTP callback request, extract code and state, send HTML response.
async fn wait_for_callback(
listener: tokio::net::TcpListener,
expected_state: &str,
redirect_url: Option<&str>,
) -> Result<CallbackParams> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Wait up to 5 minutes for the callback, or until Ctrl+C
let accept_result = tokio::time::timeout(
std::time::Duration::from_secs(300),
listener.accept(),
)
.await
.map_err(|_| SunbeamError::identity("Login timed out (5 min). Try again with `sunbeam auth login`."))?;
let (mut stream, _) = accept_result.ctx("Failed to accept callback connection")?;
let mut buf = vec![0u8; 4096];
let n = stream
.read(&mut buf)
.await
.ctx("Failed to read callback request")?;
let request = String::from_utf8_lossy(&buf[..n]);
// Parse the GET request line: "GET /callback?code=...&state=... HTTP/1.1"
let request_line = request
.lines()
.next()
.ctx("Empty callback request")?;
let path = request_line
.split_whitespace()
.nth(1)
.ctx("No path in callback request")?;
// Parse query params
let query = path
.split('?')
.nth(1)
.ctx("No query params in callback")?;
let mut code = None;
let mut state = None;
for param in query.split('&') {
let mut kv = param.splitn(2, '=');
match (kv.next(), kv.next()) {
(Some("code"), Some(v)) => code = Some(v.to_string()),
(Some("state"), Some(v)) => state = Some(v.to_string()),
_ => {}
}
}
let code = code.ok_or_else(|| SunbeamError::identity("No 'code' in callback"))?;
let state = state.ok_or_else(|| SunbeamError::identity("No 'state' in callback"))?;
if state != expected_state {
return Err(SunbeamError::identity(
"OAuth2 state mismatch -- possible CSRF attack",
));
}
// Send success response — redirect to next step if provided, otherwise show done page
let response = if let Some(next_url) = redirect_url {
let html = format!(
"<!DOCTYPE html><html><head>\
<meta http-equiv='refresh' content='1;url={next_url}'>\
<style>\
body{{font-family:system-ui,sans-serif;display:flex;justify-content:center;\
align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}}\
.card{{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}}\
h2{{margin:0 0 1rem}}p{{color:#9ca3af}}a{{color:#d97706}}\
</style></head><body><div class='card'>\
<h2>SSO login successful</h2>\
<p>Redirecting to Gitea token setup...</p>\
<p><a href='{next_url}'>Click here if not redirected</a></p>\
</div></body></html>"
);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
)
} else {
let html = "\
<!DOCTYPE html><html><head><style>\
body{font-family:system-ui,sans-serif;display:flex;justify-content:center;\
align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}\
.card{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}\
h2{margin:0 0 1rem}p{color:#9ca3af}\
</style></head><body><div class='card'>\
<h2>Authentication successful</h2>\
<p>You can close this tab and return to the terminal.</p>\
</div></body></html>";
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
)
};
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
Ok(CallbackParams { code, state })
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Get a valid access token, refreshing if needed.
///
/// Returns the access token string ready for use in Authorization headers.
/// If no cached token exists or refresh fails, returns an error prompting
/// the user to run `sunbeam auth login`.
pub async fn get_token() -> Result<String> {
let cached = match read_cache() {
Ok(tokens) => tokens,
Err(_) => {
return Err(SunbeamError::identity(
"Not logged in. Run `sunbeam auth login` to authenticate.",
));
}
};
// Check if access token is still valid (>60s remaining)
let now = Utc::now();
if cached.expires_at > now + chrono::Duration::seconds(60) {
return Ok(cached.access_token);
}
// Try to refresh
if !cached.refresh_token.is_empty() {
match refresh_token(&cached).await {
Ok(new_tokens) => return Ok(new_tokens.access_token),
Err(e) => {
crate::output::warn(&format!("Token refresh failed: {e}"));
}
}
}
Err(SunbeamError::identity(
"Session expired. Run `sunbeam auth login` to re-authenticate.",
))
}
/// Interactive browser-based OAuth2 login.
/// SSO login — Hydra OIDC authorization code flow with PKCE.
/// `gitea_redirect`: if Some, the browser callback page auto-redirects to Gitea token page.
pub async fn cmd_auth_sso_login_with_redirect(
domain_override: Option<&str>,
gitea_redirect: Option<&str>,
) -> Result<()> {
crate::output::step("Authenticating with Hydra");
// Resolve domain: explicit flag > cached token domain > config > cluster discovery
let domain = resolve_domain(domain_override).await?;
crate::output::ok(&format!("Domain: {domain}"));
// OIDC discovery
let discovery = discover_oidc(&domain).await?;
// Resolve client_id
let client_id = resolve_client_id().await;
// Generate PKCE
let (code_verifier, code_challenge) = generate_pkce();
// Generate state
let state = generate_state();
// Bind callback listener
let (listener, port) = bind_callback_listener().await?;
let redirect_uri = format!("http://localhost:{port}/callback");
// Build authorization URL
let auth_url = format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&state={}",
discovery.authorization_endpoint,
urlencoding(&client_id),
urlencoding(&redirect_uri),
"openid%20email%20profile%20offline_access",
code_challenge,
state,
);
crate::output::ok("Opening browser for login...");
println!("\n {auth_url}\n");
// Try to open the browser
let _open_result = open_browser(&auth_url);
// Wait for callback
crate::output::ok("Waiting for authentication callback...");
let callback = wait_for_callback(listener, &state, gitea_redirect).await?;
// Exchange code for tokens
crate::output::ok("Exchanging authorization code for tokens...");
let token_resp = exchange_code(
&discovery.token_endpoint,
&callback.code,
&redirect_uri,
&client_id,
&code_verifier,
)
.await?;
let expires_at = Utc::now()
+ chrono::Duration::seconds(token_resp.expires_in.unwrap_or(3600));
let tokens = AuthTokens {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token.unwrap_or_default(),
expires_at,
id_token: token_resp.id_token.clone(),
domain: domain.clone(),
gitea_token: None,
};
// Print success with email if available
let email = tokens
.id_token
.as_ref()
.and_then(|t| extract_email(t));
if let Some(ref email) = email {
crate::output::ok(&format!("Logged in as {email}"));
} else {
crate::output::ok("Logged in successfully");
}
write_cache(&tokens)?;
Ok(())
}
/// SSO login — standalone (no redirect after callback).
pub async fn cmd_auth_sso_login(domain_override: Option<&str>) -> Result<()> {
cmd_auth_sso_login_with_redirect(domain_override, None).await
}
/// Gitea token login — opens the PAT creation page and prompts for the token.
pub async fn cmd_auth_git_login(domain_override: Option<&str>) -> Result<()> {
crate::output::step("Setting up Gitea API access");
let domain = resolve_domain(domain_override).await?;
let url = format!("https://src.{domain}/user/settings/applications");
crate::output::ok("Opening Gitea token page in your browser...");
crate::output::ok("Create a token with all scopes selected, then paste it below.");
println!("\n {url}\n");
let _ = open_browser(&url);
// Prompt for the token
eprint!(" Gitea token: ");
let mut token = String::new();
std::io::stdin()
.read_line(&mut token)
.ctx("Failed to read token from stdin")?;
let token = token.trim().to_string();
if token.is_empty() {
return Err(SunbeamError::identity("No token provided."));
}
// Verify the token works
let client = reqwest::Client::new();
let resp = client
.get(format!("https://src.{domain}/api/v1/user"))
.header("Authorization", format!("token {token}"))
.send()
.await
.ctx("Failed to verify Gitea token")?;
if !resp.status().is_success() {
return Err(SunbeamError::identity(format!(
"Gitea token is invalid (HTTP {}). Check the token and try again.",
resp.status()
)));
}
let user: serde_json::Value = resp.json().await?;
let login = user
.get("login")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
// Save to cache
let mut tokens = read_cache().unwrap_or_else(|_| AuthTokens {
access_token: String::new(),
refresh_token: String::new(),
expires_at: Utc::now(),
id_token: None,
domain: domain.clone(),
gitea_token: None,
});
tokens.gitea_token = Some(token);
if tokens.domain.is_empty() {
tokens.domain = domain;
}
write_cache(&tokens)?;
crate::output::ok(&format!("Gitea authenticated as {login}"));
Ok(())
}
/// Combined login — SSO first, then Gitea.
pub async fn cmd_auth_login_all(domain_override: Option<&str>) -> Result<()> {
// Resolve domain early so we can build the Gitea redirect URL
let domain = resolve_domain(domain_override).await?;
let gitea_url = format!("https://src.{domain}/user/settings/applications");
cmd_auth_sso_login_with_redirect(Some(&domain), Some(&gitea_url)).await?;
cmd_auth_git_login(Some(&domain)).await?;
Ok(())
}
/// Get the Gitea API token (for use by pm.rs).
pub fn get_gitea_token() -> Result<String> {
let tokens = read_cache().map_err(|_| {
SunbeamError::identity("Not logged in. Run `sunbeam auth login` first.")
})?;
tokens.gitea_token.ok_or_else(|| {
SunbeamError::identity(
"No Gitea token. Run `sunbeam auth login` or `sunbeam auth set-gitea-token <token>`.",
)
})
}
/// Remove cached auth tokens.
pub async fn cmd_auth_logout() -> Result<()> {
let path = cache_path();
if path.exists() {
std::fs::remove_file(&path)
.with_ctx(|| format!("Failed to remove {}", path.display()))?;
crate::output::ok("Logged out (cached tokens removed)");
} else {
crate::output::ok("Not logged in (no cached tokens to remove)");
}
Ok(())
}
/// Print current auth status.
pub async fn cmd_auth_status() -> Result<()> {
match read_cache() {
Ok(tokens) => {
let now = Utc::now();
let expired = tokens.expires_at <= now;
// Try to get email from id_token
let identity = tokens
.id_token
.as_deref()
.and_then(extract_email)
.unwrap_or_else(|| "unknown".to_string());
if expired {
crate::output::ok(&format!(
"Logged in as {identity} (token expired at {})",
tokens.expires_at.format("%Y-%m-%d %H:%M:%S UTC")
));
if !tokens.refresh_token.is_empty() {
crate::output::ok("Token can be refreshed automatically on next use");
}
} else {
crate::output::ok(&format!(
"Logged in as {identity} (token valid until {})",
tokens.expires_at.format("%Y-%m-%d %H:%M:%S UTC")
));
}
crate::output::ok(&format!("Domain: {}", tokens.domain));
}
Err(_) => {
crate::output::ok("Not logged in. Run `sunbeam auth login` to authenticate.");
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Utility helpers
// ---------------------------------------------------------------------------
/// Minimal percent-encoding for URL query parameters.
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push_str(&format!("%{:02X}", b));
}
}
}
out
}
/// Try to open a URL in the default browser.
fn open_browser(url: &str) -> std::result::Result<(), std::io::Error> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open").arg(url).spawn()?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open").arg(url).spawn()?;
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = url;
// No-op on unsupported platforms; URL is printed to the terminal.
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_pkce_generation() {
let (verifier, challenge) = generate_pkce();
// Verifier should be base64url-encoded 32 bytes -> 43 chars
assert_eq!(verifier.len(), 43);
// Challenge should be base64url-encoded SHA256 -> 43 chars
assert_eq!(challenge.len(), 43);
// Verify the challenge matches the verifier
let expected_hash = Sha256::digest(verifier.as_bytes());
let expected_challenge =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(expected_hash);
assert_eq!(challenge, expected_challenge);
// Two calls should produce different values
let (v2, c2) = generate_pkce();
assert_ne!(verifier, v2);
assert_ne!(challenge, c2);
}
#[test]
fn test_token_cache_roundtrip() {
let tokens = AuthTokens {
access_token: "access_abc".to_string(),
refresh_token: "refresh_xyz".to_string(),
expires_at: Utc::now() + Duration::hours(1),
id_token: Some("eyJhbGciOiJSUzI1NiJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.sig".to_string()),
domain: "sunbeam.pt".to_string(),
gitea_token: None,
};
let json = serde_json::to_string_pretty(&tokens).unwrap();
let deserialized: AuthTokens = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.access_token, "access_abc");
assert_eq!(deserialized.refresh_token, "refresh_xyz");
assert_eq!(deserialized.domain, "sunbeam.pt");
assert!(deserialized.id_token.is_some());
// Verify expires_at survives roundtrip (within 1 second tolerance)
let diff = (deserialized.expires_at - tokens.expires_at)
.num_milliseconds()
.abs();
assert!(diff < 1000, "expires_at drift: {diff}ms");
}
#[test]
fn test_token_cache_roundtrip_no_id_token() {
let tokens = AuthTokens {
access_token: "access".to_string(),
refresh_token: "refresh".to_string(),
expires_at: Utc::now() + Duration::hours(1),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let json = serde_json::to_string(&tokens).unwrap();
// id_token should be absent from the JSON when None
assert!(!json.contains("id_token"));
let deserialized: AuthTokens = serde_json::from_str(&json).unwrap();
assert!(deserialized.id_token.is_none());
}
#[test]
fn test_token_expiry_check_valid() {
let tokens = AuthTokens {
access_token: "valid".to_string(),
refresh_token: "refresh".to_string(),
expires_at: Utc::now() + Duration::hours(1),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let now = Utc::now();
// Token is valid: more than 60 seconds until expiry
assert!(tokens.expires_at > now + Duration::seconds(60));
}
#[test]
fn test_token_expiry_check_expired() {
let tokens = AuthTokens {
access_token: "expired".to_string(),
refresh_token: "refresh".to_string(),
expires_at: Utc::now() - Duration::hours(1),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let now = Utc::now();
// Token is expired
assert!(tokens.expires_at <= now + Duration::seconds(60));
}
#[test]
fn test_token_expiry_check_almost_expired() {
let tokens = AuthTokens {
access_token: "almost".to_string(),
refresh_token: "refresh".to_string(),
expires_at: Utc::now() + Duration::seconds(30),
id_token: None,
domain: "example.com".to_string(),
gitea_token: None,
};
let now = Utc::now();
// Token expires in 30s, which is within the 60s threshold
assert!(tokens.expires_at <= now + Duration::seconds(60));
}
#[test]
fn test_jwt_payload_decode() {
// Build a fake JWT: header.payload.signature
let payload_json = r#"{"email":"user@example.com","sub":"12345"}"#;
let encoded_payload =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let fake_jwt = format!("eyJhbGciOiJSUzI1NiJ9.{encoded_payload}.fakesig");
let payload = decode_jwt_payload(&fake_jwt).unwrap();
assert_eq!(payload["email"], "user@example.com");
assert_eq!(payload["sub"], "12345");
}
#[test]
fn test_extract_email() {
let payload_json = r#"{"email":"alice@sunbeam.pt","name":"Alice"}"#;
let encoded_payload =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let fake_jwt = format!("eyJhbGciOiJSUzI1NiJ9.{encoded_payload}.fakesig");
assert_eq!(extract_email(&fake_jwt), Some("alice@sunbeam.pt".to_string()));
}
#[test]
fn test_extract_email_missing() {
let payload_json = r#"{"sub":"12345","name":"Bob"}"#;
let encoded_payload =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let fake_jwt = format!("eyJhbGciOiJSUzI1NiJ9.{encoded_payload}.fakesig");
assert_eq!(extract_email(&fake_jwt), None);
}
#[test]
fn test_urlencoding() {
assert_eq!(urlencoding("hello"), "hello");
assert_eq!(urlencoding("hello world"), "hello%20world");
assert_eq!(
urlencoding("http://localhost:9876/callback"),
"http%3A%2F%2Flocalhost%3A9876%2Fcallback"
);
}
#[test]
fn test_generate_state() {
let s1 = generate_state();
let s2 = generate_state();
assert_ne!(s1, s2);
// 16 bytes base64url -> 22 chars
assert_eq!(s1.len(), 22);
}
#[test]
fn test_cache_path_is_under_sunbeam() {
let path = cache_path_for_domain("sunbeam.pt");
let path_str = path.to_string_lossy();
assert!(path_str.contains("sunbeam"));
assert!(path_str.contains("auth"));
assert!(path_str.ends_with("sunbeam.pt.json"));
}
#[test]
fn test_cache_path_default_domain() {
let path = cache_path_for_domain("");
assert!(path.to_string_lossy().ends_with("default.json"));
}
}

View File

@@ -0,0 +1,792 @@
//! Service-level health checks -- functional probes beyond pod readiness.
mod probes;
use crate::error::Result;
use crate::kube::parse_target;
use crate::output::{ok, step, warn};
// ---------------------------------------------------------------------------
// CheckResult
// ---------------------------------------------------------------------------
/// Result of a single health check.
#[derive(Debug, Clone)]
pub struct CheckResult {
pub name: String,
pub ns: String,
pub svc: String,
pub passed: bool,
pub detail: String,
}
impl CheckResult {
fn ok(name: &str, ns: &str, svc: &str, detail: &str) -> Self {
Self {
name: name.into(),
ns: ns.into(),
svc: svc.into(),
passed: true,
detail: detail.into(),
}
}
fn fail(name: &str, ns: &str, svc: &str, detail: &str) -> Self {
Self {
name: name.into(),
ns: ns.into(),
svc: svc.into(),
passed: false,
detail: detail.into(),
}
}
}
// ---------------------------------------------------------------------------
// HTTP client builder
// ---------------------------------------------------------------------------
/// Build a reqwest client that trusts the mkcert local CA if available,
/// does not follow redirects, and has a 5s timeout.
fn build_http_client() -> Result<reqwest::Client> {
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(5));
// Try mkcert root CA
if let Ok(output) = std::process::Command::new("mkcert")
.arg("-CAROOT")
.output()
{
if output.status.success() {
let ca_root = String::from_utf8_lossy(&output.stdout).trim().to_string();
let ca_file = std::path::Path::new(&ca_root).join("rootCA.pem");
if ca_file.exists() {
if let Ok(pem_bytes) = std::fs::read(&ca_file) {
if let Ok(cert) = reqwest::Certificate::from_pem(&pem_bytes) {
builder = builder.add_root_certificate(cert);
}
}
}
}
}
Ok(builder.build()?)
}
/// Helper: GET a URL, return (status_code, body_bytes). Does not follow redirects.
async fn http_get(
client: &reqwest::Client,
url: &str,
headers: Option<&[(&str, &str)]>,
) -> std::result::Result<(u16, Vec<u8>), String> {
let mut req = client.get(url);
if let Some(hdrs) = headers {
for (k, v) in hdrs {
req = req.header(*k, *v);
}
}
match req.send().await {
Ok(resp) => {
let status = resp.status().as_u16();
let body = resp.bytes().await.unwrap_or_default().to_vec();
Ok((status, body))
}
Err(e) => Err(format!("{e}")),
}
}
/// Read a K8s secret field, returning empty string on failure.
async fn kube_secret(ns: &str, name: &str, key: &str) -> String {
crate::kube::kube_get_secret_field(ns, name, key)
.await
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// Check registry -- function pointer + metadata
// ---------------------------------------------------------------------------
type CheckFn = for<'a> fn(
&'a str,
&'a reqwest::Client,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = CheckResult> + Send + 'a>>;
struct CheckEntry {
func: CheckFn,
ns: &'static str,
svc: &'static str,
}
fn check_registry() -> Vec<CheckEntry> {
use probes::*;
vec![
CheckEntry {
func: |d, c| Box::pin(check_gitea_version(d, c)),
ns: "devtools",
svc: "gitea",
},
CheckEntry {
func: |d, c| Box::pin(check_gitea_auth(d, c)),
ns: "devtools",
svc: "gitea",
},
CheckEntry {
func: |d, c| Box::pin(check_postgres(d, c)),
ns: "data",
svc: "postgres",
},
CheckEntry {
func: |d, c| Box::pin(check_valkey(d, c)),
ns: "data",
svc: "valkey",
},
CheckEntry {
func: |d, c| Box::pin(check_openbao(d, c)),
ns: "data",
svc: "openbao",
},
CheckEntry {
func: |d, c| Box::pin(check_seaweedfs(d, c)),
ns: "storage",
svc: "seaweedfs",
},
CheckEntry {
func: |d, c| Box::pin(check_kratos(d, c)),
ns: "ory",
svc: "kratos",
},
CheckEntry {
func: |d, c| Box::pin(check_hydra_oidc(d, c)),
ns: "ory",
svc: "hydra",
},
CheckEntry {
func: |d, c| Box::pin(check_people(d, c)),
ns: "lasuite",
svc: "people",
},
CheckEntry {
func: |d, c| Box::pin(check_people_api(d, c)),
ns: "lasuite",
svc: "people",
},
CheckEntry {
func: |d, c| Box::pin(check_livekit(d, c)),
ns: "media",
svc: "livekit",
},
]
}
// ---------------------------------------------------------------------------
// cmd_check -- concurrent execution
// ---------------------------------------------------------------------------
/// Run service-level health checks, optionally scoped to a namespace or service.
pub async fn cmd_check(target: Option<&str>) -> Result<()> {
step("Service health checks...");
let domain = crate::kube::get_domain().await?;
let http_client = build_http_client()?;
let (ns_filter, svc_filter) = parse_target(target)?;
let all_checks = check_registry();
let selected: Vec<&CheckEntry> = all_checks
.iter()
.filter(|e| {
(ns_filter.is_none() || ns_filter == Some(e.ns))
&& (svc_filter.is_none() || svc_filter == Some(e.svc))
})
.collect();
if selected.is_empty() {
warn(&format!(
"No checks match target: {}",
target.unwrap_or("(none)")
));
return Ok(());
}
// Run all checks concurrently
let mut join_set = tokio::task::JoinSet::new();
for entry in &selected {
let domain = domain.clone();
let client = http_client.clone();
let func = entry.func;
join_set.spawn(async move { func(&domain, &client).await });
}
let mut results: Vec<CheckResult> = Vec::new();
while let Some(res) = join_set.join_next().await {
match res {
Ok(cr) => results.push(cr),
Err(e) => results.push(CheckResult::fail("unknown", "?", "?", &format!("{e}"))),
}
}
// Sort to match the registry order for consistent output
let registry = check_registry();
results.sort_by(|a, b| {
let idx_a = registry
.iter()
.position(|e| e.ns == a.ns && e.svc == a.svc)
.unwrap_or(usize::MAX);
let idx_b = registry
.iter()
.position(|e| e.ns == b.ns && e.svc == b.svc)
.unwrap_or(usize::MAX);
idx_a.cmp(&idx_b).then_with(|| a.name.cmp(&b.name))
});
// Print grouped by namespace
let name_w = results.iter().map(|r| r.name.len()).max().unwrap_or(0);
let mut cur_ns: Option<&str> = None;
for r in &results {
if cur_ns != Some(&r.ns) {
println!(" {}:", r.ns);
cur_ns = Some(&r.ns);
}
let icon = if r.passed { "\u{2713}" } else { "\u{2717}" };
let detail = if r.detail.is_empty() {
String::new()
} else {
format!(" {}", r.detail)
};
println!(" {icon} {:<name_w$}{detail}", r.name);
}
println!();
let failed: Vec<&CheckResult> = results.iter().filter(|r| !r.passed).collect();
if failed.is_empty() {
ok(&format!("All {} check(s) passed.", results.len()));
} else {
warn(&format!("{} check(s) failed.", failed.len()));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use super::probes::*;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
// ── S3 auth header tests ─────────────────────────────────────────────
#[test]
fn test_s3_auth_headers_format() {
let (auth, amzdate) = s3_auth_headers(
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"s3.example.com",
);
// Verify header structure
assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/"));
assert!(auth.contains("us-east-1/s3/aws4_request"));
assert!(auth.contains("SignedHeaders=host;x-amz-date"));
assert!(auth.contains("Signature="));
// amzdate format: YYYYMMDDTHHMMSSZ
assert_eq!(amzdate.len(), 16);
assert!(amzdate.ends_with('Z'));
assert!(amzdate.contains('T'));
}
#[test]
fn test_s3_auth_headers_signature_changes_with_key() {
let (auth1, _) = s3_auth_headers("key1", "secret1", "host1");
let (auth2, _) = s3_auth_headers("key2", "secret2", "host2");
// Different keys produce different signatures
let sig1 = auth1.split("Signature=").nth(1).unwrap();
let sig2 = auth2.split("Signature=").nth(1).unwrap();
assert_ne!(sig1, sig2);
}
#[test]
fn test_s3_auth_headers_credential_scope() {
let (auth, amzdate) = s3_auth_headers("AK", "SK", "s3.example.com");
let datestamp = &amzdate[..8];
let expected_scope = format!("{datestamp}/us-east-1/s3/aws4_request");
assert!(auth.contains(&expected_scope));
}
// ── hex encoding ────────────────────────────────────────────────────
#[test]
fn test_hex_encode_empty() {
assert_eq!(hex_encode(b""), "");
}
#[test]
fn test_hex_encode_zero() {
assert_eq!(hex_encode(b"\x00"), "00");
}
#[test]
fn test_hex_encode_ff() {
assert_eq!(hex_encode(b"\xff"), "ff");
}
#[test]
fn test_hex_encode_deadbeef() {
assert_eq!(hex_encode(b"\xde\xad\xbe\xef"), "deadbeef");
}
#[test]
fn test_hex_encode_hello() {
assert_eq!(hex_encode(b"hello"), "68656c6c6f");
}
// ── CheckResult ─────────────────────────────────────────────────────
#[test]
fn test_check_result_ok() {
let r = CheckResult::ok("gitea-version", "devtools", "gitea", "v1.21.0");
assert!(r.passed);
assert_eq!(r.name, "gitea-version");
assert_eq!(r.ns, "devtools");
assert_eq!(r.svc, "gitea");
assert_eq!(r.detail, "v1.21.0");
}
#[test]
fn test_check_result_fail() {
let r = CheckResult::fail("postgres", "data", "postgres", "cluster not found");
assert!(!r.passed);
assert_eq!(r.detail, "cluster not found");
}
// ── Check registry ──────────────────────────────────────────────────
#[test]
fn test_check_registry_has_all_checks() {
let registry = check_registry();
assert_eq!(registry.len(), 11);
// Verify order matches Python CHECKS list
assert_eq!(registry[0].ns, "devtools");
assert_eq!(registry[0].svc, "gitea");
assert_eq!(registry[1].ns, "devtools");
assert_eq!(registry[1].svc, "gitea");
assert_eq!(registry[2].ns, "data");
assert_eq!(registry[2].svc, "postgres");
assert_eq!(registry[3].ns, "data");
assert_eq!(registry[3].svc, "valkey");
assert_eq!(registry[4].ns, "data");
assert_eq!(registry[4].svc, "openbao");
assert_eq!(registry[5].ns, "storage");
assert_eq!(registry[5].svc, "seaweedfs");
assert_eq!(registry[6].ns, "ory");
assert_eq!(registry[6].svc, "kratos");
assert_eq!(registry[7].ns, "ory");
assert_eq!(registry[7].svc, "hydra");
assert_eq!(registry[8].ns, "lasuite");
assert_eq!(registry[8].svc, "people");
assert_eq!(registry[9].ns, "lasuite");
assert_eq!(registry[9].svc, "people");
assert_eq!(registry[10].ns, "media");
assert_eq!(registry[10].svc, "livekit");
}
#[test]
fn test_check_registry_filter_namespace() {
let all = check_registry();
let filtered: Vec<&CheckEntry> = all.iter().filter(|e| e.ns == "ory").collect();
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_check_registry_filter_service() {
let all = check_registry();
let filtered: Vec<&CheckEntry> = all
.iter()
.filter(|e| e.ns == "ory" && e.svc == "kratos")
.collect();
assert_eq!(filtered.len(), 1);
}
#[test]
fn test_check_registry_filter_no_match() {
let all = check_registry();
let filtered: Vec<&CheckEntry> =
all.iter().filter(|e| e.ns == "nonexistent").collect();
assert!(filtered.is_empty());
}
// ── HMAC-SHA256 verification ────────────────────────────────────────
#[test]
fn test_hmac_sha256_known_vector() {
// RFC 4231 Test Case 2
let key = b"Jefe";
let data = b"what do ya want for nothing?";
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key");
mac.update(data);
let result = hex_encode(mac.finalize().into_bytes());
assert_eq!(
result,
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
}
// ── SHA256 verification ─────────────────────────────────────────────
#[test]
fn test_sha256_empty() {
let hash = hex_encode(Sha256::digest(b""));
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn test_sha256_hello() {
let hash = hex_encode(Sha256::digest(b"hello"));
assert_eq!(
hash,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
// ── Additional CheckResult tests ──────────────────────────────────
#[test]
fn test_check_result_ok_empty_detail() {
let r = CheckResult::ok("test", "ns", "svc", "");
assert!(r.passed);
assert!(r.detail.is_empty());
}
#[test]
fn test_check_result_fail_contains_status_code() {
let r = CheckResult::fail("gitea-version", "devtools", "gitea", "HTTP 502");
assert!(!r.passed);
assert!(r.detail.contains("502"));
}
#[test]
fn test_check_result_fail_contains_secret_message() {
let r = CheckResult::fail(
"gitea-auth",
"devtools",
"gitea",
"password not found in secret",
);
assert!(!r.passed);
assert!(r.detail.contains("secret"));
}
#[test]
fn test_check_result_ok_with_version() {
let r = CheckResult::ok("gitea-version", "devtools", "gitea", "v1.21.0");
assert!(r.passed);
assert!(r.detail.contains("1.21.0"));
}
#[test]
fn test_check_result_ok_with_login() {
let r = CheckResult::ok("gitea-auth", "devtools", "gitea", "user=gitea_admin");
assert!(r.passed);
assert!(r.detail.contains("gitea_admin"));
}
#[test]
fn test_check_result_ok_authenticated() {
let r = CheckResult::ok("seaweedfs", "storage", "seaweedfs", "S3 authenticated");
assert!(r.passed);
assert!(r.detail.contains("authenticated"));
}
// ── Additional registry tests ─────────────────────────────────────
#[test]
fn test_check_registry_expected_namespaces() {
let registry = check_registry();
let namespaces: std::collections::HashSet<&str> =
registry.iter().map(|e| e.ns).collect();
for expected in &["devtools", "data", "storage", "ory", "lasuite", "media"] {
assert!(
namespaces.contains(expected),
"registry missing namespace: {expected}"
);
}
}
#[test]
fn test_check_registry_expected_services() {
let registry = check_registry();
let services: std::collections::HashSet<&str> =
registry.iter().map(|e| e.svc).collect();
for expected in &[
"gitea", "postgres", "valkey", "openbao", "seaweedfs", "kratos", "hydra",
"people", "livekit",
] {
assert!(
services.contains(expected),
"registry missing service: {expected}"
);
}
}
#[test]
fn test_check_registry_devtools_has_two_gitea_entries() {
let registry = check_registry();
let gitea: Vec<_> = registry
.iter()
.filter(|e| e.ns == "devtools" && e.svc == "gitea")
.collect();
assert_eq!(gitea.len(), 2);
}
#[test]
fn test_check_registry_lasuite_has_two_people_entries() {
let registry = check_registry();
let people: Vec<_> = registry
.iter()
.filter(|e| e.ns == "lasuite" && e.svc == "people")
.collect();
assert_eq!(people.len(), 2);
}
#[test]
fn test_check_registry_data_has_three_entries() {
let registry = check_registry();
let data: Vec<_> = registry.iter().filter(|e| e.ns == "data").collect();
assert_eq!(data.len(), 3); // postgres, valkey, openbao
}
// ── Filter logic (mirrors Python TestCmdCheck) ────────────────────
/// Helper: apply the same filter logic as cmd_check to the registry.
fn filter_registry(
ns_filter: Option<&str>,
svc_filter: Option<&str>,
) -> Vec<(&'static str, &'static str)> {
let all = check_registry();
all.into_iter()
.filter(|e| ns_filter.map_or(true, |ns| e.ns == ns))
.filter(|e| svc_filter.map_or(true, |svc| e.svc == svc))
.map(|e| (e.ns, e.svc))
.collect()
}
#[test]
fn test_no_target_runs_all() {
let selected = filter_registry(None, None);
assert_eq!(selected.len(), 11);
}
#[test]
fn test_ns_filter_devtools_selects_two() {
let selected = filter_registry(Some("devtools"), None);
assert_eq!(selected.len(), 2);
assert!(selected.iter().all(|(ns, _)| *ns == "devtools"));
}
#[test]
fn test_ns_filter_skips_other_namespaces() {
let selected = filter_registry(Some("devtools"), None);
// Should NOT contain data/postgres
assert!(selected.iter().all(|(ns, _)| *ns != "data"));
}
#[test]
fn test_svc_filter_ory_kratos() {
let selected = filter_registry(Some("ory"), Some("kratos"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0], ("ory", "kratos"));
}
#[test]
fn test_svc_filter_ory_hydra() {
let selected = filter_registry(Some("ory"), Some("hydra"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0], ("ory", "hydra"));
}
#[test]
fn test_svc_filter_people_returns_both() {
let selected = filter_registry(Some("lasuite"), Some("people"));
assert_eq!(selected.len(), 2);
assert!(selected.iter().all(|(ns, svc)| *ns == "lasuite" && *svc == "people"));
}
#[test]
fn test_filter_nonexistent_ns_returns_empty() {
let selected = filter_registry(Some("nonexistent"), None);
assert!(selected.is_empty());
}
#[test]
fn test_filter_ns_match_svc_mismatch_returns_empty() {
// ory namespace exists but postgres service does not live there
let selected = filter_registry(Some("ory"), Some("postgres"));
assert!(selected.is_empty());
}
#[test]
fn test_filter_data_namespace() {
let selected = filter_registry(Some("data"), None);
assert_eq!(selected.len(), 3);
let svcs: Vec<&str> = selected.iter().map(|(_, svc)| *svc).collect();
assert!(svcs.contains(&"postgres"));
assert!(svcs.contains(&"valkey"));
assert!(svcs.contains(&"openbao"));
}
#[test]
fn test_filter_storage_namespace() {
let selected = filter_registry(Some("storage"), None);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0], ("storage", "seaweedfs"));
}
#[test]
fn test_filter_media_namespace() {
let selected = filter_registry(Some("media"), None);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0], ("media", "livekit"));
}
// ── S3 auth AWS reference vector test ─────────────────────────────
#[test]
fn test_s3_auth_headers_aws_reference_vector() {
// Uses AWS test values with a fixed timestamp to verify signature
// correctness against a known reference (AWS SigV4 documentation).
use chrono::TimeZone;
let access_key = "AKIAIOSFODNN7EXAMPLE";
let secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
let host = "examplebucket.s3.amazonaws.com";
let now = chrono::Utc.with_ymd_and_hms(2013, 5, 24, 0, 0, 0).unwrap();
let (auth, amzdate) = s3_auth_headers_at(access_key, secret_key, host, now);
// 1. Verify the date header
assert_eq!(amzdate, "20130524T000000Z");
// 2. Verify canonical request intermediate values.
// Canonical request for GET / with empty body:
// GET\n/\n\nhost:examplebucket.s3.amazonaws.com\n
// x-amz-date:20130524T000000Z\n\nhost;x-amz-date\n<sha256("")>
let payload_hash =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
let canonical = format!(
"GET\n/\n\nhost:{host}\nx-amz-date:{amzdate}\n\nhost;x-amz-date\n{payload_hash}"
);
let canonical_hash = hex_encode(&Sha256::digest(canonical.as_bytes()));
// 3. Verify the string to sign
let credential_scope = "20130524/us-east-1/s3/aws4_request";
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{amzdate}\n{credential_scope}\n{canonical_hash}"
);
// 4. Compute the expected signing key and signature to pin the value.
fn hmac_sign(key: &[u8], msg: &[u8]) -> Vec<u8> {
let mut mac =
HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
let k = hmac_sign(
format!("AWS4{secret_key}").as_bytes(),
b"20130524",
);
let k = hmac_sign(&k, b"us-east-1");
let k = hmac_sign(&k, b"s3");
let k = hmac_sign(&k, b"aws4_request");
let expected_sig = {
let mut mac =
HmacSha256::new_from_slice(&k).expect("HMAC accepts any key length");
mac.update(string_to_sign.as_bytes());
hex_encode(&mac.finalize().into_bytes())
};
// 5. Verify the full Authorization header matches
let expected_auth = format!(
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
SignedHeaders=host;x-amz-date, Signature={expected_sig}"
);
assert_eq!(auth, expected_auth);
// 6. Pin the exact signature value so any regression is caught
// immediately without needing to recompute.
let sig = auth.split("Signature=").nth(1).unwrap();
assert_eq!(sig, expected_sig);
assert_eq!(sig.len(), 64, "SHA-256 HMAC signature must be 64 hex chars");
}
// ── Additional S3 auth header tests ───────────────────────────────
#[test]
fn test_s3_auth_headers_deterministic() {
// Same inputs at the same point in time produce identical output.
// (Time may advance between calls, but the format is still valid.)
let (auth1, date1) = s3_auth_headers("AK", "SK", "host");
let (auth2, date2) = s3_auth_headers("AK", "SK", "host");
// If both calls happen within the same second, they must be identical.
if date1 == date2 {
assert_eq!(auth1, auth2, "same inputs at same time must produce same signature");
}
}
#[test]
fn test_s3_auth_headers_different_hosts_differ() {
let (auth1, d1) = s3_auth_headers("AK", "SK", "s3.a.com");
let (auth2, d2) = s3_auth_headers("AK", "SK", "s3.b.com");
let sig1 = auth1.split("Signature=").nth(1).unwrap();
let sig2 = auth2.split("Signature=").nth(1).unwrap();
// Different hosts -> different canonical request -> different signature
// (only guaranteed when timestamps match)
if d1 == d2 {
assert_ne!(sig1, sig2);
}
}
#[test]
fn test_s3_auth_headers_signature_is_64_hex_chars() {
let (auth, _) = s3_auth_headers("AK", "SK", "host");
let sig = auth.split("Signature=").nth(1).unwrap();
assert_eq!(sig.len(), 64, "SHA-256 HMAC hex signature is 64 chars");
assert!(
sig.chars().all(|c| c.is_ascii_hexdigit()),
"signature must be lowercase hex: {sig}"
);
}
// ── hex_encode edge cases ─────────────────────────────────────────
#[test]
fn test_hex_encode_all_byte_values() {
// Verify 0x00..0xff all produce 2-char lowercase hex
for b in 0u8..=255 {
let encoded = hex_encode([b]);
assert_eq!(encoded.len(), 2);
assert!(encoded.chars().all(|c| c.is_ascii_hexdigit()));
}
}
#[test]
fn test_hex_encode_matches_format() {
// Cross-check against Rust's built-in formatting
let bytes: Vec<u8> = (0..32).collect();
let expected: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex_encode(&bytes), expected);
}
}

View File

@@ -0,0 +1,433 @@
//! Individual service health check probe functions.
use base64::Engine;
use hmac::{Hmac, Mac};
use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, ListParams};
use kube::ResourceExt;
use sha2::{Digest, Sha256};
use super::{CheckResult, http_get, kube_secret};
use crate::kube::{get_client, kube_exec};
type HmacSha256 = Hmac<Sha256>;
// ---------------------------------------------------------------------------
// Individual checks
// ---------------------------------------------------------------------------
/// GET /api/v1/version -> JSON with version field.
pub(super) async fn check_gitea_version(domain: &str, client: &reqwest::Client) -> CheckResult {
let url = format!("https://src.{domain}/api/v1/version");
match http_get(client, &url, None).await {
Ok((200, body)) => {
let ver = serde_json::from_slice::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("version").and_then(|v| v.as_str()).map(String::from))
.unwrap_or_else(|| "?".into());
CheckResult::ok("gitea-version", "devtools", "gitea", &format!("v{ver}"))
}
Ok((status, _)) => {
CheckResult::fail("gitea-version", "devtools", "gitea", &format!("HTTP {status}"))
}
Err(e) => CheckResult::fail("gitea-version", "devtools", "gitea", &e),
}
}
/// GET /api/v1/user with admin credentials -> 200 and login field.
pub(super) async fn check_gitea_auth(domain: &str, client: &reqwest::Client) -> CheckResult {
let username = {
let u = kube_secret("devtools", "gitea-admin-credentials", "username").await;
if u.is_empty() {
"gitea_admin".to_string()
} else {
u
}
};
let password =
kube_secret("devtools", "gitea-admin-credentials", "password").await;
if password.is_empty() {
return CheckResult::fail(
"gitea-auth",
"devtools",
"gitea",
"password not found in secret",
);
}
let creds =
base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
let auth_hdr = format!("Basic {creds}");
let url = format!("https://src.{domain}/api/v1/user");
match http_get(client, &url, Some(&[("Authorization", &auth_hdr)])).await {
Ok((200, body)) => {
let login = serde_json::from_slice::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("login").and_then(|v| v.as_str()).map(String::from))
.unwrap_or_else(|| "?".into());
CheckResult::ok("gitea-auth", "devtools", "gitea", &format!("user={login}"))
}
Ok((status, _)) => {
CheckResult::fail("gitea-auth", "devtools", "gitea", &format!("HTTP {status}"))
}
Err(e) => CheckResult::fail("gitea-auth", "devtools", "gitea", &e),
}
}
/// CNPG Cluster readyInstances == instances.
pub(super) async fn check_postgres(_domain: &str, _client: &reqwest::Client) -> CheckResult {
let kube_client = match get_client().await {
Ok(c) => c,
Err(e) => {
return CheckResult::fail("postgres", "data", "postgres", &format!("{e}"));
}
};
let ar = kube::api::ApiResource {
group: "postgresql.cnpg.io".into(),
version: "v1".into(),
api_version: "postgresql.cnpg.io/v1".into(),
kind: "Cluster".into(),
plural: "clusters".into(),
};
let api: Api<kube::api::DynamicObject> =
Api::namespaced_with(kube_client.clone(), "data", &ar);
match api.get_opt("postgres").await {
Ok(Some(obj)) => {
let ready = obj
.data
.get("status")
.and_then(|s| s.get("readyInstances"))
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_default();
let total = obj
.data
.get("status")
.and_then(|s| s.get("instances"))
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_default();
if !ready.is_empty() && !total.is_empty() && ready == total {
CheckResult::ok(
"postgres",
"data",
"postgres",
&format!("{ready}/{total} ready"),
)
} else {
let r = if ready.is_empty() { "?" } else { &ready };
let t = if total.is_empty() { "?" } else { &total };
CheckResult::fail("postgres", "data", "postgres", &format!("{r}/{t} ready"))
}
}
Ok(None) => CheckResult::fail("postgres", "data", "postgres", "cluster not found"),
Err(e) => CheckResult::fail("postgres", "data", "postgres", &format!("{e}")),
}
}
/// kubectl exec valkey pod -- valkey-cli ping -> PONG.
pub(super) async fn check_valkey(_domain: &str, _client: &reqwest::Client) -> CheckResult {
let kube_client = match get_client().await {
Ok(c) => c,
Err(e) => return CheckResult::fail("valkey", "data", "valkey", &format!("{e}")),
};
let api: Api<Pod> = Api::namespaced(kube_client.clone(), "data");
let lp = ListParams::default().labels("app=valkey");
let pod_list = match api.list(&lp).await {
Ok(l) => l,
Err(e) => return CheckResult::fail("valkey", "data", "valkey", &format!("{e}")),
};
let pod_name = match pod_list.items.first() {
Some(p) => p.name_any(),
None => return CheckResult::fail("valkey", "data", "valkey", "no valkey pod"),
};
match kube_exec("data", &pod_name, &["valkey-cli", "ping"], Some("valkey")).await {
Ok((_, out)) => {
let passed = out == "PONG";
let detail = if out.is_empty() {
"no response".to_string()
} else {
out
};
CheckResult {
name: "valkey".into(),
ns: "data".into(),
svc: "valkey".into(),
passed,
detail,
}
}
Err(e) => CheckResult::fail("valkey", "data", "valkey", &format!("{e}")),
}
}
/// kubectl exec openbao-0 -- bao status -format=json -> initialized + unsealed.
pub(super) async fn check_openbao(_domain: &str, _client: &reqwest::Client) -> CheckResult {
match kube_exec(
"data",
"openbao-0",
&["bao", "status", "-format=json"],
Some("openbao"),
)
.await
{
Ok((_, out)) => {
if out.is_empty() {
return CheckResult::fail("openbao", "data", "openbao", "no response");
}
match serde_json::from_str::<serde_json::Value>(&out) {
Ok(data) => {
let init = data
.get("initialized")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let sealed = data
.get("sealed")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let passed = init && !sealed;
CheckResult {
name: "openbao".into(),
ns: "data".into(),
svc: "openbao".into(),
passed,
detail: format!("init={init}, sealed={sealed}"),
}
}
Err(_) => {
let truncated: String = out.chars().take(80).collect();
CheckResult::fail("openbao", "data", "openbao", &truncated)
}
}
}
Err(e) => CheckResult::fail("openbao", "data", "openbao", &format!("{e}")),
}
}
// ---------------------------------------------------------------------------
// S3 auth (AWS4-HMAC-SHA256)
// ---------------------------------------------------------------------------
/// Generate AWS4-HMAC-SHA256 Authorization and x-amz-date headers for an unsigned
/// GET / request, matching the Python `_s3_auth_headers` function exactly.
pub(crate) fn s3_auth_headers(access_key: &str, secret_key: &str, host: &str) -> (String, String) {
s3_auth_headers_at(access_key, secret_key, host, chrono::Utc::now())
}
/// Deterministic inner implementation that accepts an explicit timestamp.
pub(crate) fn s3_auth_headers_at(
access_key: &str,
secret_key: &str,
host: &str,
now: chrono::DateTime<chrono::Utc>,
) -> (String, String) {
let amzdate = now.format("%Y%m%dT%H%M%SZ").to_string();
let datestamp = now.format("%Y%m%d").to_string();
let payload_hash = hex_encode(&Sha256::digest(b""));
let canonical = format!(
"GET\n/\n\nhost:{host}\nx-amz-date:{amzdate}\n\nhost;x-amz-date\n{payload_hash}"
);
let credential_scope = format!("{datestamp}/us-east-1/s3/aws4_request");
let canonical_hash = hex_encode(&Sha256::digest(canonical.as_bytes()));
let string_to_sign =
format!("AWS4-HMAC-SHA256\n{amzdate}\n{credential_scope}\n{canonical_hash}");
fn hmac_sign(key: &[u8], msg: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
let k = hmac_sign(
format!("AWS4{secret_key}").as_bytes(),
datestamp.as_bytes(),
);
let k = hmac_sign(&k, b"us-east-1");
let k = hmac_sign(&k, b"s3");
let k = hmac_sign(&k, b"aws4_request");
let sig = {
let mut mac = HmacSha256::new_from_slice(&k).expect("HMAC accepts any key length");
mac.update(string_to_sign.as_bytes());
hex_encode(&mac.finalize().into_bytes())
};
let auth = format!(
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders=host;x-amz-date, Signature={sig}"
);
(auth, amzdate)
}
/// GET https://s3.{domain}/ with S3 credentials -> 200 list-buckets response.
pub(super) async fn check_seaweedfs(domain: &str, client: &reqwest::Client) -> CheckResult {
let access_key =
kube_secret("storage", "seaweedfs-s3-credentials", "S3_ACCESS_KEY").await;
let secret_key =
kube_secret("storage", "seaweedfs-s3-credentials", "S3_SECRET_KEY").await;
if access_key.is_empty() || secret_key.is_empty() {
return CheckResult::fail(
"seaweedfs",
"storage",
"seaweedfs",
"credentials not found in seaweedfs-s3-credentials secret",
);
}
let host = format!("s3.{domain}");
let url = format!("https://{host}/");
let (auth, amzdate) = s3_auth_headers(&access_key, &secret_key, &host);
match http_get(
client,
&url,
Some(&[("Authorization", &auth), ("x-amz-date", &amzdate)]),
)
.await
{
Ok((200, _)) => {
CheckResult::ok("seaweedfs", "storage", "seaweedfs", "S3 authenticated")
}
Ok((status, _)) => CheckResult::fail(
"seaweedfs",
"storage",
"seaweedfs",
&format!("HTTP {status}"),
),
Err(e) => CheckResult::fail("seaweedfs", "storage", "seaweedfs", &e),
}
}
/// GET /kratos/health/ready -> 200.
pub(super) async fn check_kratos(domain: &str, client: &reqwest::Client) -> CheckResult {
let url = format!("https://auth.{domain}/kratos/health/ready");
match http_get(client, &url, None).await {
Ok((status, body)) => {
let ok_flag = status == 200;
let mut detail = format!("HTTP {status}");
if !ok_flag && !body.is_empty() {
let body_str: String =
String::from_utf8_lossy(&body).chars().take(80).collect();
detail = format!("{detail}: {body_str}");
}
CheckResult {
name: "kratos".into(),
ns: "ory".into(),
svc: "kratos".into(),
passed: ok_flag,
detail,
}
}
Err(e) => CheckResult::fail("kratos", "ory", "kratos", &e),
}
}
/// GET /.well-known/openid-configuration -> 200 with issuer field.
pub(super) async fn check_hydra_oidc(domain: &str, client: &reqwest::Client) -> CheckResult {
let url = format!("https://auth.{domain}/.well-known/openid-configuration");
match http_get(client, &url, None).await {
Ok((200, body)) => {
let issuer = serde_json::from_slice::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("issuer").and_then(|v| v.as_str()).map(String::from))
.unwrap_or_else(|| "?".into());
CheckResult::ok("hydra-oidc", "ory", "hydra", &format!("issuer={issuer}"))
}
Ok((status, _)) => {
CheckResult::fail("hydra-oidc", "ory", "hydra", &format!("HTTP {status}"))
}
Err(e) => CheckResult::fail("hydra-oidc", "ory", "hydra", &e),
}
}
/// GET https://people.{domain}/ -> any response < 500 (302 to OIDC is fine).
pub(super) async fn check_people(domain: &str, client: &reqwest::Client) -> CheckResult {
let url = format!("https://people.{domain}/");
match http_get(client, &url, None).await {
Ok((status, _)) => CheckResult {
name: "people".into(),
ns: "lasuite".into(),
svc: "people".into(),
passed: status < 500,
detail: format!("HTTP {status}"),
},
Err(e) => CheckResult::fail("people", "lasuite", "people", &e),
}
}
/// GET /api/v1.0/config/ -> any response < 500 (401 auth-required is fine).
pub(super) async fn check_people_api(domain: &str, client: &reqwest::Client) -> CheckResult {
let url = format!("https://people.{domain}/api/v1.0/config/");
match http_get(client, &url, None).await {
Ok((status, _)) => CheckResult {
name: "people-api".into(),
ns: "lasuite".into(),
svc: "people".into(),
passed: status < 500,
detail: format!("HTTP {status}"),
},
Err(e) => CheckResult::fail("people-api", "lasuite", "people", &e),
}
}
/// kubectl exec livekit-server pod -- wget localhost:7880/ -> rc 0.
pub(super) async fn check_livekit(_domain: &str, _client: &reqwest::Client) -> CheckResult {
let kube_client = match get_client().await {
Ok(c) => c,
Err(e) => return CheckResult::fail("livekit", "media", "livekit", &format!("{e}")),
};
let api: Api<Pod> = Api::namespaced(kube_client.clone(), "media");
let lp = ListParams::default().labels("app.kubernetes.io/name=livekit-server");
let pod_list = match api.list(&lp).await {
Ok(l) => l,
Err(e) => return CheckResult::fail("livekit", "media", "livekit", &format!("{e}")),
};
let pod_name = match pod_list.items.first() {
Some(p) => p.name_any(),
None => return CheckResult::fail("livekit", "media", "livekit", "no livekit pod"),
};
match kube_exec(
"media",
&pod_name,
&["wget", "-qO-", "http://localhost:7880/"],
None,
)
.await
{
Ok((exit_code, _)) => {
if exit_code == 0 {
CheckResult::ok("livekit", "media", "livekit", "server responding")
} else {
CheckResult::fail("livekit", "media", "livekit", "server not responding")
}
}
Err(e) => CheckResult::fail("livekit", "media", "livekit", &format!("{e}")),
}
}
// ---------------------------------------------------------------------------
// hex encoding helper (avoids adding the `hex` crate)
// ---------------------------------------------------------------------------
pub(crate) fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
let bytes = bytes.as_ref();
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX_CHARS[(b >> 4) as usize] as char);
s.push(HEX_CHARS[(b & 0xf) as usize] as char);
}
s
}

View File

@@ -0,0 +1,461 @@
//! Cluster lifecycle — cert-manager, Linkerd, TLS, core service readiness.
//!
//! Pure K8s implementation: no Lima VM operations.
use crate::constants::GITEA_ADMIN_USER;
use crate::error::{Result, ResultExt, SunbeamError};
use std::path::PathBuf;
const CERT_MANAGER_URL: &str =
"https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml";
const GATEWAY_API_CRDS_URL: &str =
"https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml";
fn secrets_dir() -> PathBuf {
crate::config::get_infra_dir()
.join("secrets")
.join("local")
}
// ---------------------------------------------------------------------------
// cert-manager
// ---------------------------------------------------------------------------
async fn ensure_cert_manager() -> Result<()> {
crate::output::step("cert-manager...");
if crate::kube::ns_exists("cert-manager").await? {
crate::output::ok("Already installed.");
return Ok(());
}
crate::output::ok("Installing...");
// Download and apply cert-manager YAML
let body = reqwest::get(CERT_MANAGER_URL)
.await
.ctx("Failed to download cert-manager manifest")?
.text()
.await
.ctx("Failed to read cert-manager manifest body")?;
crate::kube::kube_apply(&body).await?;
// Wait for rollout
for dep in &[
"cert-manager",
"cert-manager-webhook",
"cert-manager-cainjector",
] {
crate::output::ok(&format!("Waiting for {dep}..."));
wait_rollout("cert-manager", dep, 120).await?;
}
crate::output::ok("Installed.");
Ok(())
}
// ---------------------------------------------------------------------------
// Linkerd
// ---------------------------------------------------------------------------
async fn ensure_linkerd() -> Result<()> {
crate::output::step("Linkerd...");
if crate::kube::ns_exists("linkerd").await? {
crate::output::ok("Already installed.");
return Ok(());
}
// Gateway API CRDs
crate::output::ok("Installing Gateway API CRDs...");
let gateway_body = reqwest::get(GATEWAY_API_CRDS_URL)
.await
.ctx("Failed to download Gateway API CRDs")?
.text()
.await?;
// Gateway API CRDs require server-side apply; kube_apply already does SSA
crate::kube::kube_apply(&gateway_body).await?;
// Linkerd CRDs via subprocess (no pure HTTP source for linkerd manifests)
crate::output::ok("Installing Linkerd CRDs...");
let crds_output = tokio::process::Command::new("linkerd")
.args(["install", "--crds"])
.output()
.await
.ctx("Failed to run `linkerd install --crds`")?;
if !crds_output.status.success() {
let stderr = String::from_utf8_lossy(&crds_output.stderr);
return Err(SunbeamError::tool("linkerd", format!("install --crds failed: {stderr}")));
}
let crds = String::from_utf8_lossy(&crds_output.stdout);
crate::kube::kube_apply(&crds).await?;
// Linkerd control plane
crate::output::ok("Installing Linkerd control plane...");
let cp_output = tokio::process::Command::new("linkerd")
.args(["install"])
.output()
.await
.ctx("Failed to run `linkerd install`")?;
if !cp_output.status.success() {
let stderr = String::from_utf8_lossy(&cp_output.stderr);
return Err(SunbeamError::tool("linkerd", format!("install failed: {stderr}")));
}
let cp = String::from_utf8_lossy(&cp_output.stdout);
crate::kube::kube_apply(&cp).await?;
for dep in &[
"linkerd-identity",
"linkerd-destination",
"linkerd-proxy-injector",
] {
crate::output::ok(&format!("Waiting for {dep}..."));
wait_rollout("linkerd", dep, 120).await?;
}
crate::output::ok("Installed.");
Ok(())
}
// ---------------------------------------------------------------------------
// TLS certificate (rcgen)
// ---------------------------------------------------------------------------
async fn ensure_tls_cert(domain: &str) -> Result<()> {
crate::output::step("TLS certificate...");
let dir = secrets_dir();
let cert_path = dir.join("tls.crt");
let key_path = dir.join("tls.key");
if cert_path.exists() {
crate::output::ok(&format!("Cert exists. Domain: {domain}"));
return Ok(());
}
crate::output::ok(&format!("Generating wildcard cert for *.{domain}..."));
std::fs::create_dir_all(&dir)
.with_ctx(|| format!("Failed to create secrets dir: {}", dir.display()))?;
let subject_alt_names = vec![format!("*.{domain}")];
let mut params = rcgen::CertificateParams::new(subject_alt_names)
.map_err(|e| SunbeamError::kube(format!("Failed to create certificate params: {e}")))?;
params
.distinguished_name
.push(rcgen::DnType::CommonName, format!("*.{domain}"));
let key_pair = rcgen::KeyPair::generate()
.map_err(|e| SunbeamError::kube(format!("Failed to generate key pair: {e}")))?;
let cert = params
.self_signed(&key_pair)
.map_err(|e| SunbeamError::kube(format!("Failed to generate self-signed certificate: {e}")))?;
std::fs::write(&cert_path, cert.pem())
.with_ctx(|| format!("Failed to write {}", cert_path.display()))?;
std::fs::write(&key_path, key_pair.serialize_pem())
.with_ctx(|| format!("Failed to write {}", key_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?;
}
crate::output::ok(&format!("Cert generated. Domain: {domain}"));
Ok(())
}
// ---------------------------------------------------------------------------
// TLS secret
// ---------------------------------------------------------------------------
async fn ensure_tls_secret(domain: &str) -> Result<()> {
crate::output::step("TLS secret...");
let _ = domain; // domain used contextually above; secret uses files
crate::kube::ensure_ns("ingress").await?;
let dir = secrets_dir();
let cert_pem =
std::fs::read_to_string(dir.join("tls.crt")).ctx("Failed to read tls.crt")?;
let key_pem =
std::fs::read_to_string(dir.join("tls.key")).ctx("Failed to read tls.key")?;
// Create TLS secret via kube-rs
let client = crate::kube::get_client().await?;
let api: kube::api::Api<k8s_openapi::api::core::v1::Secret> =
kube::api::Api::namespaced(client.clone(), "ingress");
let b64_cert = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
cert_pem.as_bytes(),
);
let b64_key = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
key_pem.as_bytes(),
);
let secret_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "pingora-tls",
"namespace": "ingress",
},
"type": "kubernetes.io/tls",
"data": {
"tls.crt": b64_cert,
"tls.key": b64_key,
},
});
let pp = kube::api::PatchParams::apply("sunbeam").force();
api.patch("pingora-tls", &pp, &kube::api::Patch::Apply(secret_obj))
.await
.ctx("Failed to create TLS secret")?;
crate::output::ok("Done.");
Ok(())
}
// ---------------------------------------------------------------------------
// Wait for core
// ---------------------------------------------------------------------------
async fn wait_for_core() -> Result<()> {
crate::output::step("Waiting for core services...");
for (ns, dep) in &[("data", "valkey"), ("ory", "kratos"), ("ory", "hydra")] {
let _ = wait_rollout(ns, dep, 120).await;
}
crate::output::ok("Core services ready.");
Ok(())
}
// ---------------------------------------------------------------------------
// Print URLs
// ---------------------------------------------------------------------------
fn print_urls(domain: &str, _gitea_admin_pass: &str) {
let sep = "\u{2500}".repeat(60);
println!("\n{sep}");
println!(" Stack is up. Domain: {domain}");
println!("{sep}");
let urls: &[(&str, String)] = &[
("Auth", format!("https://auth.{domain}/")),
("Docs", format!("https://docs.{domain}/")),
("Meet", format!("https://meet.{domain}/")),
("Drive", format!("https://drive.{domain}/")),
("Chat", format!("https://chat.{domain}/")),
("Mail", format!("https://mail.{domain}/")),
("People", format!("https://people.{domain}/")),
(
"Gitea",
format!(
"https://src.{domain}/ ({GITEA_ADMIN_USER} / <from openbao>)"
),
),
];
for (name, url) in urls {
println!(" {name:<10} {url}");
}
println!();
println!(" OpenBao UI:");
println!(" kubectl --context=sunbeam -n data port-forward svc/openbao 8200:8200");
println!(" http://localhost:8200");
println!(
" token: kubectl --context=sunbeam -n data get secret openbao-keys \
-o jsonpath='{{.data.root-token}}' | base64 -d"
);
println!("{sep}\n");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Poll deployment rollout status (approximate: check Available condition).
async fn wait_rollout(ns: &str, deployment: &str, timeout_secs: u64) -> Result<()> {
use k8s_openapi::api::apps::v1::Deployment;
use std::time::{Duration, Instant};
let client = crate::kube::get_client().await?;
let api: kube::api::Api<Deployment> = kube::api::Api::namespaced(client.clone(), ns);
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop {
if Instant::now() > deadline {
return Err(SunbeamError::kube(format!("Timed out waiting for deployment {ns}/{deployment}")));
}
match api.get_opt(deployment).await? {
Some(dep) => {
if let Some(status) = &dep.status {
if let Some(conditions) = &status.conditions {
let available = conditions.iter().any(|c| {
c.type_ == "Available" && c.status == "True"
});
if available {
return Ok(());
}
}
}
}
None => {
// Deployment doesn't exist yet — keep waiting
}
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/// Full cluster bring-up (pure K8s — no Lima VM operations).
pub async fn cmd_up() -> Result<()> {
// Resolve domain from cluster state
let domain = crate::kube::get_domain().await?;
ensure_cert_manager().await?;
ensure_linkerd().await?;
ensure_tls_cert(&domain).await?;
ensure_tls_secret(&domain).await?;
// Apply manifests
crate::manifests::cmd_apply("local", &domain, "", "").await?;
// Seed secrets
crate::secrets::cmd_seed().await?;
// Gitea bootstrap
crate::gitea::cmd_bootstrap().await?;
// Mirror amd64-only images
crate::images::cmd_mirror().await?;
// Wait for core services
wait_for_core().await?;
// Get gitea admin password for URL display
let admin_pass = crate::kube::kube_get_secret_field(
"devtools",
"gitea-admin-credentials",
"password",
)
.await
.unwrap_or_default();
print_urls(&domain, &admin_pass);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cert_manager_url_points_to_github_release() {
assert!(CERT_MANAGER_URL.starts_with("https://github.com/cert-manager/cert-manager/"));
assert!(CERT_MANAGER_URL.contains("/releases/download/"));
assert!(CERT_MANAGER_URL.ends_with(".yaml"));
}
#[test]
fn cert_manager_url_has_version() {
// Verify the URL contains a version tag like v1.x.x
assert!(
CERT_MANAGER_URL.contains("/v1."),
"CERT_MANAGER_URL should reference a v1.x release"
);
}
#[test]
fn gateway_api_crds_url_points_to_github_release() {
assert!(GATEWAY_API_CRDS_URL
.starts_with("https://github.com/kubernetes-sigs/gateway-api/"));
assert!(GATEWAY_API_CRDS_URL.contains("/releases/download/"));
assert!(GATEWAY_API_CRDS_URL.ends_with(".yaml"));
}
#[test]
fn gateway_api_crds_url_has_version() {
assert!(
GATEWAY_API_CRDS_URL.contains("/v1."),
"GATEWAY_API_CRDS_URL should reference a v1.x release"
);
}
#[test]
fn secrets_dir_ends_with_secrets_local() {
let dir = secrets_dir();
assert!(
dir.ends_with("secrets/local"),
"secrets_dir() should end with secrets/local, got: {}",
dir.display()
);
}
#[test]
fn secrets_dir_has_at_least_three_components() {
let dir = secrets_dir();
let components: Vec<_> = dir.components().collect();
assert!(
components.len() >= 3,
"secrets_dir() should have at least 3 path components (base/secrets/local), got: {}",
dir.display()
);
}
#[test]
fn gitea_admin_user_constant() {
assert_eq!(GITEA_ADMIN_USER, "gitea_admin");
}
#[test]
fn print_urls_contains_expected_services() {
// Capture print_urls output by checking the URL construction logic.
// We can't easily capture stdout in unit tests, but we can verify
// the URL format matches expectations.
let domain = "test.local";
let expected_urls = [
format!("https://auth.{domain}/"),
format!("https://docs.{domain}/"),
format!("https://meet.{domain}/"),
format!("https://drive.{domain}/"),
format!("https://chat.{domain}/"),
format!("https://mail.{domain}/"),
format!("https://people.{domain}/"),
format!("https://src.{domain}/"),
];
// Verify URL patterns are valid
for url in &expected_urls {
assert!(url.starts_with("https://"));
assert!(url.contains(domain));
}
}
#[test]
fn print_urls_gitea_includes_credentials() {
let domain = "example.local";
let gitea_url = format!(
"https://src.{domain}/ ({GITEA_ADMIN_USER} / <from openbao>)"
);
assert!(gitea_url.contains(GITEA_ADMIN_USER));
assert!(gitea_url.contains("<from openbao>"));
assert!(gitea_url.contains(&format!("src.{domain}")));
}
}

404
sunbeam-sdk/src/config.rs Normal file
View File

@@ -0,0 +1,404 @@
use crate::error::{Result, ResultExt, SunbeamError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
// ---------------------------------------------------------------------------
// Config data model
// ---------------------------------------------------------------------------
/// Sunbeam configuration stored at ~/.sunbeam.json.
///
/// Supports kubectl-style named contexts. Each context bundles a domain,
/// kube context, SSH host, and infrastructure directory.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SunbeamConfig {
/// The active context name. If empty, uses "default".
#[serde(default, rename = "current-context")]
pub current_context: String,
/// Named contexts.
#[serde(default)]
pub contexts: HashMap<String, Context>,
// --- Legacy fields (migrated on load) ---
#[serde(default, skip_serializing_if = "String::is_empty")]
pub production_host: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub infra_directory: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub acme_email: String,
}
/// A named context — everything needed to target a specific environment.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Context {
/// The domain suffix (e.g. "sunbeam.pt", "192.168.105.3.sslip.io").
#[serde(default)]
pub domain: String,
/// Kubernetes context name (e.g. "production", "sunbeam").
#[serde(default, rename = "kube-context")]
pub kube_context: String,
/// SSH host for production tunnel (e.g. "sienna@62.210.145.138").
#[serde(default, rename = "ssh-host")]
pub ssh_host: String,
/// Infrastructure directory root.
#[serde(default, rename = "infra-dir")]
pub infra_dir: String,
/// ACME email for cert-manager.
#[serde(default, rename = "acme-email")]
pub acme_email: String,
}
// ---------------------------------------------------------------------------
// Active context (set once at startup, read everywhere)
// ---------------------------------------------------------------------------
static ACTIVE_CONTEXT: OnceLock<Context> = OnceLock::new();
/// Initialize the active context. Called once from cli::dispatch().
pub fn set_active_context(ctx: Context) {
let _ = ACTIVE_CONTEXT.set(ctx);
}
/// Get the active context. Panics if not initialized (should never happen
/// after dispatch starts).
pub fn active_context() -> &'static Context {
ACTIVE_CONTEXT.get().expect("active context not initialized")
}
/// Get the domain from the active context. Returns empty string if not set.
pub fn domain() -> &'static str {
ACTIVE_CONTEXT
.get()
.map(|c| c.domain.as_str())
.unwrap_or("")
}
// ---------------------------------------------------------------------------
// Config file I/O
// ---------------------------------------------------------------------------
fn config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".sunbeam.json")
}
/// Load configuration from ~/.sunbeam.json, return default if not found.
/// Migrates legacy flat config to context-based format.
pub fn load_config() -> SunbeamConfig {
let path = config_path();
if !path.exists() {
return SunbeamConfig::default();
}
let mut config: SunbeamConfig = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
crate::output::warn(&format!(
"Failed to parse config from {}: {e}",
path.display()
));
SunbeamConfig::default()
}),
Err(e) => {
crate::output::warn(&format!(
"Failed to read config from {}: {e}",
path.display()
));
SunbeamConfig::default()
}
};
// Migrate legacy flat fields into a "production" context
if !config.production_host.is_empty() && !config.contexts.contains_key("production") {
let domain = derive_domain_from_host(&config.production_host);
config.contexts.insert(
"production".to_string(),
Context {
domain,
kube_context: "production".to_string(),
ssh_host: config.production_host.clone(),
infra_dir: config.infra_directory.clone(),
acme_email: config.acme_email.clone(),
},
);
if config.current_context.is_empty() {
config.current_context = "production".to_string();
}
}
config
}
/// Save configuration to ~/.sunbeam.json.
pub fn save_config(config: &SunbeamConfig) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_ctx(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let content = serde_json::to_string_pretty(config)?;
std::fs::write(&path, content)
.with_ctx(|| format!("Failed to save config to {}", path.display()))?;
crate::output::ok(&format!("Configuration saved to {}", path.display()));
Ok(())
}
/// Resolve the context to use, given CLI flags and config.
///
/// Priority (same as kubectl):
/// 1. `--context` flag (explicit context name)
/// 2. `current-context` from config
/// 3. Default to "local"
pub fn resolve_context(
config: &SunbeamConfig,
_env_flag: &str,
context_override: Option<&str>,
domain_override: &str,
) -> Context {
let context_name = if let Some(explicit) = context_override {
explicit.to_string()
} else if !config.current_context.is_empty() {
config.current_context.clone()
} else {
"local".to_string()
};
let mut ctx = config
.contexts
.get(&context_name)
.cloned()
.unwrap_or_else(|| {
// Synthesize defaults for well-known names
match context_name.as_str() {
"local" => Context {
kube_context: "sunbeam".to_string(),
..Default::default()
},
"production" => Context {
kube_context: "production".to_string(),
ssh_host: config.production_host.clone(),
infra_dir: config.infra_directory.clone(),
acme_email: config.acme_email.clone(),
domain: derive_domain_from_host(&config.production_host),
..Default::default()
},
_ => Default::default(),
}
});
// CLI flags override context values
if !domain_override.is_empty() {
ctx.domain = domain_override.to_string();
}
ctx
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Derive a domain from an SSH host (e.g. "user@admin.sunbeam.pt" → "sunbeam.pt").
fn derive_domain_from_host(host: &str) -> String {
let raw = host.split('@').last().unwrap_or(host);
let raw = raw.split(':').next().unwrap_or(raw);
let parts: Vec<&str> = raw.split('.').collect();
if parts.len() >= 2 {
format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1])
} else {
String::new()
}
}
/// Get production host from config or SUNBEAM_SSH_HOST environment variable.
pub fn get_production_host() -> String {
let config = load_config();
// Check active context first
if let Some(ctx) = ACTIVE_CONTEXT.get() {
if !ctx.ssh_host.is_empty() {
return ctx.ssh_host.clone();
}
}
if !config.production_host.is_empty() {
return config.production_host;
}
std::env::var("SUNBEAM_SSH_HOST").unwrap_or_default()
}
/// Infrastructure manifests directory as a Path.
pub fn get_infra_dir() -> PathBuf {
// Check active context
if let Some(ctx) = ACTIVE_CONTEXT.get() {
if !ctx.infra_dir.is_empty() {
return PathBuf::from(&ctx.infra_dir);
}
}
let configured = load_config().infra_directory;
if !configured.is_empty() {
return PathBuf::from(configured);
}
// Dev fallback
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
.and_then(|p| {
let mut dir = p.as_path();
for _ in 0..10 {
dir = dir.parent()?;
if dir.join("infrastructure").is_dir() {
return Some(dir.join("infrastructure"));
}
}
None
})
.unwrap_or_else(|| PathBuf::from("infrastructure"))
}
/// Monorepo root directory (parent of the infrastructure directory).
pub fn get_repo_root() -> PathBuf {
get_infra_dir()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
/// Clear configuration file.
pub fn clear_config() -> Result<()> {
let path = config_path();
if path.exists() {
std::fs::remove_file(&path)
.with_ctx(|| format!("Failed to remove {}", path.display()))?;
crate::output::ok(&format!("Configuration cleared from {}", path.display()));
} else {
crate::output::warn("No configuration file found to clear");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = SunbeamConfig::default();
assert!(config.current_context.is_empty());
assert!(config.contexts.is_empty());
}
#[test]
fn test_derive_domain_from_host() {
assert_eq!(derive_domain_from_host("sienna@admin.sunbeam.pt"), "sunbeam.pt");
assert_eq!(derive_domain_from_host("user@62.210.145.138"), "145.138");
assert_eq!(derive_domain_from_host("sunbeam.pt"), "sunbeam.pt");
assert_eq!(derive_domain_from_host("localhost"), "");
}
#[test]
fn test_legacy_migration() {
let json = r#"{
"production_host": "sienna@62.210.145.138",
"infra_directory": "/path/to/infra",
"acme_email": "ops@sunbeam.pt"
}"#;
let config: SunbeamConfig = serde_json::from_str(json).unwrap();
// After load_config migration, contexts would be populated.
// Here we just test the struct deserializes legacy fields.
assert_eq!(config.production_host, "sienna@62.210.145.138");
assert!(config.contexts.is_empty()); // migration happens in load_config()
}
#[test]
fn test_context_roundtrip() {
let mut config = SunbeamConfig::default();
config.current_context = "production".to_string();
config.contexts.insert(
"production".to_string(),
Context {
domain: "sunbeam.pt".to_string(),
kube_context: "production".to_string(),
ssh_host: "sienna@server.sunbeam.pt".to_string(),
infra_dir: "/home/infra".to_string(),
acme_email: "ops@sunbeam.pt".to_string(),
},
);
let json = serde_json::to_string(&config).unwrap();
let loaded: SunbeamConfig = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.current_context, "production");
let ctx = loaded.contexts.get("production").unwrap();
assert_eq!(ctx.domain, "sunbeam.pt");
assert_eq!(ctx.ssh_host, "sienna@server.sunbeam.pt");
}
#[test]
fn test_resolve_context_explicit_flag() {
let mut config = SunbeamConfig::default();
config.contexts.insert(
"production".to_string(),
Context {
domain: "sunbeam.pt".to_string(),
kube_context: "production".to_string(),
..Default::default()
},
);
// --context production explicitly selects the named context
let ctx = resolve_context(&config, "", Some("production"), "");
assert_eq!(ctx.domain, "sunbeam.pt");
assert_eq!(ctx.kube_context, "production");
}
#[test]
fn test_resolve_context_current_context() {
let mut config = SunbeamConfig::default();
config.current_context = "staging".to_string();
config.contexts.insert(
"staging".to_string(),
Context {
domain: "staging.example.com".to_string(),
..Default::default()
},
);
// No --context flag, uses current-context
let ctx = resolve_context(&config, "", None, "");
assert_eq!(ctx.domain, "staging.example.com");
}
#[test]
fn test_resolve_context_domain_override() {
let config = SunbeamConfig::default();
let ctx = resolve_context(&config, "", None, "custom.example.com");
assert_eq!(ctx.domain, "custom.example.com");
}
#[test]
fn test_resolve_context_defaults_local() {
let config = SunbeamConfig::default();
// No current-context, no --context flag → defaults to "local"
let ctx = resolve_context(&config, "", None, "");
assert_eq!(ctx.kube_context, "sunbeam");
}
#[test]
fn test_resolve_context_flag_overrides_current() {
let mut config = SunbeamConfig::default();
config.current_context = "staging".to_string();
config.contexts.insert(
"staging".to_string(),
Context { domain: "staging.example.com".to_string(), ..Default::default() },
);
config.contexts.insert(
"prod".to_string(),
Context { domain: "prod.example.com".to_string(), ..Default::default() },
);
// --context prod overrides current-context "staging"
let ctx = resolve_context(&config, "", Some("prod"), "");
assert_eq!(ctx.domain, "prod.example.com");
}
}

View File

@@ -0,0 +1,16 @@
//! Shared constants used across multiple modules.
pub const GITEA_ADMIN_USER: &str = "gitea_admin";
pub const MANAGED_NS: &[&str] = &[
"data",
"devtools",
"ingress",
"lasuite",
"matrix",
"media",
"monitoring",
"ory",
"storage",
"vault-secrets-operator",
];

365
sunbeam-sdk/src/error.rs Normal file
View File

@@ -0,0 +1,365 @@
//! Unified error tree for the sunbeam CLI.
//!
//! Every module returns `Result<T, SunbeamError>`. Errors bubble up to `main`,
//! which maps them to exit codes and log output.
/// Exit codes for the sunbeam CLI.
#[allow(dead_code)]
pub mod exit {
pub const SUCCESS: i32 = 0;
pub const GENERAL: i32 = 1;
pub const USAGE: i32 = 2;
pub const KUBE: i32 = 3;
pub const CONFIG: i32 = 4;
pub const NETWORK: i32 = 5;
pub const SECRETS: i32 = 6;
pub const BUILD: i32 = 7;
pub const IDENTITY: i32 = 8;
pub const EXTERNAL_TOOL: i32 = 9;
}
/// Top-level error type for the sunbeam CLI.
///
/// Each variant maps to a logical error category with its own exit code.
/// Leaf errors (io, json, yaml, kube, reqwest, etc.) are converted via `From` impls.
#[derive(Debug, thiserror::Error)]
pub enum SunbeamError {
/// Kubernetes API or cluster-related error.
#[error("{context}")]
Kube {
context: String,
#[source]
source: Option<kube::Error>,
},
/// Configuration error (missing config, invalid config, bad arguments).
#[error("{0}")]
Config(String),
/// Network/HTTP error.
#[error("{context}")]
Network {
context: String,
#[source]
source: Option<reqwest::Error>,
},
/// OpenBao / Vault error.
#[error("{0}")]
Secrets(String),
/// Image build error.
#[error("{0}")]
Build(String),
/// Identity / user management error (Kratos, Hydra).
#[error("{0}")]
Identity(String),
/// External tool error (kustomize, linkerd, buildctl, yarn, etc.).
#[error("{tool}: {detail}")]
ExternalTool { tool: String, detail: String },
/// IO error.
#[error("{context}: {source}")]
Io {
context: String,
source: std::io::Error,
},
/// JSON serialization/deserialization error.
#[error("{0}")]
Json(#[from] serde_json::Error),
/// YAML serialization/deserialization error.
#[error("{0}")]
Yaml(#[from] serde_yaml::Error),
/// Catch-all for errors that don't fit a specific category.
#[error("{0}")]
Other(String),
}
/// Convenience type alias used throughout the codebase.
pub type Result<T> = std::result::Result<T, SunbeamError>;
impl SunbeamError {
/// Map this error to a process exit code.
pub fn exit_code(&self) -> i32 {
match self {
SunbeamError::Config(_) => exit::CONFIG,
SunbeamError::Kube { .. } => exit::KUBE,
SunbeamError::Network { .. } => exit::NETWORK,
SunbeamError::Secrets(_) => exit::SECRETS,
SunbeamError::Build(_) => exit::BUILD,
SunbeamError::Identity(_) => exit::IDENTITY,
SunbeamError::ExternalTool { .. } => exit::EXTERNAL_TOOL,
SunbeamError::Io { .. } => exit::GENERAL,
SunbeamError::Json(_) => exit::GENERAL,
SunbeamError::Yaml(_) => exit::GENERAL,
SunbeamError::Other(_) => exit::GENERAL,
}
}
}
// ---------------------------------------------------------------------------
// From impls for automatic conversion
// ---------------------------------------------------------------------------
impl From<kube::Error> for SunbeamError {
fn from(e: kube::Error) -> Self {
SunbeamError::Kube {
context: e.to_string(),
source: Some(e),
}
}
}
impl From<reqwest::Error> for SunbeamError {
fn from(e: reqwest::Error) -> Self {
SunbeamError::Network {
context: e.to_string(),
source: Some(e),
}
}
}
impl From<std::io::Error> for SunbeamError {
fn from(e: std::io::Error) -> Self {
SunbeamError::Io {
context: "IO error".into(),
source: e,
}
}
}
impl From<lettre::transport::smtp::Error> for SunbeamError {
fn from(e: lettre::transport::smtp::Error) -> Self {
SunbeamError::Network {
context: format!("SMTP error: {e}"),
source: None,
}
}
}
impl From<lettre::error::Error> for SunbeamError {
fn from(e: lettre::error::Error) -> Self {
SunbeamError::Other(format!("Email error: {e}"))
}
}
impl From<base64::DecodeError> for SunbeamError {
fn from(e: base64::DecodeError) -> Self {
SunbeamError::Other(format!("Base64 decode error: {e}"))
}
}
impl From<std::string::FromUtf8Error> for SunbeamError {
fn from(e: std::string::FromUtf8Error) -> Self {
SunbeamError::Other(format!("UTF-8 error: {e}"))
}
}
// ---------------------------------------------------------------------------
// Context extension trait (replaces anyhow's .context())
// ---------------------------------------------------------------------------
/// Extension trait that adds `.ctx()` to `Result<T, E>` for adding context strings.
/// Replaces `anyhow::Context`.
pub trait ResultExt<T> {
/// Add context to an error, converting it to `SunbeamError`.
fn ctx(self, context: &str) -> Result<T>;
/// Add lazy context to an error.
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T>;
}
impl<T, E: Into<SunbeamError>> ResultExt<T> for std::result::Result<T, E> {
fn ctx(self, context: &str) -> Result<T> {
self.map_err(|e| {
let inner = e.into();
match inner {
SunbeamError::Kube { source, .. } => SunbeamError::Kube {
context: context.to_string(),
source,
},
SunbeamError::Network { source, .. } => SunbeamError::Network {
context: context.to_string(),
source,
},
SunbeamError::Io { source, .. } => SunbeamError::Io {
context: context.to_string(),
source,
},
SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")),
SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")),
SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")),
SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")),
SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool {
tool,
detail: format!("{context}: {detail}"),
},
other => SunbeamError::Other(format!("{context}: {other}")),
}
})
}
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
self.map_err(|e| {
let context = f();
let inner = e.into();
match inner {
SunbeamError::Kube { source, .. } => SunbeamError::Kube {
context,
source,
},
SunbeamError::Network { source, .. } => SunbeamError::Network {
context,
source,
},
SunbeamError::Io { source, .. } => SunbeamError::Io {
context,
source,
},
SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")),
SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")),
SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")),
SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")),
SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool {
tool,
detail: format!("{context}: {detail}"),
},
other => SunbeamError::Other(format!("{context}: {other}")),
}
})
}
}
impl<T> ResultExt<T> for Option<T> {
fn ctx(self, context: &str) -> Result<T> {
self.ok_or_else(|| SunbeamError::Other(context.to_string()))
}
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
self.ok_or_else(|| SunbeamError::Other(f()))
}
}
// ---------------------------------------------------------------------------
// Convenience constructors
// ---------------------------------------------------------------------------
impl SunbeamError {
pub fn kube(context: impl Into<String>) -> Self {
SunbeamError::Kube {
context: context.into(),
source: None,
}
}
pub fn config(msg: impl Into<String>) -> Self {
SunbeamError::Config(msg.into())
}
pub fn network(context: impl Into<String>) -> Self {
SunbeamError::Network {
context: context.into(),
source: None,
}
}
pub fn secrets(msg: impl Into<String>) -> Self {
SunbeamError::Secrets(msg.into())
}
pub fn build(msg: impl Into<String>) -> Self {
SunbeamError::Build(msg.into())
}
pub fn identity(msg: impl Into<String>) -> Self {
SunbeamError::Identity(msg.into())
}
pub fn tool(tool: impl Into<String>, detail: impl Into<String>) -> Self {
SunbeamError::ExternalTool {
tool: tool.into(),
detail: detail.into(),
}
}
}
// ---------------------------------------------------------------------------
// bail! macro replacement
// ---------------------------------------------------------------------------
/// Like anyhow::bail! but produces a SunbeamError::Other.
#[macro_export]
macro_rules! bail {
($($arg:tt)*) => {
return Err($crate::error::SunbeamError::Other(format!($($arg)*)))
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exit_codes() {
assert_eq!(SunbeamError::config("bad").exit_code(), exit::CONFIG);
assert_eq!(SunbeamError::kube("fail").exit_code(), exit::KUBE);
assert_eq!(SunbeamError::network("fail").exit_code(), exit::NETWORK);
assert_eq!(SunbeamError::secrets("fail").exit_code(), exit::SECRETS);
assert_eq!(SunbeamError::build("fail").exit_code(), exit::BUILD);
assert_eq!(SunbeamError::identity("fail").exit_code(), exit::IDENTITY);
assert_eq!(
SunbeamError::tool("kustomize", "not found").exit_code(),
exit::EXTERNAL_TOOL
);
assert_eq!(SunbeamError::Other("oops".into()).exit_code(), exit::GENERAL);
}
#[test]
fn test_display_formatting() {
let e = SunbeamError::tool("kustomize", "build failed");
assert_eq!(e.to_string(), "kustomize: build failed");
let e = SunbeamError::config("missing --domain");
assert_eq!(e.to_string(), "missing --domain");
}
#[test]
fn test_kube_from() {
// Just verify the From impl compiles and categorizes correctly
let e = SunbeamError::kube("test");
assert!(matches!(e, SunbeamError::Kube { .. }));
}
#[test]
fn test_context_extension() {
let result: std::result::Result<(), std::io::Error> =
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
let mapped = result.ctx("reading config");
assert!(mapped.is_err());
let e = mapped.unwrap_err();
assert!(e.to_string().starts_with("reading config"));
assert_eq!(e.exit_code(), exit::GENERAL); // IO maps to general
}
#[test]
fn test_option_context() {
let val: Option<i32> = None;
let result = val.ctx("value not found");
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "value not found");
}
#[test]
fn test_bail_macro() {
fn failing() -> Result<()> {
bail!("something went wrong: {}", 42);
}
let e = failing().unwrap_err();
assert_eq!(e.to_string(), "something went wrong: 42");
}
}

View File

@@ -0,0 +1,429 @@
//! Gitea bootstrap -- admin setup, org creation, OIDC auth source configuration.
use crate::error::Result;
use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, ListParams};
use serde_json::Value;
use crate::kube::{get_client, get_domain, kube_exec, kube_get_secret_field};
use crate::output::{ok, step, warn};
const GITEA_ADMIN_USER: &str = "gitea_admin";
const GITEA_ADMIN_EMAIL: &str = "gitea@local.domain";
/// Bootstrap Gitea: set admin password, create orgs, configure OIDC.
pub async fn cmd_bootstrap() -> Result<()> {
let domain = get_domain().await?;
// Retrieve gitea admin password from cluster secret
let gitea_admin_pass = kube_get_secret_field("devtools", "gitea-admin-credentials", "password")
.await
.unwrap_or_default();
if gitea_admin_pass.is_empty() {
warn("gitea-admin-credentials password not found -- cannot bootstrap.");
return Ok(());
}
step("Bootstrapping Gitea...");
// Wait for a Running + Ready Gitea pod
let pod_name = wait_for_gitea_pod().await?;
let Some(pod) = pod_name else {
warn("Gitea pod not ready after 3 min -- skipping bootstrap.");
return Ok(());
};
// Set admin password
set_admin_password(&pod, &gitea_admin_pass).await?;
// Mark admin as private
mark_admin_private(&pod, &gitea_admin_pass).await?;
// Create orgs
create_orgs(&pod, &gitea_admin_pass).await?;
// Configure OIDC auth source
configure_oidc(&pod, &gitea_admin_pass).await?;
ok(&format!(
"Gitea ready -- https://src.{domain} ({GITEA_ADMIN_USER} / <from openbao>)"
));
Ok(())
}
/// Wait for a Running + Ready Gitea pod (up to 3 minutes).
async fn wait_for_gitea_pod() -> Result<Option<String>> {
let client = get_client().await?;
let pods: Api<Pod> = Api::namespaced(client.clone(), "devtools");
for _ in 0..60 {
let lp = ListParams::default().labels("app.kubernetes.io/name=gitea");
if let Ok(pod_list) = pods.list(&lp).await {
for pod in &pod_list.items {
let phase = pod
.status
.as_ref()
.and_then(|s| s.phase.as_deref())
.unwrap_or("");
if phase != "Running" {
continue;
}
let ready = pod
.status
.as_ref()
.and_then(|s| s.container_statuses.as_ref())
.and_then(|cs| cs.first())
.map(|c| c.ready)
.unwrap_or(false);
if ready {
let name = pod
.metadata
.name
.as_deref()
.unwrap_or("")
.to_string();
if !name.is_empty() {
return Ok(Some(name));
}
}
}
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
Ok(None)
}
/// Set the admin password via gitea CLI exec.
async fn set_admin_password(pod: &str, password: &str) -> Result<()> {
let (code, output) = kube_exec(
"devtools",
pod,
&[
"gitea",
"admin",
"user",
"change-password",
"--username",
GITEA_ADMIN_USER,
"--password",
password,
"--must-change-password=false",
],
Some("gitea"),
)
.await?;
if code == 0 || output.to_lowercase().contains("password") {
ok(&format!("Admin '{GITEA_ADMIN_USER}' password set."));
} else {
warn(&format!("change-password: {output}"));
}
Ok(())
}
/// Call Gitea API via kubectl exec + curl inside the pod.
async fn gitea_api(
pod: &str,
method: &str,
path: &str,
password: &str,
data: Option<&Value>,
) -> Result<Value> {
let url = format!("http://localhost:3000/api/v1{path}");
let auth = format!("{GITEA_ADMIN_USER}:{password}");
let mut args = vec![
"curl", "-s", "-X", method, &url, "-H", "Content-Type: application/json", "-u", &auth,
];
let data_str;
if let Some(d) = data {
data_str = serde_json::to_string(d)?;
args.push("-d");
args.push(&data_str);
}
let (_, stdout) = kube_exec("devtools", pod, &args, Some("gitea")).await?;
Ok(serde_json::from_str(&stdout).unwrap_or(Value::Object(Default::default())))
}
/// Mark the admin account as private.
async fn mark_admin_private(pod: &str, password: &str) -> Result<()> {
let data = serde_json::json!({
"source_id": 0,
"login_name": GITEA_ADMIN_USER,
"email": GITEA_ADMIN_EMAIL,
"visibility": "private",
});
let result = gitea_api(
pod,
"PATCH",
&format!("/admin/users/{GITEA_ADMIN_USER}"),
password,
Some(&data),
)
.await?;
if result.get("login").and_then(|v| v.as_str()) == Some(GITEA_ADMIN_USER) {
ok(&format!("Admin '{GITEA_ADMIN_USER}' marked as private."));
} else {
warn(&format!("Could not set admin visibility: {result}"));
}
Ok(())
}
/// Create the studio and internal organizations.
async fn create_orgs(pod: &str, password: &str) -> Result<()> {
let orgs = [
("studio", "public", "Public source code"),
("internal", "private", "Internal tools and services"),
];
for (org_name, visibility, desc) in &orgs {
let data = serde_json::json!({
"username": org_name,
"visibility": visibility,
"description": desc,
});
let result = gitea_api(pod, "POST", "/orgs", password, Some(&data)).await?;
if result.get("id").is_some() {
ok(&format!("Created org '{org_name}'."));
} else if result
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase()
.contains("already")
{
ok(&format!("Org '{org_name}' already exists."));
} else {
let msg = result
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{result}"));
warn(&format!("Org '{org_name}': {msg}"));
}
}
Ok(())
}
/// Configure Hydra as the OIDC authentication source.
async fn configure_oidc(pod: &str, _password: &str) -> Result<()> {
// List existing auth sources
let (_, auth_list_output) =
kube_exec("devtools", pod, &["gitea", "admin", "auth", "list"], Some("gitea")).await?;
let mut existing_id: Option<String> = None;
let mut exact_ok = false;
for line in auth_list_output.lines().skip(1) {
// Tab-separated: ID\tName\tType\tEnabled
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 2 {
continue;
}
let src_id = parts[0].trim();
let src_name = parts[1].trim();
if src_name == "Sunbeam" {
exact_ok = true;
break;
}
let src_type = if parts.len() > 2 {
parts[2].trim()
} else {
""
};
if src_name == "Sunbeam Auth"
|| (src_name.starts_with("Sunbeam") && src_type == "OAuth2")
{
existing_id = Some(src_id.to_string());
}
}
if exact_ok {
ok("OIDC auth source 'Sunbeam' already present.");
return Ok(());
}
if let Some(eid) = existing_id {
// Wrong name -- rename in-place
let (code, stderr) = kube_exec(
"devtools",
pod,
&[
"gitea",
"admin",
"auth",
"update-oauth",
"--id",
&eid,
"--name",
"Sunbeam",
],
Some("gitea"),
)
.await?;
if code == 0 {
ok(&format!(
"Renamed OIDC auth source (id={eid}) to 'Sunbeam'."
));
} else {
warn(&format!("Rename failed: {stderr}"));
}
return Ok(());
}
// Create new OIDC auth source
let oidc_id = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_ID").await;
let oidc_secret = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_SECRET").await;
match (oidc_id, oidc_secret) {
(Ok(oidc_id), Ok(oidc_sec)) => {
let discover_url =
"http://hydra-public.ory.svc.cluster.local:4444/.well-known/openid-configuration";
let (code, stderr) = kube_exec(
"devtools",
pod,
&[
"gitea",
"admin",
"auth",
"add-oauth",
"--name",
"Sunbeam",
"--provider",
"openidConnect",
"--key",
&oidc_id,
"--secret",
&oidc_sec,
"--auto-discover-url",
discover_url,
"--scopes",
"openid",
"--scopes",
"email",
"--scopes",
"profile",
],
Some("gitea"),
)
.await?;
if code == 0 {
ok("OIDC auth source 'Sunbeam' configured.");
} else {
warn(&format!("OIDC auth source config failed: {stderr}"));
}
}
_ => {
warn("oidc-gitea secret not found -- OIDC auth source not configured.");
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_constants() {
assert_eq!(GITEA_ADMIN_USER, "gitea_admin");
assert_eq!(GITEA_ADMIN_EMAIL, "gitea@local.domain");
}
#[test]
fn test_org_definitions() {
// Verify the org configs match the Python version
let orgs = [
("studio", "public", "Public source code"),
("internal", "private", "Internal tools and services"),
];
assert_eq!(orgs[0].0, "studio");
assert_eq!(orgs[0].1, "public");
assert_eq!(orgs[1].0, "internal");
assert_eq!(orgs[1].1, "private");
}
#[test]
fn test_parse_auth_list_output() {
let output = "ID\tName\tType\tEnabled\n1\tSunbeam\tOAuth2\ttrue\n";
let mut found = false;
for line in output.lines().skip(1) {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 && parts[1].trim() == "Sunbeam" {
found = true;
}
}
assert!(found);
}
#[test]
fn test_parse_auth_list_rename_needed() {
let output = "ID\tName\tType\tEnabled\n5\tSunbeam Auth\tOAuth2\ttrue\n";
let mut rename_id: Option<String> = None;
for line in output.lines().skip(1) {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let name = parts[1].trim();
let typ = parts[2].trim();
if name == "Sunbeam Auth" || (name.starts_with("Sunbeam") && typ == "OAuth2") {
rename_id = Some(parts[0].trim().to_string());
}
}
}
assert_eq!(rename_id, Some("5".to_string()));
}
#[test]
fn test_gitea_api_response_parsing() {
// Simulate a successful org creation response
let json_str = r#"{"id": 1, "username": "studio"}"#;
let val: Value = serde_json::from_str(json_str).unwrap();
assert!(val.get("id").is_some());
// Simulate an "already exists" response
let json_str = r#"{"message": "organization already exists"}"#;
let val: Value = serde_json::from_str(json_str).unwrap();
assert!(val
.get("message")
.unwrap()
.as_str()
.unwrap()
.to_lowercase()
.contains("already"));
}
#[test]
fn test_admin_visibility_patch_body() {
let data = serde_json::json!({
"source_id": 0,
"login_name": GITEA_ADMIN_USER,
"email": GITEA_ADMIN_EMAIL,
"visibility": "private",
});
assert_eq!(data["login_name"], "gitea_admin");
assert_eq!(data["visibility"], "private");
}
}

View File

@@ -0,0 +1,806 @@
//! Per-service image build functions.
use crate::error::{Result, ResultExt, SunbeamError};
use crate::output::{ok, step, warn};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::{build_image, deploy_rollout, get_build_env};
/// Message component definition: (cli_name, image_name, dockerfile_rel, target).
pub const MESSAGES_COMPONENTS: &[(&str, &str, &str, Option<&str>)] = &[
(
"messages-backend",
"messages-backend",
"src/backend/Dockerfile",
Some("runtime-distroless-prod"),
),
(
"messages-frontend",
"messages-frontend",
"src/frontend/Dockerfile",
Some("runtime-prod"),
),
(
"messages-mta-in",
"messages-mta-in",
"src/mta-in/Dockerfile",
None,
),
(
"messages-mta-out",
"messages-mta-out",
"src/mta-out/Dockerfile",
None,
),
(
"messages-mpa",
"messages-mpa",
"src/mpa/rspamd/Dockerfile",
None,
),
(
"messages-socks-proxy",
"messages-socks-proxy",
"src/socks-proxy/Dockerfile",
None,
),
];
pub async fn build_proxy(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let proxy_dir = crate::config::get_repo_root().join("proxy");
if !proxy_dir.is_dir() {
return Err(SunbeamError::build(format!("Proxy source not found at {}", proxy_dir.display())));
}
let image = format!("{}/studio/proxy:latest", env.registry);
step(&format!("Building sunbeam-proxy -> {image} ..."));
build_image(
&env,
&image,
&proxy_dir.join("Dockerfile"),
&proxy_dir,
None,
None,
push,
no_cache,
&[],
)
.await?;
if deploy {
deploy_rollout(&env, &["pingora"], "ingress", 120, Some(&[image])).await?;
}
Ok(())
}
pub async fn build_tuwunel(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let tuwunel_dir = crate::config::get_repo_root().join("tuwunel");
if !tuwunel_dir.is_dir() {
return Err(SunbeamError::build(format!("Tuwunel source not found at {}", tuwunel_dir.display())));
}
let image = format!("{}/studio/tuwunel:latest", env.registry);
step(&format!("Building tuwunel -> {image} ..."));
build_image(
&env,
&image,
&tuwunel_dir.join("Dockerfile"),
&tuwunel_dir,
None,
None,
push,
no_cache,
&[],
)
.await?;
if deploy {
deploy_rollout(&env, &["tuwunel"], "matrix", 180, Some(&[image])).await?;
}
Ok(())
}
pub async fn build_integration(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let sunbeam_dir = crate::config::get_repo_root();
let integration_service_dir = sunbeam_dir.join("integration-service");
let dockerfile = integration_service_dir.join("Dockerfile");
let dockerignore = integration_service_dir.join(".dockerignore");
if !dockerfile.exists() {
return Err(SunbeamError::build(format!(
"integration-service Dockerfile not found at {}",
dockerfile.display()
)));
}
if !sunbeam_dir
.join("integration")
.join("packages")
.join("widgets")
.is_dir()
{
return Err(SunbeamError::build(format!(
"integration repo not found at {} -- \
run: cd sunbeam && git clone https://github.com/suitenumerique/integration.git",
sunbeam_dir.join("integration").display()
)));
}
let image = format!("{}/studio/integration:latest", env.registry);
step(&format!("Building integration -> {image} ..."));
// .dockerignore needs to be at context root
let root_ignore = sunbeam_dir.join(".dockerignore");
let mut copied_ignore = false;
if !root_ignore.exists() && dockerignore.exists() {
std::fs::copy(&dockerignore, &root_ignore).ok();
copied_ignore = true;
}
let result = build_image(
&env,
&image,
&dockerfile,
&sunbeam_dir,
None,
None,
push,
no_cache,
&[],
)
.await;
if copied_ignore && root_ignore.exists() {
let _ = std::fs::remove_file(&root_ignore);
}
result?;
if deploy {
deploy_rollout(&env, &["integration"], "lasuite", 120, None).await?;
}
Ok(())
}
pub async fn build_kratos_admin(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let kratos_admin_dir = crate::config::get_repo_root().join("kratos-admin");
if !kratos_admin_dir.is_dir() {
return Err(SunbeamError::build(format!(
"kratos-admin source not found at {}",
kratos_admin_dir.display()
)));
}
let image = format!("{}/studio/kratos-admin-ui:latest", env.registry);
step(&format!("Building kratos-admin-ui -> {image} ..."));
build_image(
&env,
&image,
&kratos_admin_dir.join("Dockerfile"),
&kratos_admin_dir,
None,
None,
push,
no_cache,
&[],
)
.await?;
if deploy {
deploy_rollout(&env, &["kratos-admin-ui"], "ory", 120, None).await?;
}
Ok(())
}
pub async fn build_meet(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let meet_dir = crate::config::get_repo_root().join("meet");
if !meet_dir.is_dir() {
return Err(SunbeamError::build(format!("meet source not found at {}", meet_dir.display())));
}
let backend_image = format!("{}/studio/meet-backend:latest", env.registry);
let frontend_image = format!("{}/studio/meet-frontend:latest", env.registry);
// Backend
step(&format!("Building meet-backend -> {backend_image} ..."));
build_image(
&env,
&backend_image,
&meet_dir.join("Dockerfile"),
&meet_dir,
Some("backend-production"),
None,
push,
no_cache,
&[],
)
.await?;
// Frontend
step(&format!("Building meet-frontend -> {frontend_image} ..."));
let frontend_dockerfile = meet_dir.join("src").join("frontend").join("Dockerfile");
if !frontend_dockerfile.exists() {
return Err(SunbeamError::build(format!(
"meet frontend Dockerfile not found at {}",
frontend_dockerfile.display()
)));
}
let mut build_args = HashMap::new();
build_args.insert("VITE_API_BASE_URL".to_string(), String::new());
build_image(
&env,
&frontend_image,
&frontend_dockerfile,
&meet_dir,
Some("frontend-production"),
Some(&build_args),
push,
no_cache,
&[],
)
.await?;
if deploy {
deploy_rollout(
&env,
&["meet-backend", "meet-celery-worker", "meet-frontend"],
"lasuite",
180,
None,
)
.await?;
}
Ok(())
}
pub async fn build_people(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let people_dir = crate::config::get_repo_root().join("people");
if !people_dir.is_dir() {
return Err(SunbeamError::build(format!("people source not found at {}", people_dir.display())));
}
let workspace_dir = people_dir.join("src").join("frontend");
let app_dir = workspace_dir.join("apps").join("desk");
let dockerfile = workspace_dir.join("Dockerfile");
if !dockerfile.exists() {
return Err(SunbeamError::build(format!("Dockerfile not found at {}", dockerfile.display())));
}
let image = format!("{}/studio/people-frontend:latest", env.registry);
step(&format!("Building people-frontend -> {image} ..."));
// yarn install
ok("Updating yarn.lock (yarn install in workspace)...");
let yarn_status = tokio::process::Command::new("yarn")
.args(["install", "--ignore-engines"])
.current_dir(&workspace_dir)
.status()
.await
.ctx("Failed to run yarn install")?;
if !yarn_status.success() {
return Err(SunbeamError::tool("yarn", "install failed"));
}
// cunningham design tokens
ok("Regenerating cunningham design tokens...");
let cunningham_bin = workspace_dir
.join("node_modules")
.join(".bin")
.join("cunningham");
let cunningham_status = tokio::process::Command::new(&cunningham_bin)
.args(["-g", "css,ts", "-o", "src/cunningham", "--utility-classes"])
.current_dir(&app_dir)
.status()
.await
.ctx("Failed to run cunningham")?;
if !cunningham_status.success() {
return Err(SunbeamError::tool("cunningham", "design token generation failed"));
}
let mut build_args = HashMap::new();
build_args.insert("DOCKER_USER".to_string(), "101".to_string());
build_image(
&env,
&image,
&dockerfile,
&people_dir,
Some("frontend-production"),
Some(&build_args),
push,
no_cache,
&[],
)
.await?;
if deploy {
deploy_rollout(&env, &["people-frontend"], "lasuite", 180, None).await?;
}
Ok(())
}
pub async fn build_messages(what: &str, push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let messages_dir = crate::config::get_repo_root().join("messages");
if !messages_dir.is_dir() {
return Err(SunbeamError::build(format!("messages source not found at {}", messages_dir.display())));
}
let components: Vec<_> = if what == "messages" {
MESSAGES_COMPONENTS.to_vec()
} else {
MESSAGES_COMPONENTS
.iter()
.filter(|(name, _, _, _)| *name == what)
.copied()
.collect()
};
let mut built_images = Vec::new();
for (component, image_name, dockerfile_rel, target) in &components {
let dockerfile = messages_dir.join(dockerfile_rel);
if !dockerfile.exists() {
warn(&format!(
"Dockerfile not found at {} -- skipping {component}",
dockerfile.display()
));
continue;
}
let image = format!("{}/studio/{image_name}:latest", env.registry);
let context_dir = dockerfile.parent().unwrap_or(&messages_dir);
step(&format!("Building {component} -> {image} ..."));
// Patch ghcr.io/astral-sh/uv COPY for messages-backend on local builds
let mut cleanup_paths = Vec::new();
let actual_dockerfile;
if !env.is_prod && *image_name == "messages-backend" {
let (patched, cleanup) =
patch_dockerfile_uv(&dockerfile, context_dir, &env.platform).await?;
actual_dockerfile = patched;
cleanup_paths = cleanup;
} else {
actual_dockerfile = dockerfile.clone();
}
build_image(
&env,
&image,
&actual_dockerfile,
context_dir,
*target,
None,
push,
no_cache,
&cleanup_paths,
)
.await?;
built_images.push(image);
}
if deploy && !built_images.is_empty() {
deploy_rollout(
&env,
&[
"messages-backend",
"messages-worker",
"messages-frontend",
"messages-mta-in",
"messages-mta-out",
"messages-mpa",
"messages-socks-proxy",
],
"lasuite",
180,
None,
)
.await?;
}
Ok(())
}
/// Build a La Suite frontend image from source and push to the Gitea registry.
#[allow(clippy::too_many_arguments)]
pub async fn build_la_suite_frontend(
app: &str,
repo_dir: &Path,
workspace_rel: &str,
app_rel: &str,
dockerfile_rel: &str,
image_name: &str,
deployment: &str,
namespace: &str,
push: bool,
deploy: bool,
no_cache: bool,
) -> Result<()> {
let env = get_build_env().await?;
let workspace_dir = repo_dir.join(workspace_rel);
let app_dir = repo_dir.join(app_rel);
let dockerfile = repo_dir.join(dockerfile_rel);
if !repo_dir.is_dir() {
return Err(SunbeamError::build(format!("{app} source not found at {}", repo_dir.display())));
}
if !dockerfile.exists() {
return Err(SunbeamError::build(format!("Dockerfile not found at {}", dockerfile.display())));
}
let image = format!("{}/studio/{image_name}:latest", env.registry);
step(&format!("Building {app} -> {image} ..."));
ok("Updating yarn.lock (yarn install in workspace)...");
let yarn_status = tokio::process::Command::new("yarn")
.args(["install", "--ignore-engines"])
.current_dir(&workspace_dir)
.status()
.await
.ctx("Failed to run yarn install")?;
if !yarn_status.success() {
return Err(SunbeamError::tool("yarn", "install failed"));
}
ok("Regenerating cunningham design tokens (yarn build-theme)...");
let theme_status = tokio::process::Command::new("yarn")
.args(["build-theme"])
.current_dir(&app_dir)
.status()
.await
.ctx("Failed to run yarn build-theme")?;
if !theme_status.success() {
return Err(SunbeamError::tool("yarn", "build-theme failed"));
}
let mut build_args = HashMap::new();
build_args.insert("DOCKER_USER".to_string(), "101".to_string());
build_image(
&env,
&image,
&dockerfile,
repo_dir,
Some("frontend-production"),
Some(&build_args),
push,
no_cache,
&[],
)
.await?;
if deploy {
deploy_rollout(&env, &[deployment], namespace, 180, None).await?;
}
Ok(())
}
/// Download uv from GitHub releases and return a patched Dockerfile path.
pub async fn patch_dockerfile_uv(
dockerfile_path: &Path,
context_dir: &Path,
platform: &str,
) -> Result<(PathBuf, Vec<PathBuf>)> {
let content = std::fs::read_to_string(dockerfile_path)
.ctx("Failed to read Dockerfile for uv patching")?;
// Match COPY --from=ghcr.io/astral-sh/uv@sha256:... /uv /uvx /bin/
let original_copy = content
.lines()
.find(|line| {
line.contains("COPY")
&& line.contains("--from=ghcr.io/astral-sh/uv@sha256:")
&& line.contains("/uv")
&& line.contains("/bin/")
})
.map(|line| line.trim().to_string());
let original_copy = match original_copy {
Some(c) => c,
None => return Ok((dockerfile_path.to_path_buf(), vec![])),
};
// Find uv version from comment like: oci://ghcr.io/astral-sh/uv:0.x.y
let version = content
.lines()
.find_map(|line| {
let marker = "oci://ghcr.io/astral-sh/uv:";
if let Some(idx) = line.find(marker) {
let rest = &line[idx + marker.len()..];
let ver = rest.split_whitespace().next().unwrap_or("");
if !ver.is_empty() {
Some(ver.to_string())
} else {
None
}
} else {
None
}
});
let version = match version {
Some(v) => v,
None => {
warn("Could not find uv version comment in Dockerfile; ghcr.io pull may fail.");
return Ok((dockerfile_path.to_path_buf(), vec![]));
}
};
let arch = if platform.contains("amd64") {
"x86_64"
} else {
"aarch64"
};
let url = format!(
"https://github.com/astral-sh/uv/releases/download/{version}/uv-{arch}-unknown-linux-gnu.tar.gz"
);
let stage_dir = context_dir.join("_sunbeam_uv_stage");
let patched_df = dockerfile_path
.parent()
.unwrap_or(dockerfile_path)
.join("Dockerfile._sunbeam_patched");
let cleanup = vec![stage_dir.clone(), patched_df.clone()];
ok(&format!(
"Downloading uv {version} ({arch}) from GitHub releases to bypass ghcr.io..."
));
std::fs::create_dir_all(&stage_dir)?;
// Download tarball
let response = reqwest::get(&url)
.await
.ctx("Failed to download uv release")?;
let tarball_bytes = response.bytes().await?;
// Extract uv and uvx from tarball
let decoder = flate2::read::GzDecoder::new(&tarball_bytes[..]);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_path_buf();
let file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if (file_name == "uv" || file_name == "uvx") && entry.header().entry_type().is_file() {
let dest = stage_dir.join(&file_name);
let mut outfile = std::fs::File::create(&dest)?;
std::io::copy(&mut entry, &mut outfile)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
}
}
}
if !stage_dir.join("uv").exists() {
warn("uv binary not found in release tarball; build may fail.");
return Ok((dockerfile_path.to_path_buf(), cleanup));
}
let patched = content.replace(
&original_copy,
"COPY _sunbeam_uv_stage/uv _sunbeam_uv_stage/uvx /bin/",
);
std::fs::write(&patched_df, patched)?;
ok(&format!(" uv {version} staged; using patched Dockerfile."));
Ok((patched_df, cleanup))
}
pub async fn build_projects(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let projects_dir = crate::config::get_repo_root().join("projects");
if !projects_dir.is_dir() {
return Err(SunbeamError::build(format!("projects source not found at {}", projects_dir.display())));
}
let image = format!("{}/studio/projects:latest", env.registry);
step(&format!("Building projects -> {image} ..."));
build_image(
&env,
&image,
&projects_dir.join("Dockerfile"),
&projects_dir,
None,
None,
push,
no_cache,
&[],
)
.await?;
if deploy {
deploy_rollout(&env, &["projects"], "lasuite", 180, Some(&[image])).await?;
}
Ok(())
}
// TODO: first deploy requires registration enabled on tuwunel to create
// the @sol:sunbeam.pt bot account. Flow:
// 1. Set allow_registration = true in tuwunel-config.yaml
// 2. Apply + restart tuwunel
// 3. Register bot via POST /_matrix/client/v3/register with registration token
// 4. Store access_token + device_id in OpenBao at secret/sol
// 5. Set allow_registration = false, re-apply
// 6. Then build + deploy sol
// This should be automated as `sunbeam user create-bot <name>`.
pub async fn build_sol(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let sol_dir = crate::config::get_repo_root().join("sol");
if !sol_dir.is_dir() {
return Err(SunbeamError::build(format!("Sol source not found at {}", sol_dir.display())));
}
let image = format!("{}/studio/sol:latest", env.registry);
step(&format!("Building sol -> {image} ..."));
build_image(
&env,
&image,
&sol_dir.join("Dockerfile"),
&sol_dir,
None,
None,
push,
no_cache,
&[],
)
.await?;
if deploy {
deploy_rollout(&env, &["sol"], "matrix", 120, None).await?;
}
Ok(())
}
pub async fn build_calendars(push: bool, deploy: bool, no_cache: bool) -> Result<()> {
let env = get_build_env().await?;
let cal_dir = crate::config::get_repo_root().join("calendars");
if !cal_dir.is_dir() {
return Err(SunbeamError::build(format!("calendars source not found at {}", cal_dir.display())));
}
let backend_dir = cal_dir.join("src").join("backend");
let backend_image = format!("{}/studio/calendars-backend:latest", env.registry);
step(&format!("Building calendars-backend -> {backend_image} ..."));
// Stage translations.json into the build context
let translations_src = cal_dir
.join("src")
.join("frontend")
.join("apps")
.join("calendars")
.join("src")
.join("features")
.join("i18n")
.join("translations.json");
let translations_dst = backend_dir.join("_translations.json");
let mut cleanup: Vec<PathBuf> = Vec::new();
let mut dockerfile = backend_dir.join("Dockerfile");
if translations_src.exists() {
std::fs::copy(&translations_src, &translations_dst)?;
cleanup.push(translations_dst);
// Patch Dockerfile to COPY translations into production image
let mut content = std::fs::read_to_string(&dockerfile)?;
content.push_str(
"\n# Sunbeam: bake translations.json for default calendar names\n\
COPY _translations.json /data/translations.json\n",
);
let patched_df = backend_dir.join("Dockerfile._sunbeam_patched");
std::fs::write(&patched_df, content)?;
cleanup.push(patched_df.clone());
dockerfile = patched_df;
}
build_image(
&env,
&backend_image,
&dockerfile,
&backend_dir,
Some("backend-production"),
None,
push,
no_cache,
&cleanup,
)
.await?;
// caldav
let caldav_image = format!("{}/studio/calendars-caldav:latest", env.registry);
step(&format!("Building calendars-caldav -> {caldav_image} ..."));
let caldav_dir = cal_dir.join("src").join("caldav");
build_image(
&env,
&caldav_image,
&caldav_dir.join("Dockerfile"),
&caldav_dir,
None,
None,
push,
no_cache,
&[],
)
.await?;
// frontend
let frontend_image = format!("{}/studio/calendars-frontend:latest", env.registry);
step(&format!(
"Building calendars-frontend -> {frontend_image} ..."
));
let integration_base = format!("https://integration.{}", env.domain);
let mut build_args = HashMap::new();
build_args.insert(
"VISIO_BASE_URL".to_string(),
format!("https://meet.{}", env.domain),
);
build_args.insert(
"GAUFRE_WIDGET_PATH".to_string(),
format!("{integration_base}/api/v2/lagaufre.js"),
);
build_args.insert(
"GAUFRE_API_URL".to_string(),
format!("{integration_base}/api/v2/services.json"),
);
build_args.insert(
"THEME_CSS_URL".to_string(),
format!("{integration_base}/api/v2/theme.css"),
);
let frontend_dir = cal_dir.join("src").join("frontend");
build_image(
&env,
&frontend_image,
&frontend_dir.join("Dockerfile"),
&frontend_dir,
Some("frontend-production"),
Some(&build_args),
push,
no_cache,
&[],
)
.await?;
if deploy {
deploy_rollout(
&env,
&[
"calendars-backend",
"calendars-worker",
"calendars-caldav",
"calendars-frontend",
],
"lasuite",
180,
Some(&[backend_image, caldav_image, frontend_image]),
)
.await?;
}
Ok(())
}

File diff suppressed because it is too large Load Diff

761
sunbeam-sdk/src/kube/mod.rs Normal file
View File

@@ -0,0 +1,761 @@
mod tunnel;
pub mod tools;
use crate::error::{Result, SunbeamError, ResultExt};
use base64::Engine;
use k8s_openapi::api::apps::v1::Deployment;
use k8s_openapi::api::core::v1::{Namespace, Secret};
use kube::api::{Api, ApiResource, DynamicObject, ListParams, Patch, PatchParams};
use kube::config::{KubeConfigOptions, Kubeconfig};
use kube::discovery::{self, Scope};
use kube::{Client, Config};
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::sync::{Mutex, OnceLock};
use tokio::sync::OnceCell;
static CONTEXT: OnceLock<String> = OnceLock::new();
static SSH_HOST: OnceLock<String> = OnceLock::new();
static KUBE_CLIENT: OnceCell<Client> = OnceCell::const_new();
static SSH_TUNNEL: Mutex<Option<tokio::process::Child>> = Mutex::new(None);
static API_DISCOVERY: OnceCell<kube::discovery::Discovery> = OnceCell::const_new();
/// Set the active kubectl context and optional SSH host for production tunnel.
pub fn set_context(ctx: &str, ssh_host: &str) {
let _ = CONTEXT.set(ctx.to_string());
let _ = SSH_HOST.set(ssh_host.to_string());
}
/// Get the active context.
pub fn context() -> &'static str {
CONTEXT.get().map(|s| s.as_str()).unwrap_or("sunbeam")
}
/// Get the SSH host (empty for local).
pub fn ssh_host() -> &'static str {
SSH_HOST.get().map(|s| s.as_str()).unwrap_or("")
}
// ---------------------------------------------------------------------------
// SSH tunnel management
// ---------------------------------------------------------------------------
/// Ensure SSH tunnel is open for production (forwards localhost:16443 -> remote:6443).
/// For local dev (empty ssh_host), this is a no-op.
#[allow(dead_code)]
pub async fn ensure_tunnel() -> Result<()> {
let host = ssh_host();
if host.is_empty() {
return Ok(());
}
// Check if tunnel is already open
if tokio::net::TcpStream::connect("127.0.0.1:16443")
.await
.is_ok()
{
return Ok(());
}
crate::output::ok(&format!("Opening SSH tunnel to {host}..."));
let child = tokio::process::Command::new("ssh")
.args([
"-p",
"2222",
"-L",
"16443:127.0.0.1:6443",
"-N",
"-o",
"ExitOnForwardFailure=yes",
"-o",
"StrictHostKeyChecking=no",
host,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ctx("Failed to spawn SSH tunnel")?;
// Store child so it lives for the process lifetime (and can be killed on cleanup)
if let Ok(mut guard) = SSH_TUNNEL.lock() {
*guard = Some(child);
}
// Wait for tunnel to become available
for _ in 0..20 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if tokio::net::TcpStream::connect("127.0.0.1:16443")
.await
.is_ok()
{
return Ok(());
}
}
bail!("SSH tunnel to {host} did not open in time")
}
// ---------------------------------------------------------------------------
// Client initialization
// ---------------------------------------------------------------------------
/// Get or create a kube::Client configured for the active context.
/// Opens SSH tunnel first if needed for production.
pub async fn get_client() -> Result<&'static Client> {
KUBE_CLIENT
.get_or_try_init(|| async {
ensure_tunnel().await?;
let kubeconfig = Kubeconfig::read().map_err(|e| SunbeamError::kube(format!("Failed to read kubeconfig: {e}")))?;
let options = KubeConfigOptions {
context: Some(context().to_string()),
..Default::default()
};
let config = Config::from_custom_kubeconfig(kubeconfig, &options)
.await
.map_err(|e| SunbeamError::kube(format!("Failed to build kube config from kubeconfig: {e}")))?;
Client::try_from(config).ctx("Failed to create kube client")
})
.await
}
// ---------------------------------------------------------------------------
// Core Kubernetes operations
// ---------------------------------------------------------------------------
/// Server-side apply a multi-document YAML manifest.
#[allow(dead_code)]
pub async fn kube_apply(manifest: &str) -> Result<()> {
let client = get_client().await?;
let ssapply = PatchParams::apply("sunbeam").force();
for doc in manifest.split("\n---") {
let doc = doc.trim();
if doc.is_empty() || doc == "---" {
continue;
}
// Parse the YAML to a DynamicObject so we can route it
let obj: serde_yaml::Value =
serde_yaml::from_str(doc).ctx("Failed to parse YAML document")?;
let api_version = obj
.get("apiVersion")
.and_then(|v| v.as_str())
.unwrap_or("");
let kind = obj.get("kind").and_then(|v| v.as_str()).unwrap_or("");
let metadata = obj.get("metadata");
let name = metadata
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("");
let namespace = metadata
.and_then(|m| m.get("namespace"))
.and_then(|v| v.as_str());
if name.is_empty() || kind.is_empty() {
continue; // skip incomplete documents
}
// Use discovery to find the right API resource
let (ar, scope) = resolve_api_resource(client, api_version, kind).await?;
let api: Api<DynamicObject> = if let Some(ns) = namespace {
Api::namespaced_with(client.clone(), ns, &ar)
} else if scope == Scope::Namespaced {
// Namespaced resource without a namespace specified; use default
Api::default_namespaced_with(client.clone(), &ar)
} else {
Api::all_with(client.clone(), &ar)
};
let patch: serde_json::Value =
serde_yaml::from_str(doc).ctx("Failed to parse YAML to JSON value")?;
api.patch(name, &ssapply, &Patch::Apply(patch))
.await
.with_ctx(|| format!("Failed to apply {kind}/{name}"))?;
}
Ok(())
}
/// Resolve an API resource from apiVersion and kind using discovery.
async fn resolve_api_resource(
client: &Client,
api_version: &str,
kind: &str,
) -> Result<(ApiResource, Scope)> {
// Split apiVersion into group and version
let (group, version) = if api_version.contains('/') {
let parts: Vec<&str> = api_version.splitn(2, '/').collect();
(parts[0], parts[1])
} else {
("", api_version) // core API group
};
let disc = API_DISCOVERY
.get_or_try_init(|| async {
discovery::Discovery::new(client.clone())
.run()
.await
.ctx("API discovery failed")
})
.await?;
for api_group in disc.groups() {
if api_group.name() == group {
for (ar, caps) in api_group.resources_by_stability() {
if ar.kind == kind && ar.version == version {
return Ok((ar, caps.scope));
}
}
}
}
bail!("Could not discover API resource for {api_version}/{kind}")
}
/// Get a Kubernetes Secret object.
#[allow(dead_code)]
pub async fn kube_get_secret(ns: &str, name: &str) -> Result<Option<Secret>> {
let client = get_client().await?;
let api: Api<Secret> = Api::namespaced(client.clone(), ns);
match api.get_opt(name).await {
Ok(secret) => Ok(secret),
Err(e) => Err(e).with_ctx(|| format!("Failed to get secret {ns}/{name}")),
}
}
/// Get a specific base64-decoded field from a Kubernetes secret.
#[allow(dead_code)]
pub async fn kube_get_secret_field(ns: &str, name: &str, key: &str) -> Result<String> {
let secret = kube_get_secret(ns, name)
.await?
.with_ctx(|| format!("Secret {ns}/{name} not found"))?;
let data = secret.data.as_ref().ctx("Secret has no data")?;
let bytes = data
.get(key)
.with_ctx(|| format!("Key {key:?} not found in secret {ns}/{name}"))?;
String::from_utf8(bytes.0.clone())
.with_ctx(|| format!("Key {key:?} in secret {ns}/{name} is not valid UTF-8"))
}
/// Check if a namespace exists.
#[allow(dead_code)]
pub async fn ns_exists(ns: &str) -> Result<bool> {
let client = get_client().await?;
let api: Api<Namespace> = Api::all(client.clone());
match api.get_opt(ns).await {
Ok(Some(_)) => Ok(true),
Ok(None) => Ok(false),
Err(e) => Err(e).with_ctx(|| format!("Failed to check namespace {ns}")),
}
}
/// Create namespace if it does not exist.
#[allow(dead_code)]
pub async fn ensure_ns(ns: &str) -> Result<()> {
if ns_exists(ns).await? {
return Ok(());
}
let client = get_client().await?;
let api: Api<Namespace> = Api::all(client.clone());
let ns_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Namespace",
"metadata": { "name": ns }
});
let pp = PatchParams::apply("sunbeam").force();
api.patch(ns, &pp, &Patch::Apply(ns_obj))
.await
.with_ctx(|| format!("Failed to create namespace {ns}"))?;
Ok(())
}
/// Create or update a generic Kubernetes secret via server-side apply.
#[allow(dead_code)]
pub async fn create_secret(ns: &str, name: &str, data: HashMap<String, String>) -> Result<()> {
let client = get_client().await?;
let api: Api<Secret> = Api::namespaced(client.clone(), ns);
// Encode values as base64
let mut encoded: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
for (k, v) in &data {
let b64 = base64::engine::general_purpose::STANDARD.encode(v.as_bytes());
encoded.insert(k.clone(), serde_json::Value::String(b64));
}
let secret_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": name,
"namespace": ns,
},
"type": "Opaque",
"data": encoded,
});
let pp = PatchParams::apply("sunbeam").force();
api.patch(name, &pp, &Patch::Apply(secret_obj))
.await
.with_ctx(|| format!("Failed to create/update secret {ns}/{name}"))?;
Ok(())
}
/// Execute a command in a pod and return (exit_code, stdout).
#[allow(dead_code)]
pub async fn kube_exec(
ns: &str,
pod: &str,
cmd: &[&str],
container: Option<&str>,
) -> Result<(i32, String)> {
let client = get_client().await?;
let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::namespaced(client.clone(), ns);
let mut ep = kube::api::AttachParams::default();
ep.stdout = true;
ep.stderr = true;
ep.stdin = false;
if let Some(c) = container {
ep.container = Some(c.to_string());
}
let cmd_strings: Vec<String> = cmd.iter().map(|s| s.to_string()).collect();
let mut attached = pods
.exec(pod, cmd_strings, &ep)
.await
.with_ctx(|| format!("Failed to exec in pod {ns}/{pod}"))?;
let stdout = {
let mut stdout_reader = attached
.stdout()
.ctx("No stdout stream from exec")?;
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut stdout_reader, &mut buf).await?;
String::from_utf8_lossy(&buf).to_string()
};
let status = attached
.take_status()
.ctx("No status channel from exec")?;
// Wait for the status
let exit_code = if let Some(status) = status.await {
status
.status
.map(|s| if s == "Success" { 0 } else { 1 })
.unwrap_or(1)
} else {
1
};
Ok((exit_code, stdout.trim().to_string()))
}
/// Patch a deployment to trigger a rollout restart.
#[allow(dead_code)]
pub async fn kube_rollout_restart(ns: &str, deployment: &str) -> Result<()> {
let client = get_client().await?;
let api: Api<Deployment> = Api::namespaced(client.clone(), ns);
let now = chrono::Utc::now().to_rfc3339();
let patch = serde_json::json!({
"spec": {
"template": {
"metadata": {
"annotations": {
"kubectl.kubernetes.io/restartedAt": now
}
}
}
}
});
api.patch(deployment, &PatchParams::default(), &Patch::Strategic(patch))
.await
.with_ctx(|| format!("Failed to restart deployment {ns}/{deployment}"))?;
Ok(())
}
/// Discover the active domain from cluster state.
///
/// Tries the gitea-inline-config secret first (DOMAIN=src.<domain>),
/// falls back to lasuite-oidc-provider configmap, then Lima VM IP.
#[allow(dead_code)]
pub async fn get_domain() -> Result<String> {
// 1. Gitea inline-config secret
if let Ok(Some(secret)) = kube_get_secret("devtools", "gitea-inline-config").await {
if let Some(data) = &secret.data {
if let Some(server_bytes) = data.get("server") {
let server_ini = String::from_utf8_lossy(&server_bytes.0);
for line in server_ini.lines() {
if let Some(rest) = line.strip_prefix("DOMAIN=src.") {
return Ok(rest.trim().to_string());
}
}
}
}
}
// 2. Fallback: lasuite-oidc-provider configmap
{
let client = get_client().await?;
let api: Api<k8s_openapi::api::core::v1::ConfigMap> =
Api::namespaced(client.clone(), "lasuite");
if let Ok(Some(cm)) = api.get_opt("lasuite-oidc-provider").await {
if let Some(data) = &cm.data {
if let Some(endpoint) = data.get("OIDC_OP_JWKS_ENDPOINT") {
if let Some(rest) = endpoint.split("https://auth.").nth(1) {
if let Some(domain) = rest.split('/').next() {
return Ok(domain.to_string());
}
}
}
}
}
}
// 3. Local dev fallback: Lima VM IP
let ip = get_lima_ip().await;
Ok(format!("{ip}.sslip.io"))
}
/// Get the socket_vmnet IP of the Lima sunbeam VM.
async fn get_lima_ip() -> String {
let output = tokio::process::Command::new("limactl")
.args(["shell", "sunbeam", "ip", "-4", "addr", "show", "eth1"])
.output()
.await;
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if line.contains("inet ") {
if let Some(addr) = line.trim().split_whitespace().nth(1) {
if let Some(ip) = addr.split('/').next() {
return ip.to_string();
}
}
}
}
}
// Fallback: hostname -I
let output2 = tokio::process::Command::new("limactl")
.args(["shell", "sunbeam", "hostname", "-I"])
.output()
.await;
if let Ok(out) = output2 {
let stdout = String::from_utf8_lossy(&out.stdout);
let ips: Vec<&str> = stdout.trim().split_whitespace().collect();
if ips.len() >= 2 {
return ips[ips.len() - 1].to_string();
} else if !ips.is_empty() {
return ips[0].to_string();
}
}
String::new()
}
// ---------------------------------------------------------------------------
// kustomize build
// ---------------------------------------------------------------------------
/// Run kustomize build --enable-helm and apply domain/email substitution.
#[allow(dead_code)]
pub async fn kustomize_build(overlay: &Path, domain: &str, email: &str) -> Result<String> {
let kustomize_path = self::tools::ensure_kustomize()?;
let helm_path = self::tools::ensure_helm()?;
// Ensure helm's parent dir is on PATH so kustomize can find it
let helm_dir = helm_path
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let mut env_path = helm_dir.clone();
if let Ok(existing) = std::env::var("PATH") {
env_path = format!("{helm_dir}:{existing}");
}
let output = tokio::process::Command::new(&kustomize_path)
.args(["build", "--enable-helm"])
.arg(overlay)
.env("PATH", &env_path)
.output()
.await
.ctx("Failed to run kustomize")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("kustomize build failed: {stderr}");
}
let mut text = String::from_utf8(output.stdout).ctx("kustomize output not UTF-8")?;
// Domain substitution
text = domain_replace(&text, domain);
// ACME email substitution
if !email.is_empty() {
text = text.replace("ACME_EMAIL", email);
}
// Registry host IP resolution
if text.contains("REGISTRY_HOST_IP") {
let registry_ip = resolve_registry_ip(domain).await;
text = text.replace("REGISTRY_HOST_IP", &registry_ip);
}
// Strip null annotations artifact
text = text.replace("\n annotations: null", "");
Ok(text)
}
/// Resolve the registry host IP for REGISTRY_HOST_IP substitution.
async fn resolve_registry_ip(domain: &str) -> String {
// Try DNS for src.<domain>
let hostname = format!("src.{domain}:443");
if let Ok(mut addrs) = tokio::net::lookup_host(&hostname).await {
if let Some(addr) = addrs.next() {
return addr.ip().to_string();
}
}
// Fallback: derive from production host config
let ssh_host = crate::config::get_production_host();
if !ssh_host.is_empty() {
let raw = ssh_host
.split('@')
.last()
.unwrap_or(&ssh_host)
.split(':')
.next()
.unwrap_or(&ssh_host);
let host_lookup = format!("{raw}:443");
if let Ok(mut addrs) = tokio::net::lookup_host(&host_lookup).await {
if let Some(addr) = addrs.next() {
return addr.ip().to_string();
}
}
// raw is likely already an IP
return raw.to_string();
}
String::new()
}
// ---------------------------------------------------------------------------
// kubectl / bao passthrough
// ---------------------------------------------------------------------------
/// Transparent kubectl passthrough for the active context.
pub async fn cmd_k8s(kubectl_args: &[String]) -> Result<()> {
ensure_tunnel().await?;
let status = tokio::process::Command::new("kubectl")
.arg(format!("--context={}", context()))
.args(kubectl_args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await
.ctx("Failed to run kubectl")?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
/// Run bao CLI inside the OpenBao pod with the root token.
pub async fn cmd_bao(bao_args: &[String]) -> Result<()> {
// Find the openbao pod
let client = get_client().await?;
let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::namespaced(client.clone(), "data");
let lp = ListParams::default().labels("app.kubernetes.io/name=openbao");
let pod_list = pods.list(&lp).await.ctx("Failed to list OpenBao pods")?;
let ob_pod = pod_list
.items
.first()
.and_then(|p| p.metadata.name.as_deref())
.ctx("OpenBao pod not found -- is the cluster running?")?
.to_string();
// Get root token
let root_token = kube_get_secret_field("data", "openbao-keys", "root-token")
.await
.ctx("root-token not found in openbao-keys secret")?;
// Build the kubectl exec command
let vault_token_env = format!("VAULT_TOKEN={root_token}");
let mut kubectl_args = vec![
format!("--context={}", context()),
"-n".to_string(),
"data".to_string(),
"exec".to_string(),
ob_pod,
"-c".to_string(),
"openbao".to_string(),
"--".to_string(),
"env".to_string(),
vault_token_env,
"bao".to_string(),
];
kubectl_args.extend(bao_args.iter().cloned());
// Use kubectl for full TTY support
let status = tokio::process::Command::new("kubectl")
.args(&kubectl_args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.await
.ctx("Failed to run bao in OpenBao pod")?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Parse target and domain_replace (already tested)
// ---------------------------------------------------------------------------
/// Parse 'ns/name' -> (Some(ns), Some(name)), 'ns' -> (Some(ns), None), None -> (None, None).
pub fn parse_target(s: Option<&str>) -> Result<(Option<&str>, Option<&str>)> {
match s {
None => Ok((None, None)),
Some(s) => {
let parts: Vec<&str> = s.splitn(3, '/').collect();
match parts.len() {
1 => Ok((Some(parts[0]), None)),
2 => Ok((Some(parts[0]), Some(parts[1]))),
_ => bail!("Invalid target {s:?}: expected 'namespace' or 'namespace/name'"),
}
}
}
}
/// Replace all occurrences of DOMAIN_SUFFIX with domain.
pub fn domain_replace(text: &str, domain: &str) -> String {
text.replace("DOMAIN_SUFFIX", domain)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_target_none() {
let (ns, name) = parse_target(None).unwrap();
assert!(ns.is_none());
assert!(name.is_none());
}
#[test]
fn test_parse_target_namespace_only() {
let (ns, name) = parse_target(Some("ory")).unwrap();
assert_eq!(ns, Some("ory"));
assert!(name.is_none());
}
#[test]
fn test_parse_target_namespace_and_name() {
let (ns, name) = parse_target(Some("ory/kratos")).unwrap();
assert_eq!(ns, Some("ory"));
assert_eq!(name, Some("kratos"));
}
#[test]
fn test_parse_target_too_many_parts() {
assert!(parse_target(Some("too/many/parts")).is_err());
}
#[test]
fn test_parse_target_empty_string() {
let (ns, name) = parse_target(Some("")).unwrap();
assert_eq!(ns, Some(""));
assert!(name.is_none());
}
#[test]
fn test_domain_replace_single() {
let result = domain_replace("src.DOMAIN_SUFFIX/foo", "192.168.1.1.sslip.io");
assert_eq!(result, "src.192.168.1.1.sslip.io/foo");
}
#[test]
fn test_domain_replace_multiple() {
let result = domain_replace("DOMAIN_SUFFIX and DOMAIN_SUFFIX", "x.sslip.io");
assert_eq!(result, "x.sslip.io and x.sslip.io");
}
#[test]
fn test_domain_replace_none() {
let result = domain_replace("no match here", "x.sslip.io");
assert_eq!(result, "no match here");
}
#[tokio::test]
async fn test_ensure_tunnel_noop_when_ssh_host_empty() {
// When ssh_host is empty (local dev), ensure_tunnel should return Ok
// immediately without spawning any SSH process.
// SSH_HOST OnceLock may already be set from another test, but the
// default (unset) value is "" which is what we want. If it was set
// to a non-empty value by a prior test in the same process, this
// test would attempt a real SSH connection and fail — that is acceptable
// as a signal that test isolation changed.
//
// In a fresh test binary SSH_HOST is unset, so ssh_host() returns "".
let result = ensure_tunnel().await;
assert!(result.is_ok(), "ensure_tunnel should be a no-op when ssh_host is empty");
}
#[test]
fn test_create_secret_data_encoding() {
// Test that we can build the expected JSON structure for secret creation
let mut data = HashMap::new();
data.insert("username".to_string(), "admin".to_string());
data.insert("password".to_string(), "s3cret".to_string());
let mut encoded: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
for (k, v) in &data {
let b64 = base64::engine::general_purpose::STANDARD.encode(v.as_bytes());
encoded.insert(k.clone(), serde_json::Value::String(b64));
}
let secret_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "test-secret",
"namespace": "default",
},
"type": "Opaque",
"data": encoded,
});
let json_str = serde_json::to_string(&secret_obj).unwrap();
assert!(json_str.contains("YWRtaW4=")); // base64("admin")
assert!(json_str.contains("czNjcmV0")); // base64("s3cret")
}
}

View File

@@ -0,0 +1,180 @@
use crate::error::{Result, ResultExt};
use std::path::PathBuf;
static KUSTOMIZE_BIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/kustomize"));
static HELM_BIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/helm"));
fn cache_dir() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")))
.join("sunbeam")
.join("bin")
}
/// Extract an embedded binary to the cache directory if not already present.
fn extract_embedded(data: &[u8], name: &str) -> Result<PathBuf> {
let dir = cache_dir();
std::fs::create_dir_all(&dir)
.with_ctx(|| format!("Failed to create cache dir: {}", dir.display()))?;
let dest = dir.join(name);
// Skip if already extracted and same size
if dest.exists() {
if let Ok(meta) = std::fs::metadata(&dest) {
if meta.len() == data.len() as u64 {
return Ok(dest);
}
}
}
std::fs::write(&dest, data)
.with_ctx(|| format!("Failed to write {}", dest.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))?;
}
Ok(dest)
}
/// Ensure kustomize is extracted and return its path.
pub fn ensure_kustomize() -> Result<PathBuf> {
extract_embedded(KUSTOMIZE_BIN, "kustomize")
}
/// Ensure helm is extracted and return its path.
pub fn ensure_helm() -> Result<PathBuf> {
extract_embedded(HELM_BIN, "helm")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kustomize_bin_is_non_empty() {
assert!(
KUSTOMIZE_BIN.len() > 0,
"Embedded kustomize binary should not be empty"
);
}
#[test]
fn helm_bin_is_non_empty() {
assert!(
HELM_BIN.len() > 0,
"Embedded helm binary should not be empty"
);
}
#[test]
fn kustomize_bin_has_reasonable_size() {
// kustomize binary should be at least 1 MB
assert!(
KUSTOMIZE_BIN.len() > 1_000_000,
"Embedded kustomize binary seems too small: {} bytes",
KUSTOMIZE_BIN.len()
);
}
#[test]
fn helm_bin_has_reasonable_size() {
// helm binary should be at least 1 MB
assert!(
HELM_BIN.len() > 1_000_000,
"Embedded helm binary seems too small: {} bytes",
HELM_BIN.len()
);
}
#[test]
fn cache_dir_ends_with_sunbeam_bin() {
let dir = cache_dir();
assert!(
dir.ends_with("sunbeam/bin"),
"cache_dir() should end with sunbeam/bin, got: {}",
dir.display()
);
}
#[test]
fn cache_dir_is_absolute() {
let dir = cache_dir();
assert!(
dir.is_absolute(),
"cache_dir() should return an absolute path, got: {}",
dir.display()
);
}
#[test]
fn ensure_kustomize_returns_valid_path() {
let path = ensure_kustomize().expect("ensure_kustomize should succeed");
assert!(
path.ends_with("kustomize"),
"ensure_kustomize path should end with 'kustomize', got: {}",
path.display()
);
assert!(path.exists(), "kustomize binary should exist at: {}", path.display());
}
#[test]
fn ensure_helm_returns_valid_path() {
let path = ensure_helm().expect("ensure_helm should succeed");
assert!(
path.ends_with("helm"),
"ensure_helm path should end with 'helm', got: {}",
path.display()
);
assert!(path.exists(), "helm binary should exist at: {}", path.display());
}
#[test]
fn ensure_kustomize_is_idempotent() {
let path1 = ensure_kustomize().expect("first call should succeed");
let path2 = ensure_kustomize().expect("second call should succeed");
assert_eq!(path1, path2, "ensure_kustomize should return the same path on repeated calls");
}
#[test]
fn ensure_helm_is_idempotent() {
let path1 = ensure_helm().expect("first call should succeed");
let path2 = ensure_helm().expect("second call should succeed");
assert_eq!(path1, path2, "ensure_helm should return the same path on repeated calls");
}
#[test]
fn extracted_kustomize_is_executable() {
let path = ensure_kustomize().expect("ensure_kustomize should succeed");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&path)
.expect("should read metadata")
.permissions();
assert!(
perms.mode() & 0o111 != 0,
"kustomize binary should be executable"
);
}
}
#[test]
fn extracted_helm_is_executable() {
let path = ensure_helm().expect("ensure_helm should succeed");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&path)
.expect("should read metadata")
.permissions();
assert!(
perms.mode() & 0o111 != 0,
"helm binary should be executable"
);
}
}
}

View File

@@ -0,0 +1 @@
// SSH tunnel management — reserved for future extraction.

19
sunbeam-sdk/src/lib.rs Normal file
View File

@@ -0,0 +1,19 @@
#[macro_use]
pub mod error;
pub mod auth;
pub mod checks;
pub mod cluster;
pub mod config;
pub mod constants;
pub mod gitea;
pub mod images;
pub mod kube;
pub mod manifests;
pub mod openbao;
pub mod output;
pub mod pm;
pub mod secrets;
pub mod services;
pub mod update;
pub mod users;

View File

@@ -0,0 +1,880 @@
use crate::error::Result;
use crate::constants::MANAGED_NS;
/// Return only the YAML documents that belong to the given namespace.
pub fn filter_by_namespace(manifests: &str, namespace: &str) -> String {
let mut kept = Vec::new();
for doc in manifests.split("\n---") {
let doc = doc.trim();
if doc.is_empty() {
continue;
}
let has_ns = doc.contains(&format!("namespace: {namespace}"));
let is_ns_resource =
doc.contains("kind: Namespace") && doc.contains(&format!("name: {namespace}"));
if has_ns || is_ns_resource {
kept.push(doc);
}
}
if kept.is_empty() {
return String::new();
}
format!("---\n{}\n", kept.join("\n---\n"))
}
/// Build kustomize overlay for env, substitute domain/email, apply via kube-rs.
///
/// Runs a second convergence pass if cert-manager is present in the overlay —
/// cert-manager registers a ValidatingWebhook that must be running before
/// ClusterIssuer / Certificate resources can be created.
pub async fn cmd_apply(env: &str, domain: &str, email: &str, namespace: &str) -> Result<()> {
// Fall back to config for ACME email if not provided via CLI flag.
let email = if email.is_empty() {
crate::config::load_config().acme_email
} else {
email.to_string()
};
let infra_dir = crate::config::get_infra_dir();
let (resolved_domain, overlay) = if env == "production" {
let d = if domain.is_empty() {
crate::kube::get_domain().await?
} else {
domain.to_string()
};
if d.is_empty() {
bail!("--domain is required for production apply on first deploy");
}
let overlay = infra_dir.join("overlays").join("production");
(d, overlay)
} else {
// Local: discover domain from Lima IP
let d = crate::kube::get_domain().await?;
let overlay = infra_dir.join("overlays").join("local");
(d, overlay)
};
let scope = if namespace.is_empty() {
String::new()
} else {
format!(" [{namespace}]")
};
crate::output::step(&format!(
"Applying manifests (env: {env}, domain: {resolved_domain}){scope}..."
));
if env == "local" {
apply_mkcert_ca_configmap().await;
}
let ns_list = if namespace.is_empty() {
None
} else {
Some(vec![namespace.to_string()])
};
pre_apply_cleanup(ns_list.as_deref()).await;
let before = snapshot_configmaps().await;
let mut manifests =
crate::kube::kustomize_build(&overlay, &resolved_domain, &email).await?;
if !namespace.is_empty() {
manifests = filter_by_namespace(&manifests, namespace);
if manifests.trim().is_empty() {
crate::output::warn(&format!(
"No resources found for namespace '{namespace}' -- check the name and try again."
));
return Ok(());
}
}
// First pass: may emit errors for resources that depend on webhooks not yet running
if let Err(e) = crate::kube::kube_apply(&manifests).await {
crate::output::warn(&format!("First apply pass had errors (may be expected): {e}"));
}
// If cert-manager is in the overlay, wait for its webhook then re-apply
let cert_manager_present = overlay
.join("../../base/cert-manager")
.exists();
if cert_manager_present && namespace.is_empty() {
if wait_for_webhook("cert-manager", "cert-manager-webhook", 120).await {
crate::output::ok("Running convergence pass for cert-manager resources...");
let manifests2 =
crate::kube::kustomize_build(&overlay, &resolved_domain, &email).await?;
crate::kube::kube_apply(&manifests2).await?;
}
}
restart_for_changed_configmaps(&before, &snapshot_configmaps().await).await;
// Post-apply hooks
if namespace.is_empty() || namespace == "matrix" {
patch_tuwunel_oauth2_redirect(&resolved_domain).await;
inject_opensearch_model_id().await;
}
if namespace.is_empty() || namespace == "data" {
ensure_opensearch_ml().await;
}
crate::output::ok("Applied.");
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Delete immutable resources that must be re-created on each apply.
async fn pre_apply_cleanup(namespaces: Option<&[String]>) {
let ns_list: Vec<&str> = match namespaces {
Some(ns) => ns.iter().map(|s| s.as_str()).collect(),
None => MANAGED_NS.to_vec(),
};
crate::output::ok("Cleaning up immutable Jobs and test Pods...");
// Prune stale VaultStaticSecrets that share a name with VaultDynamicSecrets
prune_stale_vault_static_secrets(&ns_list).await;
for ns in &ns_list {
// Delete all jobs
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(e) => {
crate::output::warn(&format!("Failed to get kube client: {e}"));
return;
}
};
let jobs: kube::api::Api<k8s_openapi::api::batch::v1::Job> =
kube::api::Api::namespaced(client.clone(), ns);
if let Ok(job_list) = jobs.list(&kube::api::ListParams::default()).await {
for job in job_list.items {
if let Some(name) = &job.metadata.name {
let dp = kube::api::DeleteParams::default();
let _ = jobs.delete(name, &dp).await;
}
}
}
// Delete test pods
let pods: kube::api::Api<k8s_openapi::api::core::v1::Pod> =
kube::api::Api::namespaced(client.clone(), ns);
if let Ok(pod_list) = pods.list(&kube::api::ListParams::default()).await {
for pod in pod_list.items {
if let Some(name) = &pod.metadata.name {
if name.ends_with("-test-connection")
|| name.ends_with("-server-test")
|| name.ends_with("-test")
{
let dp = kube::api::DeleteParams::default();
let _ = pods.delete(name, &dp).await;
}
}
}
}
}
}
/// Prune VaultStaticSecrets that share a name with VaultDynamicSecrets in the same namespace.
async fn prune_stale_vault_static_secrets(namespaces: &[&str]) {
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(e) => {
crate::output::warn(&format!("Failed to get kube client for VSS pruning: {e}"));
return;
}
};
let vss_ar = kube::api::ApiResource {
group: "secrets.hashicorp.com".into(),
version: "v1beta1".into(),
api_version: "secrets.hashicorp.com/v1beta1".into(),
kind: "VaultStaticSecret".into(),
plural: "vaultstaticsecrets".into(),
};
let vds_ar = kube::api::ApiResource {
group: "secrets.hashicorp.com".into(),
version: "v1beta1".into(),
api_version: "secrets.hashicorp.com/v1beta1".into(),
kind: "VaultDynamicSecret".into(),
plural: "vaultdynamicsecrets".into(),
};
for ns in namespaces {
let vss_api: kube::api::Api<kube::api::DynamicObject> =
kube::api::Api::namespaced_with(client.clone(), ns, &vss_ar);
let vds_api: kube::api::Api<kube::api::DynamicObject> =
kube::api::Api::namespaced_with(client.clone(), ns, &vds_ar);
let vss_list = match vss_api.list(&kube::api::ListParams::default()).await {
Ok(l) => l,
Err(_) => continue,
};
let vds_list = match vds_api.list(&kube::api::ListParams::default()).await {
Ok(l) => l,
Err(_) => continue,
};
let vds_names: std::collections::HashSet<String> = vds_list
.items
.iter()
.filter_map(|o| o.metadata.name.clone())
.collect();
for vss in &vss_list.items {
if let Some(name) = &vss.metadata.name {
if vds_names.contains(name) {
crate::output::ok(&format!(
"Pruning stale VaultStaticSecret {ns}/{name} (replaced by VaultDynamicSecret)"
));
let dp = kube::api::DeleteParams::default();
let _ = vss_api.delete(name, &dp).await;
}
}
}
}
}
/// Snapshot ConfigMap resourceVersions across managed namespaces.
async fn snapshot_configmaps() -> std::collections::HashMap<String, String> {
let mut result = std::collections::HashMap::new();
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(_) => return result,
};
for ns in MANAGED_NS {
let cms: kube::api::Api<k8s_openapi::api::core::v1::ConfigMap> =
kube::api::Api::namespaced(client.clone(), ns);
if let Ok(cm_list) = cms.list(&kube::api::ListParams::default()).await {
for cm in cm_list.items {
if let (Some(name), Some(rv)) = (
&cm.metadata.name,
&cm.metadata.resource_version,
) {
result.insert(format!("{ns}/{name}"), rv.clone());
}
}
}
}
result
}
/// Restart deployments that mount any ConfigMap whose resourceVersion changed.
async fn restart_for_changed_configmaps(
before: &std::collections::HashMap<String, String>,
after: &std::collections::HashMap<String, String>,
) {
let mut changed_by_ns: std::collections::HashMap<&str, std::collections::HashSet<&str>> =
std::collections::HashMap::new();
for (key, rv) in after {
if before.get(key) != Some(rv) {
if let Some((ns, name)) = key.split_once('/') {
changed_by_ns.entry(ns).or_default().insert(name);
}
}
}
if changed_by_ns.is_empty() {
return;
}
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(_) => return,
};
for (ns, cm_names) in &changed_by_ns {
let deps: kube::api::Api<k8s_openapi::api::apps::v1::Deployment> =
kube::api::Api::namespaced(client.clone(), ns);
if let Ok(dep_list) = deps.list(&kube::api::ListParams::default()).await {
for dep in dep_list.items {
let dep_name = dep.metadata.name.as_deref().unwrap_or("");
// Check if this deployment mounts any changed ConfigMap
let volumes = dep
.spec
.as_ref()
.and_then(|s| s.template.spec.as_ref())
.and_then(|s| s.volumes.as_ref());
if let Some(vols) = volumes {
let mounts_changed = vols.iter().any(|v| {
if let Some(cm) = &v.config_map {
cm_names.contains(cm.name.as_str())
} else {
false
}
});
if mounts_changed {
crate::output::ok(&format!(
"Restarting {ns}/{dep_name} (ConfigMap updated)..."
));
let _ = crate::kube::kube_rollout_restart(ns, dep_name).await;
}
}
}
}
}
}
/// Wait for a webhook endpoint to become ready.
async fn wait_for_webhook(ns: &str, svc: &str, timeout_secs: u64) -> bool {
crate::output::ok(&format!(
"Waiting for {ns}/{svc} webhook (up to {timeout_secs}s)..."
));
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(_) => return false,
};
let eps: kube::api::Api<k8s_openapi::api::core::v1::Endpoints> =
kube::api::Api::namespaced(client.clone(), ns);
loop {
if std::time::Instant::now() > deadline {
crate::output::warn(&format!(
" {ns}/{svc} not ready after {timeout_secs}s -- continuing anyway."
));
return false;
}
if let Ok(Some(ep)) = eps.get_opt(svc).await {
let has_addr = ep
.subsets
.as_ref()
.and_then(|ss| ss.first())
.and_then(|s| s.addresses.as_ref())
.is_some_and(|a| !a.is_empty());
if has_addr {
crate::output::ok(&format!(" {ns}/{svc} ready."));
return true;
}
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
/// Create/update gitea-mkcert-ca ConfigMap from the local mkcert root CA.
async fn apply_mkcert_ca_configmap() {
let caroot = tokio::process::Command::new("mkcert")
.arg("-CAROOT")
.output()
.await;
let caroot_path = match caroot {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
_ => {
crate::output::warn("mkcert not found -- skipping gitea-mkcert-ca ConfigMap.");
return;
}
};
let ca_pem_path = std::path::Path::new(&caroot_path).join("rootCA.pem");
let ca_pem = match std::fs::read_to_string(&ca_pem_path) {
Ok(s) => s,
Err(_) => {
crate::output::warn(&format!(
"mkcert root CA not found at {} -- skipping.",
ca_pem_path.display()
));
return;
}
};
let cm = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "gitea-mkcert-ca", "namespace": "devtools"},
"data": {"ca.crt": ca_pem},
});
let manifest = serde_json::to_string(&cm).unwrap_or_default();
if let Err(e) = crate::kube::kube_apply(&manifest).await {
crate::output::warn(&format!("Failed to apply gitea-mkcert-ca: {e}"));
} else {
crate::output::ok("gitea-mkcert-ca ConfigMap applied.");
}
}
/// Patch the tuwunel OAuth2Client redirect URI with the actual client_id.
async fn patch_tuwunel_oauth2_redirect(domain: &str) {
let client_id = match crate::kube::kube_get_secret_field("matrix", "oidc-tuwunel", "CLIENT_ID")
.await
{
Ok(id) if !id.is_empty() => id,
_ => {
crate::output::warn(
"oidc-tuwunel secret not yet available -- skipping redirect URI patch.",
);
return;
}
};
let redirect_uri = format!(
"https://messages.{domain}/_matrix/client/unstable/login/sso/callback/{client_id}"
);
// Patch the OAuth2Client CRD via kube-rs
let client = match crate::kube::get_client().await {
Ok(c) => c,
Err(_) => return,
};
let ar = kube::api::ApiResource {
group: "hydra.ory.sh".into(),
version: "v1alpha1".into(),
api_version: "hydra.ory.sh/v1alpha1".into(),
kind: "OAuth2Client".into(),
plural: "oauth2clients".into(),
};
let api: kube::api::Api<kube::api::DynamicObject> =
kube::api::Api::namespaced_with(client.clone(), "matrix", &ar);
let patch = serde_json::json!({
"spec": {
"redirectUris": [redirect_uri]
}
});
let pp = kube::api::PatchParams::default();
if let Err(e) = api
.patch("tuwunel", &pp, &kube::api::Patch::Merge(patch))
.await
{
crate::output::warn(&format!("Failed to patch tuwunel OAuth2Client: {e}"));
} else {
crate::output::ok("Patched tuwunel OAuth2Client redirect URI.");
}
}
// ---------------------------------------------------------------------------
// OpenSearch helpers (kube exec + curl inside pod)
// ---------------------------------------------------------------------------
/// Call OpenSearch API via kube exec curl inside the opensearch pod.
async fn os_api(path: &str, method: &str, body: Option<&str>) -> Option<String> {
let url = format!("http://localhost:9200{path}");
let mut curl_args: Vec<&str> = vec!["curl", "-sf", &url];
if method != "GET" {
curl_args.extend_from_slice(&["-X", method]);
}
let body_string;
if let Some(b) = body {
body_string = b.to_string();
curl_args.extend_from_slice(&["-H", "Content-Type: application/json", "-d", &body_string]);
}
// Build the full exec command: exec deploy/opensearch -n data -c opensearch -- curl ...
let exec_cmd = curl_args;
match crate::kube::kube_exec("data", "opensearch-0", &exec_cmd, Some("opensearch")).await {
Ok((0, out)) if !out.is_empty() => Some(out),
_ => None,
}
}
/// Inject OpenSearch model_id into matrix/opensearch-ml-config ConfigMap.
async fn inject_opensearch_model_id() {
let pipe_resp =
match os_api("/_ingest/pipeline/tuwunel_embedding_pipeline", "GET", None).await {
Some(r) => r,
None => {
crate::output::warn(
"OpenSearch ingest pipeline not found -- skipping model_id injection.",
);
return;
}
};
let model_id = serde_json::from_str::<serde_json::Value>(&pipe_resp)
.ok()
.and_then(|v| {
v.get("tuwunel_embedding_pipeline")?
.get("processors")?
.as_array()?
.iter()
.find_map(|p| {
p.get("text_embedding")?
.get("model_id")?
.as_str()
.map(String::from)
})
});
let Some(model_id) = model_id else {
crate::output::warn(
"No model_id in ingest pipeline -- tuwunel hybrid search unavailable.",
);
return;
};
// Check if ConfigMap already has this value
if let Ok(current) =
crate::kube::kube_get_secret_field("matrix", "opensearch-ml-config", "model_id").await
{
if current == model_id {
return;
}
}
let cm = serde_json::json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "opensearch-ml-config", "namespace": "matrix"},
"data": {"model_id": &model_id},
});
let manifest = serde_json::to_string(&cm).unwrap_or_default();
if let Err(e) = crate::kube::kube_apply(&manifest).await {
crate::output::warn(&format!("Failed to inject OpenSearch model_id: {e}"));
} else {
crate::output::ok(&format!(
"Injected OpenSearch model_id ({model_id}) into matrix/opensearch-ml-config."
));
}
}
/// Configure OpenSearch ML Commons for neural search.
///
/// 1. Sets cluster settings to allow ML on data nodes.
/// 2. Registers and deploys all-mpnet-base-v2 (pre-trained, 384-dim).
/// 3. Creates ingest + search pipelines for hybrid BM25+neural scoring.
async fn ensure_opensearch_ml() {
if os_api("/_cluster/health", "GET", None).await.is_none() {
crate::output::warn("OpenSearch not reachable -- skipping ML setup.");
return;
}
// 1. ML Commons cluster settings
let settings = serde_json::json!({
"persistent": {
"plugins.ml_commons.only_run_on_ml_node": false,
"plugins.ml_commons.native_memory_threshold": 90,
"plugins.ml_commons.model_access_control_enabled": false,
"plugins.ml_commons.allow_registering_model_via_url": true,
}
});
os_api(
"/_cluster/settings",
"PUT",
Some(&serde_json::to_string(&settings).unwrap()),
)
.await;
// 2. Check if model already registered and deployed
let search_body =
r#"{"query":{"match":{"name":"huggingface/sentence-transformers/all-mpnet-base-v2"}}}"#;
let search_resp = match os_api("/_plugins/_ml/models/_search", "POST", Some(search_body)).await
{
Some(r) => r,
None => {
crate::output::warn("OpenSearch ML search API failed -- skipping ML setup.");
return;
}
};
let resp: serde_json::Value = match serde_json::from_str(&search_resp) {
Ok(v) => v,
Err(_) => return,
};
let hits = resp
.get("hits")
.and_then(|h| h.get("hits"))
.and_then(|h| h.as_array())
.cloned()
.unwrap_or_default();
let mut model_id: Option<String> = None;
let mut already_deployed = false;
for hit in &hits {
let state = hit
.get("_source")
.and_then(|s| s.get("model_state"))
.and_then(|v| v.as_str())
.unwrap_or("");
let id = hit.get("_id").and_then(|v| v.as_str()).unwrap_or("");
match state {
"DEPLOYED" => {
model_id = Some(id.to_string());
already_deployed = true;
break;
}
"REGISTERED" | "DEPLOYING" => {
model_id = Some(id.to_string());
}
_ => {}
}
}
if !already_deployed {
if let Some(ref mid) = model_id {
// Registered but not deployed -- deploy it
crate::output::ok("Deploying OpenSearch ML model...");
os_api(
&format!("/_plugins/_ml/models/{mid}/_deploy"),
"POST",
None,
)
.await;
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(r) =
os_api(&format!("/_plugins/_ml/models/{mid}"), "GET", None).await
{
if r.contains("\"DEPLOYED\"") {
break;
}
}
}
} else {
// Register from pre-trained hub
crate::output::ok("Registering OpenSearch ML model (all-mpnet-base-v2)...");
let reg_body = serde_json::json!({
"name": "huggingface/sentence-transformers/all-mpnet-base-v2",
"version": "1.0.1",
"model_format": "TORCH_SCRIPT",
});
let reg_resp = match os_api(
"/_plugins/_ml/models/_register",
"POST",
Some(&serde_json::to_string(&reg_body).unwrap()),
)
.await
{
Some(r) => r,
None => {
crate::output::warn("Failed to register ML model -- skipping.");
return;
}
};
let task_id = serde_json::from_str::<serde_json::Value>(&reg_resp)
.ok()
.and_then(|v| v.get("task_id")?.as_str().map(String::from))
.unwrap_or_default();
if task_id.is_empty() {
crate::output::warn("No task_id from model registration -- skipping.");
return;
}
crate::output::ok("Waiting for model registration...");
let mut registered_id = None;
for _ in 0..60 {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
if let Some(task_resp) =
os_api(&format!("/_plugins/_ml/tasks/{task_id}"), "GET", None).await
{
if let Ok(task) = serde_json::from_str::<serde_json::Value>(&task_resp) {
match task.get("state").and_then(|v| v.as_str()).unwrap_or("") {
"COMPLETED" => {
registered_id = task
.get("model_id")
.and_then(|v| v.as_str())
.map(String::from);
break;
}
"FAILED" => {
crate::output::warn(&format!(
"ML model registration failed: {task_resp}"
));
return;
}
_ => {}
}
}
}
}
let Some(mid) = registered_id else {
crate::output::warn("ML model registration timed out.");
return;
};
crate::output::ok("Deploying ML model...");
os_api(
&format!("/_plugins/_ml/models/{mid}/_deploy"),
"POST",
None,
)
.await;
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(r) =
os_api(&format!("/_plugins/_ml/models/{mid}"), "GET", None).await
{
if r.contains("\"DEPLOYED\"") {
break;
}
}
}
model_id = Some(mid);
}
}
let Some(model_id) = model_id else {
crate::output::warn("No ML model available -- skipping pipeline setup.");
return;
};
// 3. Ingest pipeline
let ingest = serde_json::json!({
"description": "Tuwunel message embedding pipeline",
"processors": [{"text_embedding": {
"model_id": &model_id,
"field_map": {"body": "embedding"},
}}],
});
os_api(
"/_ingest/pipeline/tuwunel_embedding_pipeline",
"PUT",
Some(&serde_json::to_string(&ingest).unwrap()),
)
.await;
// 4. Search pipeline
let search = serde_json::json!({
"description": "Tuwunel hybrid BM25+neural search pipeline",
"phase_results_processors": [{"normalization-processor": {
"normalization": {"technique": "min_max"},
"combination": {
"technique": "arithmetic_mean",
"parameters": {"weights": [0.3, 0.7]},
},
}}],
});
os_api(
"/_search/pipeline/tuwunel_hybrid_pipeline",
"PUT",
Some(&serde_json::to_string(&search).unwrap()),
)
.await;
crate::output::ok(&format!("OpenSearch ML ready (model: {model_id})."));
}
#[cfg(test)]
mod tests {
use super::*;
const MULTI_DOC: &str = "\
---
apiVersion: v1
kind: ConfigMap
metadata:
name: meet-config
namespace: lasuite
data:
FOO: bar
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: meet-backend
namespace: lasuite
spec:
replicas: 1
---
apiVersion: v1
kind: Namespace
metadata:
name: lasuite
---
apiVersion: v1
kind: ConfigMap
metadata:
name: pingora-config
namespace: ingress
data:
config.toml: |
hello
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: pingora
namespace: ingress
spec:
replicas: 1
";
#[test]
fn test_keeps_matching_namespace() {
let result = filter_by_namespace(MULTI_DOC, "lasuite");
assert!(result.contains("name: meet-config"));
assert!(result.contains("name: meet-backend"));
}
#[test]
fn test_excludes_other_namespaces() {
let result = filter_by_namespace(MULTI_DOC, "lasuite");
assert!(!result.contains("namespace: ingress"));
assert!(!result.contains("name: pingora-config"));
assert!(!result.contains("name: pingora\n"));
}
#[test]
fn test_includes_namespace_resource_itself() {
let result = filter_by_namespace(MULTI_DOC, "lasuite");
assert!(result.contains("kind: Namespace"));
}
#[test]
fn test_ingress_filter() {
let result = filter_by_namespace(MULTI_DOC, "ingress");
assert!(result.contains("name: pingora-config"));
assert!(result.contains("name: pingora"));
assert!(!result.contains("namespace: lasuite"));
}
#[test]
fn test_unknown_namespace_returns_empty() {
let result = filter_by_namespace(MULTI_DOC, "nonexistent");
assert!(result.trim().is_empty());
}
#[test]
fn test_empty_input_returns_empty() {
let result = filter_by_namespace("", "lasuite");
assert!(result.trim().is_empty());
}
#[test]
fn test_result_starts_with_separator() {
let result = filter_by_namespace(MULTI_DOC, "lasuite");
assert!(result.starts_with("---"));
}
#[test]
fn test_does_not_include_namespace_resource_for_wrong_ns() {
let result = filter_by_namespace(MULTI_DOC, "ingress");
assert!(!result.contains("kind: Namespace"));
}
#[test]
fn test_single_doc_matching() {
let doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n";
let result = filter_by_namespace(doc, "ory");
assert!(result.contains("name: x"));
}
#[test]
fn test_single_doc_not_matching() {
let doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n";
let result = filter_by_namespace(doc, "lasuite");
assert!(result.trim().is_empty());
}
}

View File

@@ -0,0 +1,498 @@
//! Lightweight OpenBao/Vault HTTP API client.
//!
//! Replaces all `kubectl exec openbao-0 -- sh -c "bao ..."` calls from the
//! Python version with direct HTTP API calls via port-forward to openbao:8200.
use crate::error::{Result, ResultExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// OpenBao HTTP client wrapping a base URL and optional root token.
#[derive(Clone)]
pub struct BaoClient {
pub base_url: String,
pub token: Option<String>,
http: reqwest::Client,
}
// ── API response types ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct InitResponse {
pub unseal_keys_b64: Vec<String>,
pub root_token: String,
}
#[derive(Debug, Deserialize)]
pub struct SealStatusResponse {
#[serde(default)]
pub initialized: bool,
#[serde(default)]
pub sealed: bool,
#[serde(default)]
pub progress: u32,
#[serde(default)]
pub t: u32,
#[serde(default)]
pub n: u32,
}
#[derive(Debug, Deserialize)]
pub struct UnsealResponse {
#[serde(default)]
pub sealed: bool,
#[serde(default)]
pub progress: u32,
}
/// KV v2 read response wrapper.
#[derive(Debug, Deserialize)]
struct KvReadResponse {
data: Option<KvReadData>,
}
#[derive(Debug, Deserialize)]
struct KvReadData {
data: Option<HashMap<String, serde_json::Value>>,
}
// ── Client implementation ───────────────────────────────────────────────────
impl BaoClient {
/// Create a new client pointing at `base_url` (e.g. `http://localhost:8200`).
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
token: None,
http: reqwest::Client::new(),
}
}
/// Create a client with an authentication token.
pub fn with_token(base_url: &str, token: &str) -> Self {
let mut client = Self::new(base_url);
client.token = Some(token.to_string());
client
}
fn url(&self, path: &str) -> String {
format!("{}/v1/{}", self.base_url, path.trim_start_matches('/'))
}
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let mut req = self.http.request(method, self.url(path));
if let Some(ref token) = self.token {
req = req.header("X-Vault-Token", token);
}
req
}
// ── System operations ───────────────────────────────────────────────
/// Get the seal status of the OpenBao instance.
pub async fn seal_status(&self) -> Result<SealStatusResponse> {
let resp = self
.http
.get(format!("{}/v1/sys/seal-status", self.base_url))
.send()
.await
.ctx("Failed to connect to OpenBao")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("OpenBao seal-status returned {status}: {body}");
}
resp.json().await.ctx("Failed to parse seal status")
}
/// Initialize OpenBao with the given number of key shares and threshold.
pub async fn init(&self, key_shares: u32, key_threshold: u32) -> Result<InitResponse> {
#[derive(Serialize)]
struct InitRequest {
secret_shares: u32,
secret_threshold: u32,
}
let resp = self
.http
.put(format!("{}/v1/sys/init", self.base_url))
.json(&InitRequest {
secret_shares: key_shares,
secret_threshold: key_threshold,
})
.send()
.await
.ctx("Failed to initialize OpenBao")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("OpenBao init returned {status}: {body}");
}
resp.json().await.ctx("Failed to parse init response")
}
/// Unseal OpenBao with one key share.
pub async fn unseal(&self, key: &str) -> Result<UnsealResponse> {
#[derive(Serialize)]
struct UnsealRequest<'a> {
key: &'a str,
}
let resp = self
.http
.put(format!("{}/v1/sys/unseal", self.base_url))
.json(&UnsealRequest { key })
.send()
.await
.ctx("Failed to unseal OpenBao")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("OpenBao unseal returned {status}: {body}");
}
resp.json().await.ctx("Failed to parse unseal response")
}
// ── Secrets engine management ───────────────────────────────────────
/// Enable a secrets engine at the given path.
/// Returns Ok(()) even if already enabled (400 is tolerated).
pub async fn enable_secrets_engine(&self, path: &str, engine_type: &str) -> Result<()> {
#[derive(Serialize)]
struct EnableRequest<'a> {
r#type: &'a str,
}
let resp = self
.request(reqwest::Method::POST, &format!("sys/mounts/{path}"))
.json(&EnableRequest {
r#type: engine_type,
})
.send()
.await
.ctx("Failed to enable secrets engine")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 400 {
// 400 = "path is already in use" — idempotent
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
bail!("Enable secrets engine {path} returned {status}: {body}");
}
}
// ── KV v2 operations ────────────────────────────────────────────────
/// Read all fields from a KV v2 secret path.
/// Returns None if the path doesn't exist (404).
pub async fn kv_get(&self, mount: &str, path: &str) -> Result<Option<HashMap<String, String>>> {
let resp = self
.request(reqwest::Method::GET, &format!("{mount}/data/{path}"))
.send()
.await
.ctx("Failed to read KV secret")?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("KV get {mount}/{path} returned {status}: {body}");
}
let kv_resp: KvReadResponse = resp.json().await.ctx("Failed to parse KV response")?;
let data = kv_resp
.data
.and_then(|d| d.data)
.unwrap_or_default();
// Convert all values to strings
let result: HashMap<String, String> = data
.into_iter()
.map(|(k, v)| {
let s = match v {
serde_json::Value::String(s) => s,
other => other.to_string(),
};
(k, s)
})
.collect();
Ok(Some(result))
}
/// Read a single field from a KV v2 secret path.
/// Returns empty string if path or field doesn't exist.
pub async fn kv_get_field(&self, mount: &str, path: &str, field: &str) -> Result<String> {
match self.kv_get(mount, path).await? {
Some(data) => Ok(data.get(field).cloned().unwrap_or_default()),
None => Ok(String::new()),
}
}
/// Write (create or overwrite) all fields in a KV v2 secret path.
pub async fn kv_put(
&self,
mount: &str,
path: &str,
data: &HashMap<String, String>,
) -> Result<()> {
#[derive(Serialize)]
struct KvWriteRequest<'a> {
data: &'a HashMap<String, String>,
}
let resp = self
.request(reqwest::Method::POST, &format!("{mount}/data/{path}"))
.json(&KvWriteRequest { data })
.send()
.await
.ctx("Failed to write KV secret")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("KV put {mount}/{path} returned {status}: {body}");
}
Ok(())
}
/// Patch (merge) fields into an existing KV v2 secret path.
pub async fn kv_patch(
&self,
mount: &str,
path: &str,
data: &HashMap<String, String>,
) -> Result<()> {
#[derive(Serialize)]
struct KvWriteRequest<'a> {
data: &'a HashMap<String, String>,
}
let resp = self
.request(reqwest::Method::PATCH, &format!("{mount}/data/{path}"))
.header("Content-Type", "application/merge-patch+json")
.json(&KvWriteRequest { data })
.send()
.await
.ctx("Failed to patch KV secret")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("KV patch {mount}/{path} returned {status}: {body}");
}
Ok(())
}
/// Delete a KV v2 secret path (soft delete — deletes latest version).
pub async fn kv_delete(&self, mount: &str, path: &str) -> Result<()> {
let resp = self
.request(reqwest::Method::DELETE, &format!("{mount}/data/{path}"))
.send()
.await
.ctx("Failed to delete KV secret")?;
// 404 is fine (already deleted)
if !resp.status().is_success() && resp.status().as_u16() != 404 {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("KV delete {mount}/{path} returned {status}: {body}");
}
Ok(())
}
// ── Auth operations ─────────────────────────────────────────────────
/// Enable an auth method at the given path.
/// Tolerates "already enabled" (400/409).
pub async fn auth_enable(&self, path: &str, method_type: &str) -> Result<()> {
#[derive(Serialize)]
struct AuthEnableRequest<'a> {
r#type: &'a str,
}
let resp = self
.request(reqwest::Method::POST, &format!("sys/auth/{path}"))
.json(&AuthEnableRequest {
r#type: method_type,
})
.send()
.await
.ctx("Failed to enable auth method")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 400 {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
bail!("Enable auth {path} returned {status}: {body}");
}
}
/// Write a policy.
pub async fn write_policy(&self, name: &str, policy_hcl: &str) -> Result<()> {
#[derive(Serialize)]
struct PolicyRequest<'a> {
policy: &'a str,
}
let resp = self
.request(
reqwest::Method::PUT,
&format!("sys/policies/acl/{name}"),
)
.json(&PolicyRequest { policy: policy_hcl })
.send()
.await
.ctx("Failed to write policy")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("Write policy {name} returned {status}: {body}");
}
Ok(())
}
/// Write to an arbitrary API path (for auth config, roles, database config, etc.).
pub async fn write(
&self,
path: &str,
data: &serde_json::Value,
) -> Result<serde_json::Value> {
let resp = self
.request(reqwest::Method::POST, path)
.json(data)
.send()
.await
.with_ctx(|| format!("Failed to write to {path}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("Write {path} returned {status}: {body}");
}
let body = resp.text().await.unwrap_or_default();
if body.is_empty() {
Ok(serde_json::Value::Null)
} else {
serde_json::from_str(&body).ctx("Failed to parse write response")
}
}
/// Read from an arbitrary API path.
pub async fn read(&self, path: &str) -> Result<Option<serde_json::Value>> {
let resp = self
.request(reqwest::Method::GET, path)
.send()
.await
.with_ctx(|| format!("Failed to read {path}"))?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("Read {path} returned {status}: {body}");
}
let body = resp.text().await.unwrap_or_default();
if body.is_empty() {
Ok(Some(serde_json::Value::Null))
} else {
Ok(Some(serde_json::from_str(&body)?))
}
}
// ── Database secrets engine ─────────────────────────────────────────
/// Configure the database secrets engine connection.
pub async fn write_db_config(
&self,
name: &str,
plugin: &str,
connection_url: &str,
username: &str,
password: &str,
allowed_roles: &str,
) -> Result<()> {
let data = serde_json::json!({
"plugin_name": plugin,
"connection_url": connection_url,
"username": username,
"password": password,
"allowed_roles": allowed_roles,
});
self.write(&format!("database/config/{name}"), &data).await?;
Ok(())
}
/// Create a database static role.
pub async fn write_db_static_role(
&self,
name: &str,
db_name: &str,
username: &str,
rotation_period: u64,
rotation_statements: &[&str],
) -> Result<()> {
let data = serde_json::json!({
"db_name": db_name,
"username": username,
"rotation_period": rotation_period,
"rotation_statements": rotation_statements,
});
self.write(&format!("database/static-roles/{name}"), &data)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_url_construction() {
let client = BaoClient::new("http://localhost:8200");
assert_eq!(client.url("sys/seal-status"), "http://localhost:8200/v1/sys/seal-status");
assert_eq!(client.url("/sys/seal-status"), "http://localhost:8200/v1/sys/seal-status");
}
#[test]
fn test_client_url_strips_trailing_slash() {
let client = BaoClient::new("http://localhost:8200/");
assert_eq!(client.base_url, "http://localhost:8200");
}
#[test]
fn test_with_token() {
let client = BaoClient::with_token("http://localhost:8200", "mytoken");
assert_eq!(client.token, Some("mytoken".to_string()));
}
#[test]
fn test_new_has_no_token() {
let client = BaoClient::new("http://localhost:8200");
assert!(client.token.is_none());
}
#[tokio::test]
async fn test_seal_status_error_on_nonexistent_server() {
// Connecting to a port where nothing is listening should produce an
// error (connection refused), not a panic or hang.
let client = BaoClient::new("http://127.0.0.1:19999");
let result = client.seal_status().await;
assert!(
result.is_err(),
"seal_status should return an error when the server is unreachable"
);
}
}

92
sunbeam-sdk/src/output.rs Normal file
View File

@@ -0,0 +1,92 @@
/// Print a step header.
pub fn step(msg: &str) {
println!("\n==> {msg}");
}
/// Print a success/info line.
pub fn ok(msg: &str) {
println!(" {msg}");
}
/// Print a warning to stderr.
pub fn warn(msg: &str) {
eprintln!(" WARN: {msg}");
}
/// Return an aligned text table. Columns padded to max width.
pub fn table(rows: &[Vec<String>], headers: &[&str]) -> String {
if headers.is_empty() {
return String::new();
}
let mut col_widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < col_widths.len() {
col_widths[i] = col_widths[i].max(cell.len());
}
}
}
let header_line: String = headers
.iter()
.enumerate()
.map(|(i, h)| format!("{:<width$}", h, width = col_widths[i]))
.collect::<Vec<_>>()
.join(" ");
let separator: String = col_widths
.iter()
.map(|&w| "-".repeat(w))
.collect::<Vec<_>>()
.join(" ");
let mut lines = vec![header_line, separator];
for row in rows {
let cells: Vec<String> = (0..headers.len())
.map(|i| {
let val = row.get(i).map(|s| s.as_str()).unwrap_or("");
format!("{:<width$}", val, width = col_widths[i])
})
.collect();
lines.push(cells.join(" "));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_table_basic() {
let rows = vec![
vec!["abc".to_string(), "def".to_string()],
vec!["x".to_string(), "longer".to_string()],
];
let result = table(&rows, &["Col1", "Col2"]);
assert!(result.contains("Col1"));
assert!(result.contains("Col2"));
assert!(result.contains("abc"));
assert!(result.contains("longer"));
}
#[test]
fn test_table_empty_headers() {
let result = table(&[], &[]);
assert!(result.is_empty());
}
#[test]
fn test_table_column_widths() {
let rows = vec![vec!["short".to_string(), "x".to_string()]];
let result = table(&rows, &["LongHeader", "H2"]);
// Header should set minimum width
for line in result.lines().skip(2) {
// Data row: "short" should be padded to "LongHeader" width
assert!(line.starts_with("short "));
}
}
}

View File

@@ -0,0 +1,420 @@
//! Gitea issues client.
use serde::{Deserialize, Serialize};
use crate::error::{Result, SunbeamError};
use super::{Ticket, Source, Status};
// ---------------------------------------------------------------------------
// IssueUpdate
// ---------------------------------------------------------------------------
/// Update payload for a Gitea issue.
#[derive(Debug, Default, Serialize)]
pub struct IssueUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
// ---------------------------------------------------------------------------
// GiteaClient
// ---------------------------------------------------------------------------
pub(super) struct GiteaClient {
base_url: String,
token: String,
http: reqwest::Client,
}
/// Serde helpers for Gitea JSON responses.
pub(super) mod gitea_json {
use super::*;
#[derive(Debug, Deserialize)]
pub struct Issue {
pub number: u64,
#[serde(default)]
pub title: String,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub state: String,
#[serde(default)]
pub assignees: Option<Vec<GiteaUser>>,
#[serde(default)]
pub labels: Option<Vec<GiteaLabel>>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub html_url: Option<String>,
#[serde(default)]
pub repository: Option<Repository>,
}
#[derive(Debug, Deserialize)]
pub struct GiteaUser {
#[serde(default)]
pub login: String,
}
#[derive(Debug, Deserialize)]
pub struct GiteaLabel {
#[serde(default)]
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct Repository {
#[serde(default)]
pub full_name: Option<String>,
}
impl Issue {
pub fn to_ticket(self, base_url: &str, org: &str, repo: &str) -> Ticket {
let status = state_to_status(&self.state);
let assignees = self
.assignees
.unwrap_or_default()
.into_iter()
.map(|u| u.login)
.collect();
let labels = self
.labels
.unwrap_or_default()
.into_iter()
.map(|l| l.name)
.collect();
let web_base = base_url.trim_end_matches("/api/v1");
let url = self
.html_url
.unwrap_or_else(|| format!("{web_base}/{org}/{repo}/issues/{}", self.number));
Ticket {
id: format!("g:{org}/{repo}#{}", self.number),
source: Source::Gitea,
title: self.title,
description: self.body.unwrap_or_default(),
status,
assignees,
labels,
created_at: self.created_at.unwrap_or_default(),
updated_at: self.updated_at.unwrap_or_default(),
url,
}
}
}
/// Map Gitea issue state to normalised status.
pub fn state_to_status(state: &str) -> Status {
match state {
"open" => Status::Open,
"closed" => Status::Closed,
_ => Status::Open,
}
}
}
impl GiteaClient {
/// Create a new Gitea client using the Hydra OAuth2 token.
pub(super) async fn new(domain: &str) -> Result<Self> {
let base_url = format!("https://src.{domain}/api/v1");
// Gitea needs its own PAT, not the Hydra access token
let token = crate::auth::get_gitea_token()?;
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| SunbeamError::network(format!("Failed to build HTTP client: {e}")))?;
Ok(Self {
base_url,
token,
http,
})
}
/// Build a request with Gitea PAT auth (`Authorization: token <pat>`).
#[allow(dead_code)]
fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
self.http
.get(url)
.header("Authorization", format!("token {}", self.token))
}
#[allow(dead_code)]
fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
self.http
.post(url)
.header("Authorization", format!("token {}", self.token))
}
#[allow(dead_code)]
fn authed_patch(&self, url: &str) -> reqwest::RequestBuilder {
self.http
.patch(url)
.header("Authorization", format!("token {}", self.token))
}
/// List issues for an org/repo (or search across an org).
pub(super) fn list_issues<'a>(
&'a self,
org: &'a str,
repo: Option<&'a str>,
state: &'a str,
) -> futures::future::BoxFuture<'a, Result<Vec<Ticket>>> {
Box::pin(self.list_issues_inner(org, repo, state))
}
async fn list_issues_inner(
&self,
org: &str,
repo: Option<&str>,
state: &str,
) -> Result<Vec<Ticket>> {
match repo {
Some(r) => {
let url = format!("{}/repos/{org}/{r}/issues", self.base_url);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.token))
.query(&[("state", state), ("type", "issues"), ("limit", "50")])
.send()
.await
.map_err(|e| SunbeamError::network(format!("Gitea list_issues: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Gitea GET issues for {org}/{r} returned {}",
resp.status()
)));
}
let issues: Vec<gitea_json::Issue> = resp
.json()
.await
.map_err(|e| SunbeamError::network(format!("Gitea issues parse error: {e}")))?;
Ok(issues
.into_iter()
.map(|i| i.to_ticket(&self.base_url, org, r))
.collect())
}
None => {
// Search across the entire org by listing org repos, then issues.
let repos_url = format!("{}/orgs/{org}/repos", self.base_url);
let repos_resp = self
.http
.get(&repos_url)
.header("Authorization", format!("token {}", self.token))
.query(&[("limit", "50")])
.send()
.await
.map_err(|e| SunbeamError::network(format!("Gitea list org repos: {e}")))?;
if !repos_resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Gitea GET repos for org {org} returned {}",
repos_resp.status()
)));
}
#[derive(Deserialize)]
struct Repo {
name: String,
}
let repos: Vec<Repo> = repos_resp
.json()
.await
.map_err(|e| SunbeamError::network(format!("Gitea repos parse: {e}")))?;
let mut all = Vec::new();
for r in &repos {
match self.list_issues(org, Some(&r.name), state).await {
Ok(mut tickets) => all.append(&mut tickets),
Err(_) => continue, // skip repos we cannot read
}
}
Ok(all)
}
}
}
/// GET /api/v1/repos/{org}/{repo}/issues/{index}
pub(super) async fn get_issue(&self, org: &str, repo: &str, index: u64) -> Result<Ticket> {
let url = format!("{}/repos/{org}/{repo}/issues/{index}", self.base_url);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.token))
.send()
.await
.map_err(|e| SunbeamError::network(format!("Gitea get_issue: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Gitea GET issue {org}/{repo}#{index} returned {}",
resp.status()
)));
}
let issue: gitea_json::Issue = resp
.json()
.await
.map_err(|e| SunbeamError::network(format!("Gitea issue parse: {e}")))?;
Ok(issue.to_ticket(&self.base_url, org, repo))
}
/// POST /api/v1/repos/{org}/{repo}/issues
pub(super) async fn create_issue(
&self,
org: &str,
repo: &str,
title: &str,
body: &str,
) -> Result<Ticket> {
let url = format!("{}/repos/{org}/{repo}/issues", self.base_url);
let payload = serde_json::json!({
"title": title,
"body": body,
});
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.token))
.json(&payload)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Gitea create_issue: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Gitea POST issue to {org}/{repo} returned {}",
resp.status()
)));
}
let issue: gitea_json::Issue = resp
.json()
.await
.map_err(|e| SunbeamError::network(format!("Gitea issue create parse: {e}")))?;
Ok(issue.to_ticket(&self.base_url, org, repo))
}
/// PATCH /api/v1/repos/{org}/{repo}/issues/{index}
pub(super) async fn update_issue(
&self,
org: &str,
repo: &str,
index: u64,
updates: &IssueUpdate,
) -> Result<()> {
let url = format!("{}/repos/{org}/{repo}/issues/{index}", self.base_url);
let resp = self
.http
.patch(&url)
.header("Authorization", format!("token {}", self.token))
.json(updates)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Gitea update_issue: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Gitea PATCH issue {org}/{repo}#{index} returned {}",
resp.status()
)));
}
Ok(())
}
/// Close an issue.
pub(super) async fn close_issue(&self, org: &str, repo: &str, index: u64) -> Result<()> {
self.update_issue(
org,
repo,
index,
&IssueUpdate {
state: Some("closed".to_string()),
..Default::default()
},
)
.await
}
/// POST /api/v1/repos/{org}/{repo}/issues/{index}/comments
pub(super) async fn comment_issue(
&self,
org: &str,
repo: &str,
index: u64,
body: &str,
) -> Result<()> {
let url = format!(
"{}/repos/{org}/{repo}/issues/{index}/comments",
self.base_url
);
let payload = serde_json::json!({ "body": body });
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.token))
.json(&payload)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Gitea comment_issue: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Gitea POST comment on {org}/{repo}#{index} returned {}",
resp.status()
)));
}
Ok(())
}
/// POST /api/v1/repos/{org}/{repo}/issues/{index}/assignees
#[allow(dead_code)]
pub(super) async fn assign_issue(
&self,
org: &str,
repo: &str,
index: u64,
assignee: &str,
) -> Result<()> {
// Use PATCH on the issue itself -- the /assignees endpoint requires
// the user to be an explicit collaborator, while PATCH works for
// any org member with write access.
let url = format!(
"{}/repos/{org}/{repo}/issues/{index}",
self.base_url
);
let payload = serde_json::json!({ "assignees": [assignee] });
let resp = self
.http
.patch(&url)
.header("Authorization", format!("token {}", self.token))
.json(&payload)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Gitea assign_issue: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Gitea assign on {org}/{repo}#{index} returned {}",
resp.status()
)));
}
Ok(())
}
}

729
sunbeam-sdk/src/pm/mod.rs Normal file
View File

@@ -0,0 +1,729 @@
//! Unified project management across Planka (kanban boards) and Gitea (issues).
//!
//! Ticket IDs use a prefix format:
//! - `p:42` or `planka:42` -- Planka card
//! - `g:studio/cli#7` or `gitea:studio/cli#7` -- Gitea issue
mod planka;
mod gitea_issues;
use planka::PlankaClient;
use gitea_issues::GiteaClient;
use crate::error::{Result, ResultExt, SunbeamError};
use crate::output;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------
/// Unified ticket representation across both systems.
#[derive(Debug, Clone)]
pub struct Ticket {
pub id: String,
pub source: Source,
pub title: String,
pub description: String,
pub status: Status,
pub assignees: Vec<String>,
pub labels: Vec<String>,
pub created_at: String,
pub updated_at: String,
pub url: String,
}
/// Which backend a ticket originates from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Planka,
Gitea,
}
/// Normalised ticket status across both systems.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Open,
InProgress,
Done,
Closed,
}
impl std::fmt::Display for Source {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Source::Planka => write!(f, "planka"),
Source::Gitea => write!(f, "gitea"),
}
}
}
impl std::fmt::Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Status::Open => write!(f, "open"),
Status::InProgress => write!(f, "in-progress"),
Status::Done => write!(f, "done"),
Status::Closed => write!(f, "closed"),
}
}
}
// ---------------------------------------------------------------------------
// Ticket ID parsing
// ---------------------------------------------------------------------------
/// A parsed ticket reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TicketRef {
/// Planka card by ID (snowflake string).
Planka(String),
/// Gitea issue: (org, repo, issue number).
Gitea {
org: String,
repo: String,
number: u64,
},
}
/// Parse a prefixed ticket ID string.
///
/// Accepted formats:
/// - `p:42`, `planka:42`
/// - `g:studio/cli#7`, `gitea:studio/cli#7`
pub fn parse_ticket_id(id: &str) -> Result<TicketRef> {
let (prefix, rest) = id
.split_once(':')
.ctx("Invalid ticket ID: expected 'p:ID' or 'g:org/repo#num'")?;
match prefix {
"p" | "planka" => {
if rest.is_empty() {
return Err(SunbeamError::config("Empty Planka card ID"));
}
Ok(TicketRef::Planka(rest.to_string()))
}
"g" | "gitea" => {
// Expected: org/repo#number
let (org_repo, num_str) = rest
.rsplit_once('#')
.ctx("Invalid Gitea ticket ID: expected org/repo#number")?;
let (org, repo) = org_repo
.split_once('/')
.ctx("Invalid Gitea ticket ID: expected org/repo#number")?;
let number: u64 = num_str
.parse()
.map_err(|_| SunbeamError::config(format!("Invalid issue number: {num_str}")))?;
Ok(TicketRef::Gitea {
org: org.to_string(),
repo: repo.to_string(),
number,
})
}
_ => Err(SunbeamError::config(format!(
"Unknown ticket prefix '{prefix}': use 'p'/'planka' or 'g'/'gitea'"
))),
}
}
// ---------------------------------------------------------------------------
// Auth helper
// ---------------------------------------------------------------------------
/// Retrieve the user's Hydra OAuth2 access token via the auth module.
async fn get_token() -> Result<String> {
crate::auth::get_token().await
}
// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------
/// Format a list of tickets as a table.
fn display_ticket_list(tickets: &[Ticket]) {
if tickets.is_empty() {
output::ok("No tickets found.");
return;
}
let rows: Vec<Vec<String>> = tickets
.iter()
.map(|t| {
vec![
t.id.clone(),
t.status.to_string(),
t.title.clone(),
t.assignees.join(", "),
t.source.to_string(),
]
})
.collect();
let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]);
println!("{tbl}");
}
/// Print a single ticket in detail.
fn display_ticket_detail(t: &Ticket) {
println!("{} ({})", t.title, t.id);
println!(" Status: {}", t.status);
println!(" Source: {}", t.source);
if !t.assignees.is_empty() {
println!(" Assignees: {}", t.assignees.join(", "));
}
if !t.labels.is_empty() {
println!(" Labels: {}", t.labels.join(", "));
}
if !t.created_at.is_empty() {
println!(" Created: {}", t.created_at);
}
if !t.updated_at.is_empty() {
println!(" Updated: {}", t.updated_at);
}
println!(" URL: {}", t.url);
if !t.description.is_empty() {
println!();
println!("{}", t.description);
}
}
// ---------------------------------------------------------------------------
// Unified commands
// ---------------------------------------------------------------------------
/// List tickets, optionally filtering by source and state.
///
/// When `source` is `None`, both Planka and Gitea are queried in parallel.
#[allow(dead_code)]
pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> {
let domain = crate::config::domain();
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
let fetch_planka = source.is_none() || matches!(source, Some("planka" | "p"));
let fetch_gitea = source.is_none() || matches!(source, Some("gitea" | "g"));
let planka_fut = async {
if fetch_planka {
let client = PlankaClient::new(&domain).await?;
client.list_all_cards().await
} else {
Ok(vec![])
}
};
let gitea_fut = async {
if fetch_gitea {
let client = GiteaClient::new(&domain).await?;
client.list_issues("studio", None, state).await
} else {
Ok(vec![])
}
};
let (planka_result, gitea_result) = tokio::join!(planka_fut, gitea_fut);
let mut tickets = Vec::new();
match planka_result {
Ok(mut t) => tickets.append(&mut t),
Err(e) => output::warn(&format!("Planka: {e}")),
}
match gitea_result {
Ok(mut t) => tickets.append(&mut t),
Err(e) => output::warn(&format!("Gitea: {e}")),
}
// Filter by state if looking at Planka results too.
if state == "closed" {
tickets.retain(|t| matches!(t.status, Status::Closed | Status::Done));
} else if state == "open" {
tickets.retain(|t| matches!(t.status, Status::Open | Status::InProgress));
}
display_ticket_list(&tickets);
Ok(())
}
/// Show details for a single ticket by ID.
#[allow(dead_code)]
pub async fn cmd_pm_show(id: &str) -> Result<()> {
let domain = crate::config::domain();
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
let ticket_ref = parse_ticket_id(id)?;
let ticket = match ticket_ref {
TicketRef::Planka(card_id) => {
let client = PlankaClient::new(&domain).await?;
client.get_card(&card_id).await?
}
TicketRef::Gitea { org, repo, number } => {
let client = GiteaClient::new(&domain).await?;
client.get_issue(&org, &repo, number).await?
}
};
display_ticket_detail(&ticket);
Ok(())
}
/// Create a new ticket.
///
/// `source` must be `"planka"` or `"gitea"`.
/// `target` is source-specific: for Planka it is `"board_id/list_id"`,
/// for Gitea it is `"org/repo"`.
#[allow(dead_code)]
pub async fn cmd_pm_create(title: &str, body: &str, source: &str, target: &str) -> Result<()> {
let domain = crate::config::domain();
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
let ticket = match source {
"planka" | "p" => {
let client = PlankaClient::new(&domain).await?;
// Fetch all boards
let projects_url = format!("{}/projects", client.base_url);
let resp = client.http.get(&projects_url).bearer_auth(&client.token).send().await?;
let projects_body: serde_json::Value = resp.json().await?;
let boards = projects_body.get("included").and_then(|i| i.get("boards"))
.and_then(|b| b.as_array())
.ok_or_else(|| SunbeamError::config("No Planka boards found"))?;
// Find the board: by name (--target "Board Name") or by ID, or use first
let board = if target.is_empty() {
boards.first()
} else {
boards.iter().find(|b| {
let name = b.get("name").and_then(|n| n.as_str()).unwrap_or("");
let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("");
name.eq_ignore_ascii_case(target) || id == target
}).or_else(|| boards.first())
}.ok_or_else(|| SunbeamError::config("No Planka boards found"))?;
let board_id = board.get("id").and_then(|v| v.as_str())
.ok_or_else(|| SunbeamError::config("Board has no ID"))?;
let board_name = board.get("name").and_then(|n| n.as_str()).unwrap_or("?");
// Fetch the board to get its lists, use the first list
let board_url = format!("{}/boards/{board_id}", client.base_url);
let board_resp = client.http.get(&board_url).bearer_auth(&client.token).send().await?;
let board_body: serde_json::Value = board_resp.json().await?;
let list_id = board_body.get("included").and_then(|i| i.get("lists"))
.and_then(|l| l.as_array()).and_then(|a| a.first())
.and_then(|l| l.get("id")).and_then(|v| v.as_str())
.ok_or_else(|| SunbeamError::config(format!("No lists in board '{board_name}'")))?;
client.create_card(board_id, list_id, title, body).await?
}
"gitea" | "g" => {
if target.is_empty() {
return Err(SunbeamError::config(
"Gitea target required: --target org/repo (e.g. studio/marathon)",
));
}
let parts: Vec<&str> = target.splitn(2, '/').collect();
if parts.len() != 2 {
return Err(SunbeamError::config("Gitea target must be 'org/repo'"));
}
let client = GiteaClient::new(&domain).await?;
client.create_issue(parts[0], parts[1], title, body).await?
}
_ => {
return Err(SunbeamError::config(format!(
"Unknown source '{source}': use 'planka' or 'gitea'"
)));
}
};
output::ok(&format!("Created: {} ({})", ticket.title, ticket.id));
println!(" {}", ticket.url);
Ok(())
}
/// Add a comment to a ticket.
#[allow(dead_code)]
pub async fn cmd_pm_comment(id: &str, text: &str) -> Result<()> {
let domain = crate::config::domain();
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
let ticket_ref = parse_ticket_id(id)?;
match ticket_ref {
TicketRef::Planka(card_id) => {
let client = PlankaClient::new(&domain).await?;
client.comment_card(&card_id, text).await?;
}
TicketRef::Gitea { org, repo, number } => {
let client = GiteaClient::new(&domain).await?;
client.comment_issue(&org, &repo, number, text).await?;
}
}
output::ok(&format!("Comment added to {id}."));
Ok(())
}
/// Close a ticket.
#[allow(dead_code)]
pub async fn cmd_pm_close(id: &str) -> Result<()> {
let domain = crate::config::domain();
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
let ticket_ref = parse_ticket_id(id)?;
match ticket_ref {
TicketRef::Planka(card_id) => {
let client = PlankaClient::new(&domain).await?;
// Get the card to find its board, then find a "Done"/"Closed" list
let ticket = client.get_card(&card_id).await?;
// Try to find the board and its lists
let url = format!("{}/cards/{card_id}", client.base_url);
let resp = client.http.get(&url).bearer_auth(&client.token).send().await
.map_err(|e| SunbeamError::network(format!("Planka get card: {e}")))?;
let body: serde_json::Value = resp.json().await?;
let board_id = body.get("item").and_then(|i| i.get("boardId"))
.and_then(|v| v.as_str()).unwrap_or("");
if !board_id.is_empty() {
// Fetch the board to get its lists
let board_url = format!("{}/boards/{board_id}", client.base_url);
let board_resp = client.http.get(&board_url).bearer_auth(&client.token).send().await
.map_err(|e| SunbeamError::network(format!("Planka get board: {e}")))?;
let board_body: serde_json::Value = board_resp.json().await?;
let lists = board_body.get("included")
.and_then(|i| i.get("lists"))
.and_then(|l| l.as_array());
if let Some(lists) = lists {
// Find a list named "Done", "Closed", "Complete", or similar
let done_list = lists.iter().find(|l| {
let name = l.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase();
name.contains("done") || name.contains("closed") || name.contains("complete")
});
if let Some(done_list) = done_list {
let list_id = done_list.get("id").and_then(|v| v.as_str()).unwrap_or("");
if !list_id.is_empty() {
client.update_card(&card_id, &planka::CardUpdate {
list_id: Some(serde_json::json!(list_id)),
..Default::default()
}).await?;
output::ok(&format!("Moved p:{card_id} to Done."));
return Ok(());
}
}
}
}
output::warn(&format!("Could not find a Done list for p:{card_id}. Move it manually."));
}
TicketRef::Gitea { org, repo, number } => {
let client = GiteaClient::new(&domain).await?;
client.close_issue(&org, &repo, number).await?;
output::ok(&format!("Closed gitea:{org}/{repo}#{number}."));
}
}
Ok(())
}
/// Assign a user to a ticket.
#[allow(dead_code)]
pub async fn cmd_pm_assign(id: &str, user: &str) -> Result<()> {
let domain = crate::config::domain();
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
let ticket_ref = parse_ticket_id(id)?;
match ticket_ref {
TicketRef::Planka(card_id) => {
let client = PlankaClient::new(&domain).await?;
client.assign_card(&card_id, user).await?;
}
TicketRef::Gitea { org, repo, number } => {
let client = GiteaClient::new(&domain).await?;
client.assign_issue(&org, &repo, number, user).await?;
}
}
output::ok(&format!("Assigned {user} to {id}."));
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- Ticket ID parsing --------------------------------------------------
#[test]
fn test_parse_planka_short() {
let r = parse_ticket_id("p:42").unwrap();
assert_eq!(r, TicketRef::Planka("42".to_string()));
}
#[test]
fn test_parse_planka_long() {
let r = parse_ticket_id("planka:100").unwrap();
assert_eq!(r, TicketRef::Planka("100".to_string()));
}
#[test]
fn test_parse_gitea_short() {
let r = parse_ticket_id("g:studio/cli#7").unwrap();
assert_eq!(
r,
TicketRef::Gitea {
org: "studio".to_string(),
repo: "cli".to_string(),
number: 7,
}
);
}
#[test]
fn test_parse_gitea_long() {
let r = parse_ticket_id("gitea:internal/infra#123").unwrap();
assert_eq!(
r,
TicketRef::Gitea {
org: "internal".to_string(),
repo: "infra".to_string(),
number: 123,
}
);
}
#[test]
fn test_parse_missing_colon() {
assert!(parse_ticket_id("noprefix").is_err());
}
#[test]
fn test_parse_unknown_prefix() {
assert!(parse_ticket_id("jira:FOO-1").is_err());
}
#[test]
fn test_parse_invalid_planka_id() {
// Empty ID should fail
assert!(parse_ticket_id("p:").is_err());
}
#[test]
fn test_parse_gitea_missing_hash() {
assert!(parse_ticket_id("g:studio/cli").is_err());
}
#[test]
fn test_parse_gitea_missing_slash() {
assert!(parse_ticket_id("g:repo#1").is_err());
}
#[test]
fn test_parse_gitea_invalid_number() {
assert!(parse_ticket_id("g:studio/cli#abc").is_err());
}
// -- Status mapping -----------------------------------------------------
#[test]
fn test_gitea_state_open() {
assert_eq!(gitea_issues::gitea_json::state_to_status("open"), Status::Open);
}
#[test]
fn test_gitea_state_closed() {
assert_eq!(gitea_issues::gitea_json::state_to_status("closed"), Status::Closed);
}
#[test]
fn test_gitea_state_unknown_defaults_open() {
assert_eq!(gitea_issues::gitea_json::state_to_status("weird"), Status::Open);
}
#[test]
fn test_status_display() {
assert_eq!(Status::Open.to_string(), "open");
assert_eq!(Status::InProgress.to_string(), "in-progress");
assert_eq!(Status::Done.to_string(), "done");
assert_eq!(Status::Closed.to_string(), "closed");
}
#[test]
fn test_source_display() {
assert_eq!(Source::Planka.to_string(), "planka");
assert_eq!(Source::Gitea.to_string(), "gitea");
}
// -- Display formatting -------------------------------------------------
#[test]
fn test_display_ticket_list_table() {
let tickets = vec![
Ticket {
id: "p:1".to_string(),
source: Source::Planka,
title: "Fix login".to_string(),
description: String::new(),
status: Status::Open,
assignees: vec!["alice".to_string()],
labels: vec![],
created_at: "2025-01-01".to_string(),
updated_at: "2025-01-02".to_string(),
url: "https://projects.example.com/cards/1".to_string(),
},
Ticket {
id: "g:studio/cli#7".to_string(),
source: Source::Gitea,
title: "Add tests".to_string(),
description: "We need more tests.".to_string(),
status: Status::InProgress,
assignees: vec!["bob".to_string(), "carol".to_string()],
labels: vec!["enhancement".to_string()],
created_at: "2025-02-01".to_string(),
updated_at: "2025-02-05".to_string(),
url: "https://src.example.com/studio/cli/issues/7".to_string(),
},
];
let rows: Vec<Vec<String>> = tickets
.iter()
.map(|t| {
vec![
t.id.clone(),
t.status.to_string(),
t.title.clone(),
t.assignees.join(", "),
t.source.to_string(),
]
})
.collect();
let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]);
assert!(tbl.contains("p:1"));
assert!(tbl.contains("g:studio/cli#7"));
assert!(tbl.contains("open"));
assert!(tbl.contains("in-progress"));
assert!(tbl.contains("Fix login"));
assert!(tbl.contains("Add tests"));
assert!(tbl.contains("alice"));
assert!(tbl.contains("bob, carol"));
assert!(tbl.contains("planka"));
assert!(tbl.contains("gitea"));
}
#[test]
fn test_display_ticket_list_empty() {
let rows: Vec<Vec<String>> = vec![];
let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]);
// Should have header + separator but no data rows.
assert!(tbl.contains("ID"));
assert_eq!(tbl.lines().count(), 2);
}
#[test]
fn test_card_update_serialization() {
let update = planka::CardUpdate {
name: Some("New name".to_string()),
description: None,
list_id: Some(serde_json::json!(5)),
};
let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["name"], "New name");
assert_eq!(json["listId"], 5);
assert!(json.get("description").is_none());
}
#[test]
fn test_issue_update_serialization() {
let update = gitea_issues::IssueUpdate {
title: None,
body: Some("Updated body".to_string()),
state: Some("closed".to_string()),
};
let json = serde_json::to_value(&update).unwrap();
assert!(json.get("title").is_none());
assert_eq!(json["body"], "Updated body");
assert_eq!(json["state"], "closed");
}
#[test]
fn test_planka_list_name_to_status() {
// Test via Card::to_ticket with synthetic included data.
use planka::planka_json::*;
let inc = BoardIncluded {
cards: vec![],
card_memberships: vec![],
card_labels: vec![],
labels: vec![],
lists: vec![
List { id: serde_json::json!(1), name: "To Do".to_string() },
List { id: serde_json::json!(2), name: "In Progress".to_string() },
List { id: serde_json::json!(3), name: "Done".to_string() },
List { id: serde_json::json!(4), name: "Archived / Closed".to_string() },
],
users: vec![],
};
let make_card = |list_id: u64| Card {
id: serde_json::json!(1),
name: "test".to_string(),
description: None,
list_id: Some(serde_json::json!(list_id)),
created_at: None,
updated_at: None,
};
assert_eq!(
make_card(1).to_ticket("https://x/api", Some(&inc)).status,
Status::Open
);
assert_eq!(
make_card(2).to_ticket("https://x/api", Some(&inc)).status,
Status::InProgress
);
assert_eq!(
make_card(3).to_ticket("https://x/api", Some(&inc)).status,
Status::Done
);
assert_eq!(
make_card(4).to_ticket("https://x/api", Some(&inc)).status,
Status::Closed
);
}
#[test]
fn test_gitea_issue_to_ticket() {
let issue = gitea_issues::gitea_json::Issue {
number: 42,
title: "Bug report".to_string(),
body: Some("Something broke".to_string()),
state: "open".to_string(),
assignees: Some(vec![gitea_issues::gitea_json::GiteaUser {
login: "dev1".to_string(),
}]),
labels: Some(vec![gitea_issues::gitea_json::GiteaLabel {
name: "bug".to_string(),
}]),
created_at: Some("2025-03-01T00:00:00Z".to_string()),
updated_at: Some("2025-03-02T00:00:00Z".to_string()),
html_url: Some("https://src.example.com/studio/app/issues/42".to_string()),
repository: None,
};
let ticket = issue.to_ticket("https://src.example.com/api/v1", "studio", "app");
assert_eq!(ticket.id, "g:studio/app#42");
assert_eq!(ticket.source, Source::Gitea);
assert_eq!(ticket.title, "Bug report");
assert_eq!(ticket.description, "Something broke");
assert_eq!(ticket.status, Status::Open);
assert_eq!(ticket.assignees, vec!["dev1"]);
assert_eq!(ticket.labels, vec!["bug"]);
assert_eq!(
ticket.url,
"https://src.example.com/studio/app/issues/42"
);
}
}

View File

@@ -0,0 +1,546 @@
//! Planka (kanban board) client.
use serde::Serialize;
use crate::error::{Result, SunbeamError};
use super::{get_token, Ticket, Source, Status};
// ---------------------------------------------------------------------------
// CardUpdate
// ---------------------------------------------------------------------------
/// Update payload for a Planka card.
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CardUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub list_id: Option<serde_json::Value>,
}
// ---------------------------------------------------------------------------
// PlankaClient
// ---------------------------------------------------------------------------
pub(super) struct PlankaClient {
pub(super) base_url: String,
pub(super) token: String,
pub(super) http: reqwest::Client,
}
/// Serde helpers for Planka JSON responses.
pub(super) mod planka_json {
use super::*;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExchangeResponse {
#[serde(default)]
pub token: Option<String>,
// Planka may also return the token in `item`
#[serde(default)]
pub item: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Card {
pub id: serde_json::Value,
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub list_id: Option<serde_json::Value>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BoardResponse {
#[serde(default)]
pub included: Option<BoardIncluded>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BoardIncluded {
#[serde(default)]
pub cards: Vec<Card>,
#[serde(default)]
pub card_memberships: Vec<CardMembership>,
#[serde(default)]
pub card_labels: Vec<CardLabel>,
#[serde(default)]
pub labels: Vec<Label>,
#[serde(default)]
pub lists: Vec<List>,
#[serde(default)]
pub users: Vec<User>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardMembership {
pub card_id: serde_json::Value,
pub user_id: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardLabel {
pub card_id: serde_json::Value,
pub label_id: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Label {
pub id: serde_json::Value,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct List {
pub id: serde_json::Value,
#[serde(default)]
pub name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: serde_json::Value,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub username: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardDetailResponse {
pub item: Card,
#[serde(default)]
pub included: Option<BoardIncluded>,
}
impl Card {
pub fn to_ticket(self, base_url: &str, included: Option<&BoardIncluded>) -> Ticket {
let status = match included {
Some(inc) => list_name_to_status(
self.list_id
.and_then(|lid| inc.lists.iter().find(|l| l.id == lid))
.map(|l| l.name.as_str())
.unwrap_or(""),
),
None => Status::Open,
};
let assignees = match included {
Some(inc) => inc
.card_memberships
.iter()
.filter(|m| m.card_id == self.id)
.filter_map(|m| {
inc.users.iter().find(|u| u.id == m.user_id).map(|u| {
u.username
.clone()
.or_else(|| u.name.clone())
.unwrap_or_else(|| m.user_id.to_string())
})
})
.collect(),
None => vec![],
};
let labels = match included {
Some(inc) => inc
.card_labels
.iter()
.filter(|cl| cl.card_id == self.id)
.filter_map(|cl| {
inc.labels.iter().find(|l| l.id == cl.label_id).map(|l| {
l.name
.clone()
.unwrap_or_else(|| cl.label_id.to_string())
})
})
.collect(),
None => vec![],
};
// Derive web URL from API base URL (strip `/api`).
let web_base = base_url.trim_end_matches("/api");
Ticket {
id: format!("p:{}", self.id.as_str().unwrap_or(&self.id.to_string())),
source: Source::Planka,
title: self.name,
description: self.description.unwrap_or_default(),
status,
assignees,
labels,
created_at: self.created_at.unwrap_or_default(),
updated_at: self.updated_at.unwrap_or_default(),
url: format!("{web_base}/cards/{}", self.id.as_str().unwrap_or(&self.id.to_string())),
}
}
}
/// Map a Planka list name to a normalised status.
fn list_name_to_status(name: &str) -> Status {
let lower = name.to_lowercase();
if lower.contains("done") || lower.contains("complete") {
Status::Done
} else if lower.contains("progress") || lower.contains("doing") || lower.contains("active")
{
Status::InProgress
} else if lower.contains("closed") || lower.contains("archive") {
Status::Closed
} else {
Status::Open
}
}
}
impl PlankaClient {
/// Create a new Planka client, exchanging the Hydra token for a Planka JWT
/// if the direct Bearer token is rejected.
pub(super) async fn new(domain: &str) -> Result<Self> {
let base_url = format!("https://projects.{domain}/api");
let hydra_token = get_token().await?;
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| SunbeamError::network(format!("Failed to build HTTP client: {e}")))?;
// Exchange the Hydra access token for a Planka JWT via our custom endpoint.
let exchange_url = format!("{base_url}/access-tokens/exchange-using-token");
let exchange_resp = http
.post(&exchange_url)
.json(&serde_json::json!({ "token": hydra_token }))
.send()
.await
.map_err(|e| SunbeamError::network(format!("Planka token exchange failed: {e}")))?;
if !exchange_resp.status().is_success() {
let status = exchange_resp.status();
let body = exchange_resp.text().await.unwrap_or_default();
return Err(SunbeamError::identity(format!(
"Planka token exchange returned {status}: {body}"
)));
}
let body: serde_json::Value = exchange_resp.json().await?;
let token = body
.get("item")
.and_then(|v| v.as_str())
.ok_or_else(|| SunbeamError::identity("Planka exchange response missing 'item' field"))?
.to_string();
Ok(Self {
base_url,
token,
http,
})
}
/// Discover all projects and boards, then fetch cards from each.
pub(super) async fn list_all_cards(&self) -> Result<Vec<Ticket>> {
// GET /api/projects returns all projects the user has access to,
// with included boards.
let url = format!("{}/projects", self.base_url);
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Planka list projects: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Planka GET projects returned {}",
resp.status()
)));
}
let body: serde_json::Value = resp.json().await?;
// Extract board IDs -- Planka uses string IDs (snowflake-style)
let board_ids: Vec<String> = body
.get("included")
.and_then(|inc| inc.get("boards"))
.and_then(|b| b.as_array())
.map(|boards| {
boards
.iter()
.filter_map(|b| {
b.get("id").and_then(|id| {
id.as_str()
.map(|s| s.to_string())
.or_else(|| id.as_u64().map(|n| n.to_string()))
})
})
.collect()
})
.unwrap_or_default();
if board_ids.is_empty() {
return Ok(vec![]);
}
// Fetch cards from each board
let mut all_tickets = Vec::new();
for board_id in &board_ids {
match self.list_cards(board_id).await {
Ok(tickets) => all_tickets.extend(tickets),
Err(e) => {
crate::output::warn(&format!("Planka board {board_id}: {e}"));
}
}
}
Ok(all_tickets)
}
/// GET /api/boards/{id} and extract all cards.
async fn list_cards(&self, board_id: &str) -> Result<Vec<Ticket>> {
let url = format!("{}/boards/{board_id}", self.base_url);
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Planka list_cards: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Planka GET board {board_id} returned {}",
resp.status()
)));
}
let body: planka_json::BoardResponse = resp
.json()
.await
.map_err(|e| SunbeamError::network(format!("Planka board parse error: {e}")))?;
let included = body.included;
let tickets = included
.as_ref()
.map(|inc| {
inc.cards
.clone()
.into_iter()
.map(|c: planka_json::Card| c.to_ticket(&self.base_url, Some(inc)))
.collect()
})
.unwrap_or_default();
Ok(tickets)
}
/// GET /api/cards/{id}
pub(super) async fn get_card(&self, id: &str) -> Result<Ticket> {
let url = format!("{}/cards/{id}", self.base_url);
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Planka get_card: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Planka GET card {id} returned {}",
resp.status()
)));
}
let body: planka_json::CardDetailResponse = resp
.json()
.await
.map_err(|e| SunbeamError::network(format!("Planka card parse error: {e}")))?;
Ok(body
.item
.to_ticket(&self.base_url, body.included.as_ref()))
}
/// POST /api/lists/{list_id}/cards
pub(super) async fn create_card(
&self,
_board_id: &str,
list_id: &str,
name: &str,
description: &str,
) -> Result<Ticket> {
let url = format!("{}/lists/{list_id}/cards", self.base_url);
let body = serde_json::json!({
"name": name,
"description": description,
"position": 65535,
});
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Planka create_card: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Planka POST card returned {}",
resp.status()
)));
}
let card: planka_json::CardDetailResponse = resp
.json()
.await
.map_err(|e| SunbeamError::network(format!("Planka card create parse error: {e}")))?;
Ok(card.item.to_ticket(&self.base_url, card.included.as_ref()))
}
/// PATCH /api/cards/{id}
pub(super) async fn update_card(&self, id: &str, updates: &CardUpdate) -> Result<()> {
let url = format!("{}/cards/{id}", self.base_url);
let resp = self
.http
.patch(&url)
.bearer_auth(&self.token)
.json(updates)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Planka update_card: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Planka PATCH card {id} returned {}",
resp.status()
)));
}
Ok(())
}
/// Move a card to a different list.
#[allow(dead_code)]
pub(super) async fn move_card(&self, id: &str, list_id: &str) -> Result<()> {
self.update_card(
id,
&CardUpdate {
list_id: Some(serde_json::json!(list_id)),
..Default::default()
},
)
.await
}
/// POST /api/cards/{id}/comment-actions
pub(super) async fn comment_card(&self, id: &str, text: &str) -> Result<()> {
let url = format!("{}/cards/{id}/comment-actions", self.base_url);
let body = serde_json::json!({ "text": text });
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Planka comment_card: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Planka POST comment on card {id} returned {}",
resp.status()
)));
}
Ok(())
}
/// Search for a Planka user by name/username, return their ID.
async fn resolve_user_id(&self, query: &str) -> Result<String> {
// "me" or "self" assigns to the current user
if query == "me" || query == "self" {
// Get current user via the token (decode JWT or call /api/users/me equivalent)
// Planka doesn't have /api/users/me, but we can get user from any board membership
let projects_url = format!("{}/projects", self.base_url);
if let Ok(resp) = self.http.get(&projects_url).bearer_auth(&self.token).send().await {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(memberships) = body.get("included")
.and_then(|i| i.get("boardMemberships"))
.and_then(|b| b.as_array())
{
if let Some(user_id) = memberships.first()
.and_then(|m| m.get("userId"))
.and_then(|v| v.as_str())
{
return Ok(user_id.to_string());
}
}
}
}
}
// Search other users (note: Planka excludes current user from search results)
let url = format!("{}/users/search", self.base_url);
let resp = self.http.get(&url)
.bearer_auth(&self.token)
.query(&[("query", query)])
.send().await
.map_err(|e| SunbeamError::network(format!("Planka user search: {e}")))?;
let body: serde_json::Value = resp.json().await?;
let users = body.get("items").and_then(|i| i.as_array());
if let Some(users) = users {
if let Some(user) = users.first() {
if let Some(id) = user.get("id").and_then(|v| v.as_str()) {
return Ok(id.to_string());
}
}
}
Err(SunbeamError::identity(format!(
"Planka user not found: {query} (use 'me' to assign to yourself)"
)))
}
/// POST /api/cards/{id}/memberships
pub(super) async fn assign_card(&self, id: &str, user: &str) -> Result<()> {
// Resolve username to user ID
let user_id = self.resolve_user_id(user).await?;
let url = format!("{}/cards/{id}/memberships", self.base_url);
let body = serde_json::json!({ "userId": user_id });
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.json(&body)
.send()
.await
.map_err(|e| SunbeamError::network(format!("Planka assign_card: {e}")))?;
if !resp.status().is_success() {
return Err(SunbeamError::network(format!(
"Planka POST membership on card {id} returned {}",
resp.status()
)));
}
Ok(())
}
}

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,
}))
}

View File

@@ -0,0 +1,573 @@
//! Service management — status, logs, restart.
use crate::error::{Result, SunbeamError};
use k8s_openapi::api::core::v1::Pod;
use kube::api::{Api, DynamicObject, ListParams, LogParams};
use kube::ResourceExt;
use std::collections::BTreeMap;
use crate::constants::MANAGED_NS;
use crate::kube::{get_client, kube_rollout_restart, parse_target};
use crate::output::{ok, step, warn};
/// Services that can be rollout-restarted, as (namespace, deployment) pairs.
pub const SERVICES_TO_RESTART: &[(&str, &str)] = &[
("ory", "hydra"),
("ory", "kratos"),
("ory", "login-ui"),
("devtools", "gitea"),
("storage", "seaweedfs-filer"),
("lasuite", "hive"),
("lasuite", "people-backend"),
("lasuite", "people-frontend"),
("lasuite", "people-celery-worker"),
("lasuite", "people-celery-beat"),
("lasuite", "projects"),
("matrix", "tuwunel"),
("media", "livekit-server"),
];
// ---------------------------------------------------------------------------
// Status helpers
// ---------------------------------------------------------------------------
/// Parsed pod row for display.
struct PodRow {
ns: String,
name: String,
ready: String,
status: String,
}
fn icon_for_status(status: &str) -> &'static str {
match status {
"Running" | "Completed" | "Succeeded" => "\u{2713}",
"Pending" => "\u{25cb}",
"Failed" => "\u{2717}",
_ => "?",
}
}
fn is_unhealthy(pod: &Pod) -> bool {
let status = pod.status.as_ref();
let phase = status
.and_then(|s| s.phase.as_deref())
.unwrap_or("Unknown");
match phase {
"Running" => {
// Check all containers are ready.
let container_statuses = status
.and_then(|s| s.container_statuses.as_ref());
if let Some(cs) = container_statuses {
let total = cs.len();
let ready = cs.iter().filter(|c| c.ready).count();
ready != total
} else {
true
}
}
"Succeeded" | "Completed" => false,
_ => true,
}
}
fn pod_phase(pod: &Pod) -> String {
pod.status
.as_ref()
.and_then(|s| s.phase.clone())
.unwrap_or_else(|| "Unknown".to_string())
}
fn pod_ready_str(pod: &Pod) -> String {
let cs = pod
.status
.as_ref()
.and_then(|s| s.container_statuses.as_ref());
match cs {
Some(cs) => {
let total = cs.len();
let ready = cs.iter().filter(|c| c.ready).count();
format!("{ready}/{total}")
}
None => "0/0".to_string(),
}
}
// ---------------------------------------------------------------------------
// VSO sync status
// ---------------------------------------------------------------------------
async fn vso_sync_status() -> Result<()> {
step("VSO secret sync status...");
let client = get_client().await?;
let mut all_ok = true;
// --- VaultStaticSecrets ---
{
let ar = kube::api::ApiResource {
group: "secrets.hashicorp.com".into(),
version: "v1beta1".into(),
api_version: "secrets.hashicorp.com/v1beta1".into(),
kind: "VaultStaticSecret".into(),
plural: "vaultstaticsecrets".into(),
};
let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
let list = api.list(&ListParams::default()).await;
if let Ok(list) = list {
// Group by namespace and sort
let mut grouped: BTreeMap<String, Vec<(String, bool)>> = BTreeMap::new();
for obj in &list.items {
let ns = obj.namespace().unwrap_or_default();
let name = obj.name_any();
let mac = obj
.data
.get("status")
.and_then(|s| s.get("secretMAC"))
.and_then(|v| v.as_str())
.unwrap_or("");
let synced = !mac.is_empty() && mac != "<none>";
if !synced {
all_ok = false;
}
grouped.entry(ns).or_default().push((name, synced));
}
for (ns, mut items) in grouped {
println!(" {ns} (VSS):");
items.sort();
for (name, synced) in items {
let icon = if synced { "\u{2713}" } else { "\u{2717}" };
println!(" {icon} {name}");
}
}
}
}
// --- VaultDynamicSecrets ---
{
let ar = kube::api::ApiResource {
group: "secrets.hashicorp.com".into(),
version: "v1beta1".into(),
api_version: "secrets.hashicorp.com/v1beta1".into(),
kind: "VaultDynamicSecret".into(),
plural: "vaultdynamicsecrets".into(),
};
let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
let list = api.list(&ListParams::default()).await;
if let Ok(list) = list {
let mut grouped: BTreeMap<String, Vec<(String, bool)>> = BTreeMap::new();
for obj in &list.items {
let ns = obj.namespace().unwrap_or_default();
let name = obj.name_any();
let renewed = obj
.data
.get("status")
.and_then(|s| s.get("lastRenewalTime"))
.and_then(|v| v.as_str())
.unwrap_or("0");
let synced = !renewed.is_empty() && renewed != "0" && renewed != "<none>";
if !synced {
all_ok = false;
}
grouped.entry(ns).or_default().push((name, synced));
}
for (ns, mut items) in grouped {
println!(" {ns} (VDS):");
items.sort();
for (name, synced) in items {
let icon = if synced { "\u{2713}" } else { "\u{2717}" };
println!(" {icon} {name}");
}
}
}
}
println!();
if all_ok {
ok("All VSO secrets synced.");
} else {
warn("Some VSO secrets are not synced.");
}
Ok(())
}
// ---------------------------------------------------------------------------
// Public commands
// ---------------------------------------------------------------------------
/// Show pod health, optionally filtered by namespace or namespace/service.
pub async fn cmd_status(target: Option<&str>) -> Result<()> {
step("Pod health across all namespaces...");
let client = get_client().await?;
let (ns_filter, svc_filter) = parse_target(target)?;
let mut pods: Vec<PodRow> = Vec::new();
match (ns_filter, svc_filter) {
(None, _) => {
// All managed namespaces
let ns_set: std::collections::HashSet<&str> =
MANAGED_NS.iter().copied().collect();
for ns in MANAGED_NS {
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
let lp = ListParams::default();
if let Ok(list) = api.list(&lp).await {
for pod in list.items {
let pod_ns = pod.namespace().unwrap_or_default();
if !ns_set.contains(pod_ns.as_str()) {
continue;
}
pods.push(PodRow {
ns: pod_ns,
name: pod.name_any(),
ready: pod_ready_str(&pod),
status: pod_phase(&pod),
});
}
}
}
}
(Some(ns), None) => {
// All pods in a namespace
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
let lp = ListParams::default();
if let Ok(list) = api.list(&lp).await {
for pod in list.items {
pods.push(PodRow {
ns: ns.to_string(),
name: pod.name_any(),
ready: pod_ready_str(&pod),
status: pod_phase(&pod),
});
}
}
}
(Some(ns), Some(svc)) => {
// Specific service: filter by app label
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
let lp = ListParams::default().labels(&format!("app={svc}"));
if let Ok(list) = api.list(&lp).await {
for pod in list.items {
pods.push(PodRow {
ns: ns.to_string(),
name: pod.name_any(),
ready: pod_ready_str(&pod),
status: pod_phase(&pod),
});
}
}
}
}
if pods.is_empty() {
warn("No pods found in managed namespaces.");
return Ok(());
}
pods.sort_by(|a, b| (&a.ns, &a.name).cmp(&(&b.ns, &b.name)));
let mut all_ok = true;
let mut cur_ns: Option<&str> = None;
for row in &pods {
if cur_ns != Some(&row.ns) {
println!(" {}:", row.ns);
cur_ns = Some(&row.ns);
}
let icon = icon_for_status(&row.status);
let mut unhealthy = !matches!(
row.status.as_str(),
"Running" | "Completed" | "Succeeded"
);
// For Running pods, check ready ratio
if !unhealthy && row.status == "Running" && row.ready.contains('/') {
let parts: Vec<&str> = row.ready.split('/').collect();
if parts.len() == 2 && parts[0] != parts[1] {
unhealthy = true;
}
}
if unhealthy {
all_ok = false;
}
println!(" {icon} {:<50} {:<6} {}", row.name, row.ready, row.status);
}
println!();
if all_ok {
ok("All pods healthy.");
} else {
warn("Some pods are not ready.");
}
vso_sync_status().await?;
Ok(())
}
/// Stream logs for a service. Target must include service name (e.g. ory/kratos).
pub async fn cmd_logs(target: &str, follow: bool) -> Result<()> {
let (ns_opt, name_opt) = parse_target(Some(target))?;
let ns = ns_opt.unwrap_or("");
let name = match name_opt {
Some(n) => n,
None => bail!("Logs require a service name, e.g. 'ory/kratos'."),
};
let client = get_client().await?;
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
// Find pods matching the app label
let lp = ListParams::default().labels(&format!("app={name}"));
let pod_list = api.list(&lp).await?;
if pod_list.items.is_empty() {
bail!("No pods found for {ns}/{name}");
}
if follow {
// Stream logs from the first matching pod
let pod_name = pod_list.items[0].name_any();
let mut lp = LogParams::default();
lp.follow = true;
lp.tail_lines = Some(100);
// log_stream returns a futures::AsyncBufRead — use the futures crate to read it
use futures::AsyncBufReadExt;
let stream = api.log_stream(&pod_name, &lp).await?;
let reader = futures::io::BufReader::new(stream);
let mut lines = reader.lines();
use futures::StreamExt;
while let Some(line) = lines.next().await {
match line {
Ok(line) => println!("{line}"),
Err(e) => {
warn(&format!("Log stream error: {e}"));
break;
}
}
}
} else {
// Print logs from all matching pods
for pod in &pod_list.items {
let pod_name = pod.name_any();
let mut lp = LogParams::default();
lp.tail_lines = Some(100);
match api.logs(&pod_name, &lp).await {
Ok(logs) => print!("{logs}"),
Err(e) => warn(&format!("Failed to get logs for {pod_name}: {e}")),
}
}
}
Ok(())
}
/// Print raw pod output in YAML or JSON format.
pub async fn cmd_get(target: &str, output: &str) -> Result<()> {
let (ns_opt, name_opt) = parse_target(Some(target))?;
let ns = match ns_opt {
Some(n) if !n.is_empty() => n,
_ => bail!("get requires namespace/name, e.g. 'sunbeam get ory/kratos-abc'"),
};
let name = match name_opt {
Some(n) => n,
None => bail!("get requires namespace/name, e.g. 'sunbeam get ory/kratos-abc'"),
};
let client = get_client().await?;
let api: Api<Pod> = Api::namespaced(client.clone(), ns);
let pod = api
.get_opt(name)
.await?
.ok_or_else(|| SunbeamError::kube(format!("Pod {ns}/{name} not found.")))?;
let text = match output {
"json" => serde_json::to_string_pretty(&pod)?,
_ => serde_yaml::to_string(&pod)?,
};
println!("{text}");
Ok(())
}
/// Restart deployments. None=all, 'ory'=namespace, 'ory/kratos'=specific.
pub async fn cmd_restart(target: Option<&str>) -> Result<()> {
step("Restarting services...");
let (ns_filter, svc_filter) = parse_target(target)?;
let matched: Vec<(&str, &str)> = match (ns_filter, svc_filter) {
(None, _) => SERVICES_TO_RESTART.to_vec(),
(Some(ns), None) => SERVICES_TO_RESTART
.iter()
.filter(|(n, _)| *n == ns)
.copied()
.collect(),
(Some(ns), Some(name)) => SERVICES_TO_RESTART
.iter()
.filter(|(n, d)| *n == ns && *d == name)
.copied()
.collect(),
};
if matched.is_empty() {
warn(&format!(
"No matching services for target: {}",
target.unwrap_or("(none)")
));
return Ok(());
}
for (ns, dep) in &matched {
if let Err(e) = kube_rollout_restart(ns, dep).await {
warn(&format!("Failed to restart {ns}/{dep}: {e}"));
}
}
ok("Done.");
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_managed_ns_contains_expected() {
assert!(MANAGED_NS.contains(&"ory"));
assert!(MANAGED_NS.contains(&"data"));
assert!(MANAGED_NS.contains(&"devtools"));
assert!(MANAGED_NS.contains(&"ingress"));
assert!(MANAGED_NS.contains(&"lasuite"));
assert!(MANAGED_NS.contains(&"matrix"));
assert!(MANAGED_NS.contains(&"media"));
assert!(MANAGED_NS.contains(&"storage"));
assert!(MANAGED_NS.contains(&"monitoring"));
assert!(MANAGED_NS.contains(&"vault-secrets-operator"));
assert_eq!(MANAGED_NS.len(), 10);
}
#[test]
fn test_services_to_restart_contains_expected() {
assert!(SERVICES_TO_RESTART.contains(&("ory", "hydra")));
assert!(SERVICES_TO_RESTART.contains(&("ory", "kratos")));
assert!(SERVICES_TO_RESTART.contains(&("ory", "login-ui")));
assert!(SERVICES_TO_RESTART.contains(&("devtools", "gitea")));
assert!(SERVICES_TO_RESTART.contains(&("storage", "seaweedfs-filer")));
assert!(SERVICES_TO_RESTART.contains(&("lasuite", "hive")));
assert!(SERVICES_TO_RESTART.contains(&("matrix", "tuwunel")));
assert!(SERVICES_TO_RESTART.contains(&("media", "livekit-server")));
assert_eq!(SERVICES_TO_RESTART.len(), 13);
}
#[test]
fn test_icon_for_status() {
assert_eq!(icon_for_status("Running"), "\u{2713}");
assert_eq!(icon_for_status("Completed"), "\u{2713}");
assert_eq!(icon_for_status("Succeeded"), "\u{2713}");
assert_eq!(icon_for_status("Pending"), "\u{25cb}");
assert_eq!(icon_for_status("Failed"), "\u{2717}");
assert_eq!(icon_for_status("Unknown"), "?");
assert_eq!(icon_for_status("CrashLoopBackOff"), "?");
}
#[test]
fn test_restart_filter_namespace() {
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART
.iter()
.filter(|(n, _)| *n == "ory")
.copied()
.collect();
assert_eq!(matched.len(), 3);
assert!(matched.contains(&("ory", "hydra")));
assert!(matched.contains(&("ory", "kratos")));
assert!(matched.contains(&("ory", "login-ui")));
}
#[test]
fn test_restart_filter_specific() {
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART
.iter()
.filter(|(n, d)| *n == "ory" && *d == "kratos")
.copied()
.collect();
assert_eq!(matched.len(), 1);
assert_eq!(matched[0], ("ory", "kratos"));
}
#[test]
fn test_restart_filter_no_match() {
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART
.iter()
.filter(|(n, d)| *n == "nonexistent" && *d == "nosuch")
.copied()
.collect();
assert!(matched.is_empty());
}
#[test]
fn test_restart_filter_all() {
let matched: Vec<(&str, &str)> = SERVICES_TO_RESTART.to_vec();
assert_eq!(matched.len(), 13);
}
#[test]
fn test_pod_ready_string_format() {
// Verify format: "N/M"
let ready = "2/3";
let parts: Vec<&str> = ready.split('/').collect();
assert_eq!(parts.len(), 2);
assert_ne!(parts[0], parts[1]); // unhealthy
}
#[test]
fn test_unhealthy_detection_by_ready_ratio() {
// Simulate the ready ratio check used in cmd_status
let ready = "1/2";
let status = "Running";
let mut unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
if !unhealthy && status == "Running" && ready.contains('/') {
let parts: Vec<&str> = ready.split('/').collect();
if parts.len() == 2 && parts[0] != parts[1] {
unhealthy = true;
}
}
assert!(unhealthy);
}
#[test]
fn test_healthy_detection_by_ready_ratio() {
let ready = "2/2";
let status = "Running";
let mut unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
if !unhealthy && status == "Running" && ready.contains('/') {
let parts: Vec<&str> = ready.split('/').collect();
if parts.len() == 2 && parts[0] != parts[1] {
unhealthy = true;
}
}
assert!(!unhealthy);
}
#[test]
fn test_completed_pods_are_healthy() {
let status = "Completed";
let unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
assert!(!unhealthy);
}
#[test]
fn test_pending_pods_are_unhealthy() {
let status = "Pending";
let unhealthy = !matches!(status, "Running" | "Completed" | "Succeeded");
assert!(unhealthy);
}
}

View File

@@ -0,0 +1,443 @@
use crate::error::{Result, ResultExt};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
/// Compile-time commit SHA set by build.rs.
pub const COMMIT: &str = env!("SUNBEAM_COMMIT");
/// Compile-time build target triple set by build.rs.
pub const TARGET: &str = env!("SUNBEAM_TARGET");
/// Compile-time build date set by build.rs.
pub const BUILD_DATE: &str = env!("SUNBEAM_BUILD_DATE");
/// Artifact name prefix for this platform.
fn artifact_name() -> String {
format!("sunbeam-{TARGET}")
}
/// Resolve the forge URL (Gitea instance).
///
/// TODO: Once kube.rs exposes `get_domain()`, derive this automatically as
/// `https://src.{domain}`. For now we read the SUNBEAM_FORGE_URL environment
/// variable with a sensible fallback.
fn forge_url() -> String {
if let Ok(url) = std::env::var("SUNBEAM_FORGE_URL") {
return url.trim_end_matches('/').to_string();
}
// Derive from production_host domain in config
let config = crate::config::load_config();
if !config.production_host.is_empty() {
// production_host is like "user@server.example.com" — extract domain
let host = config
.production_host
.split('@')
.last()
.unwrap_or(&config.production_host);
// Strip any leading subdomain segments that look like a hostname to get the base domain.
// For a host like "admin.sunbeam.pt", the forge is "src.sunbeam.pt".
// Heuristic: use the last two segments as the domain.
let parts: Vec<&str> = host.split('.').collect();
if parts.len() >= 2 {
let domain = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
return format!("https://src.{domain}");
}
}
// Hard fallback — will fail at runtime if not configured, which is fine.
String::new()
}
/// Cache file location for background update checks.
fn update_cache_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".local/share"))
.join("sunbeam")
.join("update-check.json")
}
// ---------------------------------------------------------------------------
// Gitea API response types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct BranchResponse {
commit: BranchCommit,
}
#[derive(Debug, Deserialize)]
struct BranchCommit {
id: String,
}
#[derive(Debug, Deserialize)]
struct ArtifactListResponse {
artifacts: Vec<Artifact>,
}
#[derive(Debug, Deserialize)]
struct Artifact {
name: String,
id: u64,
}
// ---------------------------------------------------------------------------
// Update-check cache
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize)]
struct UpdateCache {
last_check: DateTime<Utc>,
latest_commit: String,
current_commit: String,
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Print version information.
pub fn cmd_version() {
println!("sunbeam {COMMIT}");
println!(" target: {TARGET}");
println!(" built: {BUILD_DATE}");
}
/// Self-update from the latest mainline commit via Gitea CI artifacts.
pub async fn cmd_update() -> Result<()> {
let base = forge_url();
if base.is_empty() {
bail!(
"Forge URL not configured. Set SUNBEAM_FORGE_URL or configure a \
production host via `sunbeam config set --host`."
);
}
crate::output::step("Checking for updates...");
let client = reqwest::Client::new();
// 1. Check latest commit on mainline
let latest_commit = fetch_latest_commit(&client, &base).await?;
let short_latest = &latest_commit[..std::cmp::min(8, latest_commit.len())];
crate::output::ok(&format!("Current: {COMMIT}"));
crate::output::ok(&format!("Latest: {short_latest}"));
if latest_commit.starts_with(COMMIT) || COMMIT.starts_with(&latest_commit[..std::cmp::min(COMMIT.len(), latest_commit.len())]) {
crate::output::ok("Already up to date.");
return Ok(());
}
// 2. Find the CI artifact for our platform
crate::output::step("Downloading update...");
let wanted = artifact_name();
let artifacts = fetch_artifacts(&client, &base).await?;
let binary_artifact = artifacts
.iter()
.find(|a| a.name == wanted)
.with_ctx(|| format!("No artifact found for platform '{wanted}'"))?;
let checksums_artifact = artifacts
.iter()
.find(|a| a.name == "checksums.txt" || a.name == "checksums");
// 3. Download the binary
let binary_url = format!(
"{base}/api/v1/repos/studio/cli/actions/artifacts/{id}",
id = binary_artifact.id
);
let binary_bytes = client
.get(&binary_url)
.send()
.await?
.error_for_status()
.ctx("Failed to download binary artifact")?
.bytes()
.await?;
crate::output::ok(&format!("Downloaded {} bytes", binary_bytes.len()));
// 4. Verify SHA256 if checksums artifact exists
if let Some(checksums) = checksums_artifact {
let checksums_url = format!(
"{base}/api/v1/repos/studio/cli/actions/artifacts/{id}",
id = checksums.id
);
let checksums_text = client
.get(&checksums_url)
.send()
.await?
.error_for_status()
.ctx("Failed to download checksums")?
.text()
.await?;
verify_checksum(&binary_bytes, &wanted, &checksums_text)?;
crate::output::ok("SHA256 checksum verified.");
} else {
crate::output::warn("No checksums artifact found; skipping verification.");
}
// 5. Atomic self-replace
crate::output::step("Installing update...");
let current_exe = std::env::current_exe().ctx("Failed to determine current executable path")?;
atomic_replace(&current_exe, &binary_bytes)?;
crate::output::ok(&format!(
"Updated sunbeam {COMMIT} -> {short_latest}"
));
// Update the cache so background check knows we are current
let _ = write_cache(&UpdateCache {
last_check: Utc::now(),
latest_commit: latest_commit.clone(),
current_commit: latest_commit,
});
Ok(())
}
/// Background update check. Returns a notification message if a newer version
/// is available, or None if up-to-date / on error / checked too recently.
///
/// This function never blocks for long and never returns errors — it silently
/// returns None on any failure.
pub async fn check_update_background() -> Option<String> {
// Read cache
let cache_path = update_cache_path();
if let Ok(data) = fs::read_to_string(&cache_path) {
if let Ok(cache) = serde_json::from_str::<UpdateCache>(&data) {
let age = Utc::now().signed_duration_since(cache.last_check);
if age.num_seconds() < 3600 {
// Checked recently — just compare cached values
if cache.latest_commit.starts_with(COMMIT)
|| COMMIT.starts_with(&cache.latest_commit[..std::cmp::min(COMMIT.len(), cache.latest_commit.len())])
{
return None; // up to date
}
let short = &cache.latest_commit[..std::cmp::min(8, cache.latest_commit.len())];
return Some(format!(
"A newer version of sunbeam is available ({short}). Run `sunbeam update` to upgrade."
));
}
}
}
// Time to check again
let base = forge_url();
if base.is_empty() {
return None;
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.ok()?;
let latest = fetch_latest_commit(&client, &base).await.ok()?;
let cache = UpdateCache {
last_check: Utc::now(),
latest_commit: latest.clone(),
current_commit: COMMIT.to_string(),
};
let _ = write_cache(&cache);
if latest.starts_with(COMMIT)
|| COMMIT.starts_with(&latest[..std::cmp::min(COMMIT.len(), latest.len())])
{
return None;
}
let short = &latest[..std::cmp::min(8, latest.len())];
Some(format!(
"A newer version of sunbeam is available ({short}). Run `sunbeam update` to upgrade."
))
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Fetch the latest commit SHA on the mainline branch.
async fn fetch_latest_commit(client: &reqwest::Client, forge_url: &str) -> Result<String> {
let url = format!("{forge_url}/api/v1/repos/studio/cli/branches/mainline");
let resp: BranchResponse = client
.get(&url)
.send()
.await?
.error_for_status()
.ctx("Failed to query mainline branch")?
.json()
.await?;
Ok(resp.commit.id)
}
/// Fetch the list of CI artifacts for the repo.
async fn fetch_artifacts(client: &reqwest::Client, forge_url: &str) -> Result<Vec<Artifact>> {
let url = format!("{forge_url}/api/v1/repos/studio/cli/actions/artifacts");
let resp: ArtifactListResponse = client
.get(&url)
.send()
.await?
.error_for_status()
.ctx("Failed to query CI artifacts")?
.json()
.await?;
Ok(resp.artifacts)
}
/// Verify that the downloaded binary matches the expected SHA256 from checksums text.
///
/// Checksums file format (one per line):
/// <hex-sha256> <filename>
fn verify_checksum(binary: &[u8], artifact_name: &str, checksums_text: &str) -> Result<()> {
let actual = {
let mut hasher = Sha256::new();
hasher.update(binary);
format!("{:x}", hasher.finalize())
};
for line in checksums_text.lines() {
// Split on whitespace — format is "<hash> <name>" or "<hash> <name>"
let mut parts = line.split_whitespace();
if let (Some(expected_hash), Some(name)) = (parts.next(), parts.next()) {
if name == artifact_name {
if actual != expected_hash {
bail!(
"Checksum mismatch for {artifact_name}:\n expected: {expected_hash}\n actual: {actual}"
);
}
return Ok(());
}
}
}
bail!("No checksum entry found for '{artifact_name}' in checksums file");
}
/// Atomically replace the binary at `target` with `new_bytes`.
///
/// Writes to a temp file in the same directory, sets executable permissions,
/// then renames over the original.
fn atomic_replace(target: &std::path::Path, new_bytes: &[u8]) -> Result<()> {
let parent = target
.parent()
.ctx("Cannot determine parent directory of current executable")?;
let tmp_path = parent.join(".sunbeam-update.tmp");
// Write new binary
fs::write(&tmp_path, new_bytes).ctx("Failed to write temporary update file")?;
// Set executable permissions (unix)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755))
.ctx("Failed to set executable permissions")?;
}
// Atomic rename
fs::rename(&tmp_path, target).ctx("Failed to replace current executable")?;
Ok(())
}
/// Write the update-check cache to disk.
fn write_cache(cache: &UpdateCache) -> Result<()> {
let path = update_cache_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(cache)?;
fs::write(&path, json)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_consts() {
// COMMIT, TARGET, BUILD_DATE are set at compile time
assert!(!COMMIT.is_empty());
assert!(!TARGET.is_empty());
assert!(!BUILD_DATE.is_empty());
}
#[test]
fn test_artifact_name() {
let name = artifact_name();
assert!(name.starts_with("sunbeam-"));
assert!(name.contains(TARGET));
}
#[test]
fn test_verify_checksum_ok() {
let data = b"hello world";
let mut hasher = Sha256::new();
hasher.update(data);
let hash = format!("{:x}", hasher.finalize());
let checksums = format!("{hash} sunbeam-test");
assert!(verify_checksum(data, "sunbeam-test", &checksums).is_ok());
}
#[test]
fn test_verify_checksum_mismatch() {
let checksums = "0000000000000000000000000000000000000000000000000000000000000000 sunbeam-test";
assert!(verify_checksum(b"hello", "sunbeam-test", checksums).is_err());
}
#[test]
fn test_verify_checksum_missing_entry() {
let checksums = "abcdef1234567890 sunbeam-other";
assert!(verify_checksum(b"hello", "sunbeam-test", checksums).is_err());
}
#[test]
fn test_update_cache_path() {
let path = update_cache_path();
assert!(path.to_string_lossy().contains("sunbeam"));
assert!(path.to_string_lossy().ends_with("update-check.json"));
}
#[test]
fn test_cache_roundtrip() {
let cache = UpdateCache {
last_check: Utc::now(),
latest_commit: "abc12345".to_string(),
current_commit: "def67890".to_string(),
};
let json = serde_json::to_string(&cache).unwrap();
let loaded: UpdateCache = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.latest_commit, "abc12345");
assert_eq!(loaded.current_commit, "def67890");
}
#[tokio::test]
async fn test_check_update_background_returns_none_when_forge_url_empty() {
// When SUNBEAM_FORGE_URL is unset and there is no production_host config,
// forge_url() returns "" and check_update_background should return None
// without making any network requests.
// Clear the env var to ensure we hit the empty-URL path.
// SAFETY: This test is not run concurrently with other tests that depend on this env var.
unsafe { std::env::remove_var("SUNBEAM_FORGE_URL") };
// Note: this test assumes no production_host is configured in the test
// environment, which is the default for CI/dev. If forge_url() returns
// a non-empty string (e.g. from config), the test may still pass because
// the background check silently returns None on network errors.
let result = check_update_background().await;
// Either None (empty forge URL or network error) — never panics.
// The key property: this completes quickly without hanging.
drop(result);
}
}

View File

@@ -0,0 +1,656 @@
//! User management -- Kratos identity operations via port-forwarded admin API.
mod provisioning;
pub use provisioning::{cmd_user_onboard, cmd_user_offboard};
use serde_json::Value;
use std::io::Write;
use crate::error::{Result, ResultExt, SunbeamError};
use crate::output::{ok, step, table, warn};
const SMTP_LOCAL_PORT: u16 = 10025;
// ---------------------------------------------------------------------------
// Port-forward helper
// ---------------------------------------------------------------------------
/// RAII guard that terminates the port-forward on drop.
struct PortForward {
child: tokio::process::Child,
pub base_url: String,
}
impl PortForward {
async fn new(ns: &str, svc: &str, local_port: u16, remote_port: u16) -> Result<Self> {
let ctx = crate::kube::context();
let child = tokio::process::Command::new("kubectl")
.arg(format!("--context={ctx}"))
.args([
"-n",
ns,
"port-forward",
&format!("svc/{svc}"),
&format!("{local_port}:{remote_port}"),
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.with_ctx(|| format!("Failed to spawn port-forward to {ns}/svc/{svc}"))?;
// Give the port-forward time to bind
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
Ok(Self {
child,
base_url: format!("http://localhost:{local_port}"),
})
}
/// Convenience: Kratos admin (ory/kratos-admin 80 -> 4434).
async fn kratos() -> Result<Self> {
Self::new("ory", "kratos-admin", 4434, 80).await
}
}
impl Drop for PortForward {
fn drop(&mut self) {
let _ = self.child.start_kill();
}
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
/// Make an HTTP request to an admin API endpoint.
async fn api(
base_url: &str,
path: &str,
method: &str,
body: Option<&Value>,
prefix: &str,
ok_statuses: &[u16],
) -> Result<Option<Value>> {
let url = format!("{base_url}{prefix}{path}");
let client = reqwest::Client::new();
let mut req = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"PATCH" => client.patch(&url),
"DELETE" => client.delete(&url),
_ => bail!("Unsupported HTTP method: {method}"),
};
req = req
.header("Content-Type", "application/json")
.header("Accept", "application/json");
if let Some(b) = body {
req = req.json(b);
}
let resp = req
.send()
.await
.with_ctx(|| format!("HTTP {method} {url} failed"))?;
let status = resp.status().as_u16();
if !resp.status().is_success() {
if ok_statuses.contains(&status) {
return Ok(None);
}
let err_text = resp.text().await.unwrap_or_default();
bail!("API error {status}: {err_text}");
}
let text = resp.text().await.unwrap_or_default();
if text.is_empty() {
return Ok(None);
}
let val: Value = serde_json::from_str(&text)
.with_ctx(|| format!("Failed to parse API response as JSON: {text}"))?;
Ok(Some(val))
}
/// Shorthand: Kratos admin API call (prefix = "/admin").
async fn kratos_api(
base_url: &str,
path: &str,
method: &str,
body: Option<&Value>,
ok_statuses: &[u16],
) -> Result<Option<Value>> {
api(base_url, path, method, body, "/admin", ok_statuses).await
}
// ---------------------------------------------------------------------------
// Identity helpers
// ---------------------------------------------------------------------------
/// Find identity by UUID or email search. Returns the identity JSON.
async fn find_identity(base_url: &str, target: &str, required: bool) -> Result<Option<Value>> {
// Looks like a UUID?
if target.len() == 36 && target.chars().filter(|&c| c == '-').count() == 4 {
let result = kratos_api(base_url, &format!("/identities/{target}"), "GET", None, &[]).await?;
return Ok(result);
}
// Search by email
let result = kratos_api(
base_url,
&format!("/identities?credentials_identifier={target}&page_size=1"),
"GET",
None,
&[],
)
.await?;
if let Some(Value::Array(arr)) = &result {
if let Some(first) = arr.first() {
return Ok(Some(first.clone()));
}
}
if required {
return Err(SunbeamError::identity(format!("Identity not found: {target}")));
}
Ok(None)
}
/// Build the PUT body for updating an identity, preserving all required fields.
fn identity_put_body(identity: &Value, state: Option<&str>, extra: Option<Value>) -> Value {
let mut body = serde_json::json!({
"schema_id": identity["schema_id"],
"traits": identity["traits"],
"state": state.unwrap_or_else(|| identity.get("state").and_then(|v| v.as_str()).unwrap_or("active")),
"metadata_public": identity.get("metadata_public").cloned().unwrap_or(Value::Null),
"metadata_admin": identity.get("metadata_admin").cloned().unwrap_or(Value::Null),
});
if let Some(extra_obj) = extra {
if let (Some(base_map), Some(extra_map)) = (body.as_object_mut(), extra_obj.as_object()) {
for (k, v) in extra_map {
base_map.insert(k.clone(), v.clone());
}
}
}
body
}
/// Generate a 24h recovery code. Returns (link, code).
async fn generate_recovery(base_url: &str, identity_id: &str) -> Result<(String, String)> {
let body = serde_json::json!({
"identity_id": identity_id,
"expires_in": "24h",
});
let result = kratos_api(base_url, "/recovery/code", "POST", Some(&body), &[]).await?;
let recovery = result.unwrap_or_default();
let link = recovery
.get("recovery_link")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let code = recovery
.get("recovery_code")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok((link, code))
}
/// Find the next sequential employee ID by scanning all employee identities.
///
/// Paginates through all identities using `page` and `page_size` params to
/// avoid missing employee IDs when there are more than 200 identities.
async fn next_employee_id(base_url: &str) -> Result<String> {
let mut max_num: u64 = 0;
let mut page = 1;
loop {
let result = kratos_api(
base_url,
&format!("/identities?page_size=200&page={page}"),
"GET",
None,
&[],
)
.await?;
let identities = match result {
Some(Value::Array(arr)) if !arr.is_empty() => arr,
_ => break,
};
for ident in &identities {
if let Some(eid) = ident
.get("traits")
.and_then(|t| t.get("employee_id"))
.and_then(|v| v.as_str())
{
if let Ok(n) = eid.parse::<u64>() {
max_num = max_num.max(n);
}
}
}
if identities.len() < 200 {
break; // last page
}
page += 1;
}
Ok((max_num + 1).to_string())
}
// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------
/// Extract a display name from identity traits (supports both default and employee schemas).
fn display_name(traits: &Value) -> String {
let given = traits
.get("given_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let family = traits
.get("family_name")
.and_then(|v| v.as_str())
.unwrap_or("");
if !given.is_empty() || !family.is_empty() {
return format!("{given} {family}").trim().to_string();
}
match traits.get("name") {
Some(Value::Object(name_map)) => {
let first = name_map
.get("first")
.and_then(|v| v.as_str())
.unwrap_or("");
let last = name_map
.get("last")
.and_then(|v| v.as_str())
.unwrap_or("");
format!("{first} {last}").trim().to_string()
}
Some(name) => name.as_str().unwrap_or("").to_string(),
None => String::new(),
}
}
/// Extract the short ID prefix (first 8 chars + "...").
fn short_id(id: &str) -> String {
if id.len() >= 8 {
format!("{}...", &id[..8])
} else {
id.to_string()
}
}
/// Get identity ID as a string from a JSON value.
fn identity_id(identity: &Value) -> Result<String> {
identity
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| SunbeamError::identity("Identity missing 'id' field"))
}
// ---------------------------------------------------------------------------
// Public commands
// ---------------------------------------------------------------------------
pub async fn cmd_user_list(search: &str) -> Result<()> {
step("Listing identities...");
let pf = PortForward::kratos().await?;
let mut path = "/identities?page_size=20".to_string();
if !search.is_empty() {
path.push_str(&format!("&credentials_identifier={search}"));
}
let result = kratos_api(&pf.base_url, &path, "GET", None, &[]).await?;
drop(pf);
let identities = match result {
Some(Value::Array(arr)) => arr,
_ => vec![],
};
let rows: Vec<Vec<String>> = identities
.iter()
.map(|i| {
let traits = i.get("traits").cloned().unwrap_or(Value::Object(Default::default()));
let email = traits
.get("email")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = display_name(&traits);
let state = i
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("active")
.to_string();
let id = i
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("");
vec![short_id(id), email, name, state]
})
.collect();
println!("{}", table(&rows, &["ID", "Email", "Name", "State"]));
Ok(())
}
pub async fn cmd_user_get(target: &str) -> Result<()> {
step(&format!("Getting identity: {target}"));
let pf = PortForward::kratos().await?;
let identity = find_identity(&pf.base_url, target, true)
.await?
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
drop(pf);
println!("{}", serde_json::to_string_pretty(&identity)?);
Ok(())
}
pub async fn cmd_user_create(email: &str, name: &str, schema_id: &str) -> Result<()> {
step(&format!("Creating identity: {email}"));
let mut traits = serde_json::json!({ "email": email });
if !name.is_empty() {
let parts: Vec<&str> = name.splitn(2, ' ').collect();
traits["name"] = serde_json::json!({
"first": parts[0],
"last": if parts.len() > 1 { parts[1] } else { "" },
});
}
let body = serde_json::json!({
"schema_id": schema_id,
"traits": traits,
"state": "active",
});
let pf = PortForward::kratos().await?;
let identity = kratos_api(&pf.base_url, "/identities", "POST", Some(&body), &[])
.await?
.ok_or_else(|| SunbeamError::identity("Failed to create identity"))?;
let iid = identity_id(&identity)?;
ok(&format!("Created identity: {iid}"));
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
drop(pf);
ok("Recovery link (valid 24h):");
println!("{link}");
ok("Recovery code (enter on the page above):");
println!("{code}");
Ok(())
}
pub async fn cmd_user_delete(target: &str) -> Result<()> {
step(&format!("Deleting identity: {target}"));
eprint!("Delete identity '{target}'? This cannot be undone. [y/N] ");
std::io::stderr().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if answer.trim().to_lowercase() != "y" {
ok("Cancelled.");
return Ok(());
}
let pf = PortForward::kratos().await?;
let identity = find_identity(&pf.base_url, target, true)
.await?
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?;
kratos_api(
&pf.base_url,
&format!("/identities/{iid}"),
"DELETE",
None,
&[],
)
.await?;
drop(pf);
ok("Deleted.");
Ok(())
}
pub async fn cmd_user_recover(target: &str) -> Result<()> {
step(&format!("Generating recovery link for: {target}"));
let pf = PortForward::kratos().await?;
let identity = find_identity(&pf.base_url, target, true)
.await?
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?;
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
drop(pf);
ok("Recovery link (valid 24h):");
println!("{link}");
ok("Recovery code (enter on the page above):");
println!("{code}");
Ok(())
}
pub async fn cmd_user_disable(target: &str) -> Result<()> {
step(&format!("Disabling identity: {target}"));
let pf = PortForward::kratos().await?;
let identity = find_identity(&pf.base_url, target, true)
.await?
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?;
let put_body = identity_put_body(&identity, Some("inactive"), None);
kratos_api(
&pf.base_url,
&format!("/identities/{iid}"),
"PUT",
Some(&put_body),
&[],
)
.await?;
kratos_api(
&pf.base_url,
&format!("/identities/{iid}/sessions"),
"DELETE",
None,
&[],
)
.await?;
drop(pf);
ok(&format!(
"Identity {}... disabled and all Kratos sessions revoked.",
&iid[..8.min(iid.len())]
));
warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE -- currently 1h.");
Ok(())
}
pub async fn cmd_user_enable(target: &str) -> Result<()> {
step(&format!("Enabling identity: {target}"));
let pf = PortForward::kratos().await?;
let identity = find_identity(&pf.base_url, target, true)
.await?
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?;
let put_body = identity_put_body(&identity, Some("active"), None);
kratos_api(
&pf.base_url,
&format!("/identities/{iid}"),
"PUT",
Some(&put_body),
&[],
)
.await?;
drop(pf);
ok(&format!("Identity {}... re-enabled.", short_id(&iid)));
Ok(())
}
pub async fn cmd_user_set_password(target: &str, password: &str) -> Result<()> {
step(&format!("Setting password for: {target}"));
let pf = PortForward::kratos().await?;
let identity = find_identity(&pf.base_url, target, true)
.await?
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?;
let extra = serde_json::json!({
"credentials": {
"password": {
"config": {
"password": password,
}
}
}
});
let put_body = identity_put_body(&identity, None, Some(extra));
kratos_api(
&pf.base_url,
&format!("/identities/{iid}"),
"PUT",
Some(&put_body),
&[],
)
.await?;
drop(pf);
ok(&format!("Password set for {}...", short_id(&iid)));
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_display_name_employee_schema() {
let traits = serde_json::json!({
"email": "test@example.com",
"given_name": "Alice",
"family_name": "Smith",
});
assert_eq!(display_name(&traits), "Alice Smith");
}
#[test]
fn test_display_name_default_schema() {
let traits = serde_json::json!({
"email": "test@example.com",
"name": { "first": "Bob", "last": "Jones" },
});
assert_eq!(display_name(&traits), "Bob Jones");
}
#[test]
fn test_display_name_empty() {
let traits = serde_json::json!({ "email": "test@example.com" });
assert_eq!(display_name(&traits), "");
}
#[test]
fn test_display_name_given_only() {
let traits = serde_json::json!({
"given_name": "Alice",
});
assert_eq!(display_name(&traits), "Alice");
}
#[test]
fn test_short_id() {
assert_eq!(
short_id("12345678-abcd-1234-abcd-123456789012"),
"12345678..."
);
}
#[test]
fn test_short_id_short() {
assert_eq!(short_id("abc"), "abc");
}
#[test]
fn test_identity_put_body_preserves_fields() {
let identity = serde_json::json!({
"schema_id": "employee",
"traits": { "email": "a@b.com" },
"state": "active",
"metadata_public": null,
"metadata_admin": null,
});
let body = identity_put_body(&identity, Some("inactive"), None);
assert_eq!(body["state"], "inactive");
assert_eq!(body["schema_id"], "employee");
assert_eq!(body["traits"]["email"], "a@b.com");
}
#[test]
fn test_identity_put_body_with_extra() {
let identity = serde_json::json!({
"schema_id": "default",
"traits": { "email": "a@b.com" },
"state": "active",
});
let extra = serde_json::json!({
"credentials": {
"password": { "config": { "password": "s3cret" } }
}
});
let body = identity_put_body(&identity, None, Some(extra));
assert_eq!(body["state"], "active");
assert!(body["credentials"]["password"]["config"]["password"] == "s3cret");
}
#[test]
fn test_identity_put_body_default_state() {
let identity = serde_json::json!({
"schema_id": "default",
"traits": {},
"state": "inactive",
});
let body = identity_put_body(&identity, None, None);
assert_eq!(body["state"], "inactive");
}
#[test]
fn test_identity_id_extraction() {
let identity = serde_json::json!({ "id": "12345678-abcd-1234-abcd-123456789012" });
assert_eq!(
identity_id(&identity).unwrap(),
"12345678-abcd-1234-abcd-123456789012"
);
}
#[test]
fn test_identity_id_missing() {
let identity = serde_json::json!({});
assert!(identity_id(&identity).is_err());
}
}

View File

@@ -0,0 +1,516 @@
//! User provisioning -- onboarding and offboarding workflows.
use serde_json::Value;
use std::io::Write;
use crate::error::{Result, ResultExt, SunbeamError};
use crate::output::{ok, step, warn};
use super::{
api, find_identity, generate_recovery, identity_id, identity_put_body, kratos_api,
next_employee_id, short_id, PortForward, SMTP_LOCAL_PORT,
};
// ---------------------------------------------------------------------------
// App-level provisioning (best-effort)
// ---------------------------------------------------------------------------
/// Resolve a deployment to the name of a running pod.
async fn pod_for_deployment(ns: &str, deployment: &str) -> Result<String> {
let client = crate::kube::get_client().await?;
let pods: kube::Api<k8s_openapi::api::core::v1::Pod> =
kube::Api::namespaced(client.clone(), ns);
let label = format!("app.kubernetes.io/name={deployment}");
let lp = kube::api::ListParams::default().labels(&label);
let pod_list = pods
.list(&lp)
.await
.with_ctx(|| format!("Failed to list pods for deployment {deployment} in {ns}"))?;
for pod in &pod_list.items {
if let Some(status) = &pod.status {
let phase = status.phase.as_deref().unwrap_or("");
if phase == "Running" {
if let Some(name) = &pod.metadata.name {
return Ok(name.clone());
}
}
}
}
// Fallback: try with app= label
let label2 = format!("app={deployment}");
let lp2 = kube::api::ListParams::default().labels(&label2);
let pod_list2 = match pods.list(&lp2).await {
Ok(list) => list,
Err(_) => {
return Err(SunbeamError::kube(format!(
"No running pod found for deployment {deployment} in {ns}"
)));
}
};
for pod in &pod_list2.items {
if let Some(status) = &pod.status {
let phase = status.phase.as_deref().unwrap_or("");
if phase == "Running" {
if let Some(name) = &pod.metadata.name {
return Ok(name.clone());
}
}
}
}
Err(SunbeamError::kube(format!(
"No running pod found for deployment {deployment} in {ns}"
)))
}
/// Create a mailbox in Messages via kubectl exec into the backend.
async fn create_mailbox(email: &str, name: &str) {
let parts: Vec<&str> = email.splitn(2, '@').collect();
if parts.len() != 2 {
warn(&format!("Invalid email for mailbox creation: {email}"));
return;
}
let local_part = parts[0];
let domain_part = parts[1];
let display_name = if name.is_empty() { local_part } else { name };
let _ = display_name; // used in Python for future features; kept for parity
step(&format!("Creating mailbox: {email}"));
let pod = match pod_for_deployment("lasuite", "messages-backend").await {
Ok(p) => p,
Err(e) => {
warn(&format!("Could not find messages-backend pod: {e}"));
return;
}
};
let script = format!(
"mb, created = Mailbox.objects.get_or_create(\n local_part=\"{}\",\n domain=MailDomain.objects.get(name=\"{}\"),\n)\nprint(\"created\" if created else \"exists\")\n",
local_part, domain_part,
);
let cmd: Vec<&str> = vec!["python", "manage.py", "shell", "-c", &script];
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("messages-backend")).await {
Ok((0, output)) if output.contains("created") => {
ok(&format!("Mailbox {email} created."));
}
Ok((0, output)) if output.contains("exists") => {
ok(&format!("Mailbox {email} already exists."));
}
Ok((_, output)) => {
warn(&format!(
"Could not create mailbox (Messages backend may not be running): {output}"
));
}
Err(e) => {
warn(&format!("Could not create mailbox: {e}"));
}
}
}
/// Delete a mailbox and associated Django user in Messages.
async fn delete_mailbox(email: &str) {
let parts: Vec<&str> = email.splitn(2, '@').collect();
if parts.len() != 2 {
warn(&format!("Invalid email for mailbox deletion: {email}"));
return;
}
let local_part = parts[0];
let domain_part = parts[1];
step(&format!("Cleaning up mailbox: {email}"));
let pod = match pod_for_deployment("lasuite", "messages-backend").await {
Ok(p) => p,
Err(e) => {
warn(&format!("Could not find messages-backend pod: {e}"));
return;
}
};
let script = format!(
"from django.contrib.auth import get_user_model\nUser = get_user_model()\ndeleted = 0\nfor mb in Mailbox.objects.filter(local_part=\"{local_part}\", domain__name=\"{domain_part}\"):\n mb.delete()\n deleted += 1\ntry:\n u = User.objects.get(email=\"{email}\")\n u.delete()\n deleted += 1\nexcept User.DoesNotExist:\n pass\nprint(f\"deleted {{deleted}}\")\n",
);
let cmd: Vec<&str> = vec!["python", "manage.py", "shell", "-c", &script];
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("messages-backend")).await {
Ok((0, output)) if output.contains("deleted") => {
ok("Mailbox and user cleaned up.");
}
Ok((_, output)) => {
warn(&format!("Could not clean up mailbox: {output}"));
}
Err(e) => {
warn(&format!("Could not clean up mailbox: {e}"));
}
}
}
/// Create a Projects (Planka) user and add them as manager of the Default project.
async fn setup_projects_user(email: &str, name: &str) {
step(&format!("Setting up Projects user: {email}"));
let pod = match pod_for_deployment("lasuite", "projects").await {
Ok(p) => p,
Err(e) => {
warn(&format!("Could not find projects pod: {e}"));
return;
}
};
let js = format!(
"const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});\nasync function go() {{\n let user = await knex('user_account').where({{email: '{email}'}}).first();\n if (!user) {{\n const id = Date.now().toString();\n await knex('user_account').insert({{\n id, email: '{email}', name: '{name}', password: '',\n is_admin: true, is_sso: true, language: 'en-US',\n created_at: new Date(), updated_at: new Date()\n }});\n user = {{id}};\n console.log('user_created');\n }} else {{\n console.log('user_exists');\n }}\n const project = await knex('project').where({{name: 'Default'}}).first();\n if (project) {{\n const exists = await knex('project_manager').where({{project_id: project.id, user_id: user.id}}).first();\n if (!exists) {{\n await knex('project_manager').insert({{\n id: (Date.now()+1).toString(), project_id: project.id,\n user_id: user.id, created_at: new Date()\n }});\n console.log('manager_added');\n }} else {{\n console.log('manager_exists');\n }}\n }} else {{\n console.log('no_default_project');\n }}\n}}\ngo().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});\n",
);
let cmd: Vec<&str> = vec!["node", "-e", &js];
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("projects")).await {
Ok((0, output))
if output.contains("manager_added") || output.contains("manager_exists") =>
{
ok("Projects user ready.");
}
Ok((0, output)) if output.contains("no_default_project") => {
warn("No Default project found in Projects -- skip.");
}
Ok((_, output)) => {
warn(&format!("Could not set up Projects user: {output}"));
}
Err(e) => {
warn(&format!("Could not set up Projects user: {e}"));
}
}
}
/// Remove a user from Projects (Planka) -- delete memberships and soft-delete user.
async fn cleanup_projects_user(email: &str) {
step(&format!("Cleaning up Projects user: {email}"));
let pod = match pod_for_deployment("lasuite", "projects").await {
Ok(p) => p,
Err(e) => {
warn(&format!("Could not find projects pod: {e}"));
return;
}
};
let js = format!(
"const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});\nasync function go() {{\n const user = await knex('user_account').where({{email: '{email}'}}).first();\n if (!user) {{ console.log('not_found'); return; }}\n await knex('board_membership').where({{user_id: user.id}}).del();\n await knex('project_manager').where({{user_id: user.id}}).del();\n await knex('user_account').where({{id: user.id}}).update({{deleted_at: new Date()}});\n console.log('cleaned');\n}}\ngo().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});\n",
);
let cmd: Vec<&str> = vec!["node", "-e", &js];
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("projects")).await {
Ok((0, output)) if output.contains("cleaned") => {
ok("Projects user cleaned up.");
}
Ok((_, output)) => {
warn(&format!("Could not clean up Projects user: {output}"));
}
Err(e) => {
warn(&format!("Could not clean up Projects user: {e}"));
}
}
}
// ---------------------------------------------------------------------------
// Onboard
// ---------------------------------------------------------------------------
/// Send a welcome email via cluster Postfix (port-forward to svc/postfix in lasuite).
async fn send_welcome_email(
domain: &str,
email: &str,
name: &str,
recovery_link: &str,
recovery_code: &str,
job_title: &str,
department: &str,
) -> Result<()> {
let greeting = if name.is_empty() {
"Hi".to_string()
} else {
format!("Hi {name}")
};
let joining_line = if !job_title.is_empty() && !department.is_empty() {
format!(
" You're joining as {job_title} in the {department} department."
)
} else {
String::new()
};
let body_text = format!(
"{greeting},
Welcome to Sunbeam Studios!{joining_line} Your account has been created.
To set your password, open this link and enter the recovery code below:
Link: {recovery_link}
Code: {recovery_code}
This link expires in 24 hours.
Once signed in you will be prompted to set up 2FA (mandatory).
After that, head to https://auth.{domain}/settings to set up your
profile -- add your name, profile picture, and any other details.
Your services:
Calendar: https://cal.{domain}
Drive: https://drive.{domain}
Mail: https://mail.{domain}
Meet: https://meet.{domain}
Projects: https://projects.{domain}
Source Code: https://src.{domain}
Messages (Matrix):
Download Element from https://element.io/download
Open Element and sign in with a custom homeserver:
Homeserver: https://messages.{domain}
Use \"Sign in with Sunbeam Studios\" (SSO) to log in.
-- With Love & Warmth, Sunbeam Studios
"
);
use lettre::message::Mailbox;
use lettre::{Message, SmtpTransport, Transport};
let from: Mailbox = format!("Sunbeam Studios <noreply@{domain}>")
.parse()
.map_err(|e| SunbeamError::Other(format!("Invalid from address: {e}")))?;
let to: Mailbox = email
.parse()
.map_err(|e| SunbeamError::Other(format!("Invalid recipient address: {e}")))?;
let message = Message::builder()
.from(from)
.to(to)
.subject("Welcome to Sunbeam Studios -- Set Your Password")
.body(body_text)
.ctx("Failed to build email message")?;
let _pf = PortForward::new("lasuite", "postfix", SMTP_LOCAL_PORT, 25).await?;
let mailer = SmtpTransport::builder_dangerous("localhost")
.port(SMTP_LOCAL_PORT)
.build();
tokio::task::spawn_blocking(move || {
mailer
.send(&message)
.map_err(|e| SunbeamError::Other(format!("Failed to send welcome email via SMTP: {e}")))
})
.await
.map_err(|e| SunbeamError::Other(format!("Email send task panicked: {e}")))??;
ok(&format!("Welcome email sent to {email}"));
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_user_onboard(
email: &str,
name: &str,
schema_id: &str,
send_email: bool,
notify: &str,
job_title: &str,
department: &str,
office_location: &str,
hire_date: &str,
manager: &str,
) -> Result<()> {
step(&format!("Onboarding: {email}"));
let pf = PortForward::kratos().await?;
let (iid, recovery_link, recovery_code, is_new) = {
let existing = find_identity(&pf.base_url, email, false).await?;
if let Some(existing) = existing {
let iid = identity_id(&existing)?;
warn(&format!("Identity already exists: {}...", short_id(&iid)));
step("Generating fresh recovery link...");
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
(iid, link, code, false)
} else {
let mut traits = serde_json::json!({ "email": email });
if !name.is_empty() {
let parts: Vec<&str> = name.splitn(2, ' ').collect();
traits["given_name"] = Value::String(parts[0].to_string());
traits["family_name"] =
Value::String(if parts.len() > 1 { parts[1] } else { "" }.to_string());
}
let mut employee_id = String::new();
if schema_id == "employee" {
employee_id = next_employee_id(&pf.base_url).await?;
traits["employee_id"] = Value::String(employee_id.clone());
if !job_title.is_empty() {
traits["job_title"] = Value::String(job_title.to_string());
}
if !department.is_empty() {
traits["department"] = Value::String(department.to_string());
}
if !office_location.is_empty() {
traits["office_location"] = Value::String(office_location.to_string());
}
if !hire_date.is_empty() {
traits["hire_date"] = Value::String(hire_date.to_string());
}
if !manager.is_empty() {
traits["manager"] = Value::String(manager.to_string());
}
}
let body = serde_json::json!({
"schema_id": schema_id,
"traits": traits,
"state": "active",
"verifiable_addresses": [{
"value": email,
"verified": true,
"via": "email",
}],
});
let identity = kratos_api(&pf.base_url, "/identities", "POST", Some(&body), &[])
.await?
.ok_or_else(|| SunbeamError::identity("Failed to create identity"))?;
let iid = identity_id(&identity)?;
ok(&format!("Created identity: {iid}"));
if !employee_id.is_empty() {
ok(&format!("Employee #{employee_id}"));
}
// Kratos ignores verifiable_addresses on POST -- PATCH to mark verified
let patch_body = serde_json::json!([
{"op": "replace", "path": "/verifiable_addresses/0/verified", "value": true},
{"op": "replace", "path": "/verifiable_addresses/0/status", "value": "completed"},
]);
kratos_api(
&pf.base_url,
&format!("/identities/{iid}"),
"PATCH",
Some(&patch_body),
&[],
)
.await?;
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
(iid, link, code, true)
}
};
drop(pf);
// Provision app-level accounts for new users
if is_new {
create_mailbox(email, name).await;
setup_projects_user(email, name).await;
}
if send_email {
let domain = crate::kube::get_domain().await?;
let recipient = if notify.is_empty() { email } else { notify };
send_welcome_email(
&domain, recipient, name, &recovery_link, &recovery_code,
job_title, department,
)
.await?;
}
ok(&format!("Identity ID: {iid}"));
ok("Recovery link (valid 24h):");
println!("{recovery_link}");
ok("Recovery code:");
println!("{recovery_code}");
Ok(())
}
// ---------------------------------------------------------------------------
// Offboard
// ---------------------------------------------------------------------------
pub async fn cmd_user_offboard(target: &str) -> Result<()> {
step(&format!("Offboarding: {target}"));
eprint!("Offboard '{target}'? This will disable the account and revoke all sessions. [y/N] ");
std::io::stderr().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if answer.trim().to_lowercase() != "y" {
ok("Cancelled.");
return Ok(());
}
let pf = PortForward::kratos().await?;
let identity = find_identity(&pf.base_url, target, true)
.await?
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
let iid = identity_id(&identity)?;
step("Disabling identity...");
let put_body = identity_put_body(&identity, Some("inactive"), None);
kratos_api(
&pf.base_url,
&format!("/identities/{iid}"),
"PUT",
Some(&put_body),
&[],
)
.await?;
ok(&format!("Identity {}... disabled.", short_id(&iid)));
step("Revoking Kratos sessions...");
kratos_api(
&pf.base_url,
&format!("/identities/{iid}/sessions"),
"DELETE",
None,
&[404],
)
.await?;
ok("Kratos sessions revoked.");
step("Revoking Hydra consent sessions...");
{
let hydra_pf = PortForward::new("ory", "hydra-admin", 14445, 4445).await?;
api(
&hydra_pf.base_url,
&format!("/oauth2/auth/sessions/consent?subject={iid}&all=true"),
"DELETE",
None,
"/admin",
&[404],
)
.await?;
}
ok("Hydra consent sessions revoked.");
drop(pf);
// Clean up Messages mailbox and Projects user
let email = identity
.get("traits")
.and_then(|t| t.get("email"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !email.is_empty() {
delete_mailbox(email).await;
cleanup_projects_user(email).await;
}
ok(&format!("Offboarding complete for {}...", short_id(&iid)));
warn("Existing access tokens expire within ~1h (Hydra TTL).");
warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE (~1h).");
Ok(())
}

18
sunbeam/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "sunbeam"
version = "0.1.0"
edition = "2024"
description = "Sunbeam local dev stack manager"
[[bin]]
name = "sunbeam"
path = "src/main.rs"
[dependencies]
sunbeam-sdk = { path = "../sunbeam-sdk", features = ["cli"] }
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive"] }
chrono = "0.4"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rustls = { version = "0.23", features = ["ring"] }

View File

@@ -1,8 +1,20 @@
"""CLI entry point — argparse dispatch table for all sunbeam verbs.""" """CLI entry point — argparse dispatch table for all sunbeam verbs."""
import argparse import argparse
import datetime
import sys import sys
def _date_type(value):
"""Validate YYYY-MM-DD date format for argparse."""
if not value:
return value
try:
datetime.date.fromisoformat(value)
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid date: {value!r} (expected YYYY-MM-DD)")
return value
ENV_CONTEXTS = { ENV_CONTEXTS = {
"local": "sunbeam", "local": "sunbeam",
"production": "production", "production": "production",
@@ -48,6 +60,8 @@ def main() -> None:
p_apply = sub.add_parser("apply", help="kustomize build + domain subst + kubectl apply") p_apply = sub.add_parser("apply", help="kustomize build + domain subst + kubectl apply")
p_apply.add_argument("namespace", nargs="?", default="", p_apply.add_argument("namespace", nargs="?", default="",
help="Limit apply to one namespace (e.g. lasuite, ingress, ory)") help="Limit apply to one namespace (e.g. lasuite, ingress, ory)")
p_apply.add_argument("--all", action="store_true", dest="apply_all",
help="Apply all namespaces without confirmation")
p_apply.add_argument("--domain", default="", help="Domain suffix (e.g. sunbeam.pt)") p_apply.add_argument("--domain", default="", help="Domain suffix (e.g. sunbeam.pt)")
p_apply.add_argument("--email", default="", help="ACME email for cert-manager") p_apply.add_argument("--email", default="", help="ACME email for cert-manager")
@@ -82,12 +96,15 @@ def main() -> None:
"docs-frontend", "people-frontend", "people", "docs-frontend", "people-frontend", "people",
"messages", "messages-backend", "messages-frontend", "messages", "messages-backend", "messages-frontend",
"messages-mta-in", "messages-mta-out", "messages-mta-in", "messages-mta-out",
"messages-mpa", "messages-socks-proxy"], "messages-mpa", "messages-socks-proxy",
"tuwunel", "calendars", "projects", "sol"],
help="What to build") help="What to build")
p_build.add_argument("--push", action="store_true", p_build.add_argument("--push", action="store_true",
help="Push image to registry after building") help="Push image to registry after building")
p_build.add_argument("--deploy", action="store_true", p_build.add_argument("--deploy", action="store_true",
help="Apply manifests and rollout restart after pushing (implies --push)") help="Apply manifests and rollout restart after pushing (implies --push)")
p_build.add_argument("--no-cache", action="store_true",
help="Disable buildkitd layer cache")
# sunbeam check [ns[/name]] # sunbeam check [ns[/name]]
p_check = sub.add_parser("check", help="Functional service health checks") p_check = sub.add_parser("check", help="Functional service health checks")
@@ -104,12 +121,14 @@ def main() -> None:
p_config = sub.add_parser("config", help="Manage sunbeam configuration") p_config = sub.add_parser("config", help="Manage sunbeam configuration")
config_sub = p_config.add_subparsers(dest="config_action", metavar="action") config_sub = p_config.add_subparsers(dest="config_action", metavar="action")
# sunbeam config set --host HOST --infra-dir DIR # sunbeam config set --host HOST --infra-dir DIR --acme-email EMAIL
p_config_set = config_sub.add_parser("set", help="Set configuration values") p_config_set = config_sub.add_parser("set", help="Set configuration values")
p_config_set.add_argument("--host", default="", p_config_set.add_argument("--host", default="",
help="Production SSH host (e.g. user@server.example.com)") help="Production SSH host (e.g. user@server.example.com)")
p_config_set.add_argument("--infra-dir", default="", p_config_set.add_argument("--infra-dir", default="",
help="Infrastructure directory root") help="Infrastructure directory root")
p_config_set.add_argument("--acme-email", default="",
help="ACME email for Let's Encrypt certificates (e.g. ops@sunbeam.pt)")
# sunbeam config get # sunbeam config get
config_sub.add_parser("get", help="Get current configuration") config_sub.add_parser("get", help="Get current configuration")
@@ -158,6 +177,21 @@ def main() -> None:
p_user_set_pw.add_argument("target", help="Email or identity ID") p_user_set_pw.add_argument("target", help="Email or identity ID")
p_user_set_pw.add_argument("password", help="New password") p_user_set_pw.add_argument("password", help="New password")
p_user_onboard = user_sub.add_parser("onboard", help="Onboard new user (create + welcome email)")
p_user_onboard.add_argument("email", help="Email address")
p_user_onboard.add_argument("--name", default="", help="Display name (First Last)")
p_user_onboard.add_argument("--schema", default="employee", help="Schema ID (default: employee)")
p_user_onboard.add_argument("--no-email", action="store_true", help="Skip sending welcome email")
p_user_onboard.add_argument("--notify", default="", help="Send welcome email to this address instead of identity email")
p_user_onboard.add_argument("--job-title", default="", help="Job title")
p_user_onboard.add_argument("--department", default="", help="Department")
p_user_onboard.add_argument("--office-location", default="", help="Office location")
p_user_onboard.add_argument("--hire-date", default="", type=_date_type, help="Hire date (YYYY-MM-DD)")
p_user_onboard.add_argument("--manager", default="", help="Manager name or email")
p_user_offboard = user_sub.add_parser("offboard", help="Offboard user (disable + revoke all)")
p_user_offboard.add_argument("target", help="Email or identity ID")
args = parser.parse_args() args = parser.parse_args()
@@ -196,11 +230,25 @@ def main() -> None:
cmd_status(args.target) cmd_status(args.target)
elif args.verb == "apply": elif args.verb == "apply":
from sunbeam.manifests import cmd_apply from sunbeam.manifests import cmd_apply, MANAGED_NS
# --domain/--email can appear before OR after the verb; subparser wins if both set. # --domain/--email can appear before OR after the verb; subparser wins if both set.
domain = getattr(args, "domain", "") or "" domain = getattr(args, "domain", "") or ""
email = getattr(args, "email", "") or "" email = getattr(args, "email", "") or ""
namespace = getattr(args, "namespace", "") or "" namespace = getattr(args, "namespace", "") or ""
apply_all = getattr(args, "apply_all", False)
# Full apply on production requires --all or interactive confirmation
if args.env == "production" and not namespace and not apply_all:
from sunbeam.output import warn
warn(f"This will apply ALL namespaces ({', '.join(MANAGED_NS)}) to production.")
try:
answer = input(" Continue? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = ""
if answer not in ("y", "yes"):
print("Aborted.")
sys.exit(0)
cmd_apply(env=args.env, domain=domain, email=email, namespace=namespace) cmd_apply(env=args.env, domain=domain, email=email, namespace=namespace)
elif args.verb == "seed": elif args.verb == "seed":
@@ -226,7 +274,7 @@ def main() -> None:
elif args.verb == "build": elif args.verb == "build":
from sunbeam.images import cmd_build from sunbeam.images import cmd_build
push = args.push or args.deploy push = args.push or args.deploy
cmd_build(args.what, push=push, deploy=args.deploy) cmd_build(args.what, push=push, deploy=args.deploy, no_cache=args.no_cache)
elif args.verb == "check": elif args.verb == "check":
from sunbeam.checks import cmd_check from sunbeam.checks import cmd_check
@@ -249,17 +297,21 @@ def main() -> None:
p_config.print_help() p_config.print_help()
sys.exit(0) sys.exit(0)
elif action == "set": elif action == "set":
config = SunbeamConfig( config = load_config()
production_host=args.host if args.host else "", if args.host:
infra_directory=args.infra_dir if args.infra_dir else "" config.production_host = args.host
) if args.infra_dir:
config.infra_directory = args.infra_dir
if args.acme_email:
config.acme_email = args.acme_email
save_config(config) save_config(config)
elif action == "get": elif action == "get":
from sunbeam.output import ok from sunbeam.output import ok
config = load_config() config = load_config()
ok(f"Production host: {config.production_host or '(not set)'}") ok(f"Production host: {config.production_host or '(not set)'}")
ok(f"Infrastructure directory: {config.infra_directory or '(not set)'}") ok(f"Infrastructure directory: {config.infra_directory or '(not set)'}")
ok(f"ACME email: {config.acme_email or '(not set)'}")
# Also show effective production host (from config or env) # Also show effective production host (from config or env)
effective_host = get_production_host() effective_host = get_production_host()
if effective_host: if effective_host:
@@ -287,7 +339,8 @@ def main() -> None:
from sunbeam.users import (cmd_user_list, cmd_user_get, cmd_user_create, from sunbeam.users import (cmd_user_list, cmd_user_get, cmd_user_create,
cmd_user_delete, cmd_user_recover, cmd_user_delete, cmd_user_recover,
cmd_user_disable, cmd_user_enable, cmd_user_disable, cmd_user_enable,
cmd_user_set_password) cmd_user_set_password,
cmd_user_onboard, cmd_user_offboard)
action = getattr(args, "user_action", None) action = getattr(args, "user_action", None)
if action is None: if action is None:
p_user.print_help() p_user.print_help()
@@ -308,6 +361,14 @@ def main() -> None:
cmd_user_enable(args.target) cmd_user_enable(args.target)
elif action == "set-password": elif action == "set-password":
cmd_user_set_password(args.target, args.password) cmd_user_set_password(args.target, args.password)
elif action == "onboard":
cmd_user_onboard(args.email, name=args.name, schema_id=args.schema,
send_email=not args.no_email, notify=args.notify,
job_title=args.job_title, department=args.department,
office_location=args.office_location,
hire_date=args.hire_date, manager=args.manager)
elif action == "offboard":
cmd_user_offboard(args.target)
else: else:
parser.print_help() parser.print_help()

View File

@@ -12,7 +12,8 @@ from sunbeam.tools import run_tool, CACHE_DIR
from sunbeam.output import step, ok, warn, die from sunbeam.output import step, ok, warn, die
LIMA_VM = "sunbeam" LIMA_VM = "sunbeam"
SECRETS_DIR = Path(__file__).parents[3] / "infrastructure" / "secrets" / "local" from sunbeam.config import get_infra_dir as _get_infra_dir
SECRETS_DIR = _get_infra_dir() / "secrets" / "local"
GITEA_ADMIN_USER = "gitea_admin" GITEA_ADMIN_USER = "gitea_admin"

View File

@@ -11,15 +11,18 @@ CONFIG_PATH = Path.home() / ".sunbeam.json"
class SunbeamConfig: class SunbeamConfig:
"""Sunbeam configuration with production host and infrastructure directory.""" """Sunbeam configuration with production host and infrastructure directory."""
def __init__(self, production_host: str = "", infra_directory: str = ""): def __init__(self, production_host: str = "", infra_directory: str = "",
acme_email: str = ""):
self.production_host = production_host self.production_host = production_host
self.infra_directory = infra_directory self.infra_directory = infra_directory
self.acme_email = acme_email
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert configuration to dictionary for JSON serialization.""" """Convert configuration to dictionary for JSON serialization."""
return { return {
"production_host": self.production_host, "production_host": self.production_host,
"infra_directory": self.infra_directory, "infra_directory": self.infra_directory,
"acme_email": self.acme_email,
} }
@classmethod @classmethod
@@ -28,6 +31,7 @@ class SunbeamConfig:
return cls( return cls(
production_host=data.get("production_host", ""), production_host=data.get("production_host", ""),
infra_directory=data.get("infra_directory", ""), infra_directory=data.get("infra_directory", ""),
acme_email=data.get("acme_email", ""),
) )
@@ -71,3 +75,22 @@ def get_infra_directory() -> str:
"""Get infrastructure directory from config.""" """Get infrastructure directory from config."""
config = load_config() config = load_config()
return config.infra_directory return config.infra_directory
def get_infra_dir() -> "Path":
"""Infrastructure manifests directory as a Path.
Prefers the configured infra_directory; falls back to the package-relative
path (works when running from the development checkout).
"""
from pathlib import Path
configured = load_config().infra_directory
if configured:
return Path(configured)
# Dev fallback: cli/sunbeam/config.py → parents[0]=cli/sunbeam, [1]=cli, [2]=monorepo root
return Path(__file__).resolve().parents[2] / "infrastructure"
def get_repo_root() -> "Path":
"""Monorepo root directory (parent of the infrastructure directory)."""
return get_infra_dir().parent

View File

@@ -259,6 +259,7 @@ def _buildctl_build_and_push(
*, *,
target: str | None = None, target: str | None = None,
build_args: dict[str, str] | None = None, build_args: dict[str, str] | None = None,
no_cache: bool = False,
) -> None: ) -> None:
"""Build and push an image via buildkitd running in k3s. """Build and push an image via buildkitd running in k3s.
@@ -320,6 +321,8 @@ def _buildctl_build_and_push(
] ]
if target: if target:
cmd += ["--opt", f"target={target}"] cmd += ["--opt", f"target={target}"]
if no_cache:
cmd += ["--no-cache"]
if build_args: if build_args:
for k, v in build_args.items(): for k, v in build_args.items():
cmd += ["--opt", f"build-arg:{k}={v}"] cmd += ["--opt", f"build-arg:{k}={v}"]
@@ -343,6 +346,7 @@ def _build_image(
target: str | None = None, target: str | None = None,
build_args: dict[str, str] | None = None, build_args: dict[str, str] | None = None,
push: bool = False, push: bool = False,
no_cache: bool = False,
cleanup_paths: list[Path] | None = None, cleanup_paths: list[Path] | None = None,
) -> None: ) -> None:
"""Build a container image via buildkitd and push to the Gitea registry. """Build a container image via buildkitd and push to the Gitea registry.
@@ -364,6 +368,7 @@ def _build_image(
context_dir=context_dir, context_dir=context_dir,
target=target, target=target,
build_args=build_args, build_args=build_args,
no_cache=no_cache,
) )
finally: finally:
for p in (cleanup_paths or []): for p in (cleanup_paths or []):
@@ -514,16 +519,16 @@ def cmd_mirror(domain: str = "", gitea_admin_pass: str = ""):
# Build dispatch # Build dispatch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def cmd_build(what: str, push: bool = False, deploy: bool = False): def cmd_build(what: str, push: bool = False, deploy: bool = False, no_cache: bool = False):
"""Build an image. Pass push=True to push, deploy=True to also apply + rollout.""" """Build an image. Pass push=True to push, deploy=True to also apply + rollout."""
try: try:
_cmd_build(what, push=push, deploy=deploy) _cmd_build(what, push=push, deploy=deploy, no_cache=no_cache)
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
cmd_str = " ".join(str(a) for a in exc.cmd) cmd_str = " ".join(str(a) for a in exc.cmd)
die(f"Build step failed (exit {exc.returncode}): {cmd_str}") die(f"Build step failed (exit {exc.returncode}): {cmd_str}")
def _cmd_build(what: str, push: bool = False, deploy: bool = False): def _cmd_build(what: str, push: bool = False, deploy: bool = False, no_cache: bool = False):
if what == "proxy": if what == "proxy":
_build_proxy(push=push, deploy=deploy) _build_proxy(push=push, deploy=deploy)
elif what == "integration": elif what == "integration":
@@ -553,6 +558,12 @@ def _cmd_build(what: str, push: bool = False, deploy: bool = False):
_build_messages(what, push=push, deploy=deploy) _build_messages(what, push=push, deploy=deploy)
elif what == "tuwunel": elif what == "tuwunel":
_build_tuwunel(push=push, deploy=deploy) _build_tuwunel(push=push, deploy=deploy)
elif what == "calendars":
_build_calendars(push=push, deploy=deploy)
elif what == "projects":
_build_projects(push=push, deploy=deploy)
elif what == "sol":
_build_sol(push=push, deploy=deploy)
else: else:
die(f"Unknown build target: {what}") die(f"Unknown build target: {what}")
@@ -645,52 +656,11 @@ def _build_kratos_admin(push: bool = False, deploy: bool = False):
step(f"Building kratos-admin-ui -> {image} ...") step(f"Building kratos-admin-ui -> {image} ...")
if env.is_prod: _build_image(
# Cross-compile Deno for x86_64 and package into a minimal image. env, image,
if not shutil.which("deno"): kratos_admin_dir / "Dockerfile", kratos_admin_dir,
die("deno not found — install Deno: https://deno.land/") push=push,
if not shutil.which("npm"): )
die("npm not found — install Node.js")
ok("Building UI assets (npm run build)...")
_run(["npm", "run", "build"], cwd=str(kratos_admin_dir / "ui"))
ok("Cross-compiling Deno binary for x86_64-linux-gnu...")
_run([
"deno", "compile",
"--target", "x86_64-unknown-linux-gnu",
"--allow-net", "--allow-read", "--allow-env",
"--include", "ui/dist",
"-o", "kratos-admin-x86_64",
"main.ts",
], cwd=str(kratos_admin_dir))
bin_path = kratos_admin_dir / "kratos-admin-x86_64"
if not bin_path.exists():
die("Deno cross-compilation produced no binary")
pkg_dir = Path(tempfile.mkdtemp(prefix="kratos-admin-pkg-"))
shutil.copy2(str(bin_path), str(pkg_dir / "kratos-admin"))
dockerfile = pkg_dir / "Dockerfile"
dockerfile.write_text(
"FROM gcr.io/distroless/cc-debian12:nonroot\n"
"WORKDIR /app\n"
"COPY kratos-admin ./\n"
"EXPOSE 3000\n"
'ENTRYPOINT ["/app/kratos-admin"]\n'
)
try:
_build_image(env, image, dockerfile, pkg_dir, push=push)
finally:
shutil.rmtree(str(pkg_dir), ignore_errors=True)
else:
# Local: buildkitd handles the full Dockerfile build
_build_image(
env, image,
kratos_admin_dir / "Dockerfile", kratos_admin_dir,
push=push,
)
if deploy: if deploy:
_deploy_rollout(env, ["kratos-admin-ui"], "ory", timeout="120s") _deploy_rollout(env, ["kratos-admin-ui"], "ory", timeout="120s")
@@ -964,3 +934,116 @@ def _patch_dockerfile_uv(
except Exception as exc: except Exception as exc:
warn(f"Failed to stage uv binaries: {exc}") warn(f"Failed to stage uv binaries: {exc}")
return (dockerfile_path, cleanup) return (dockerfile_path, cleanup)
def _build_projects(push: bool = False, deploy: bool = False):
"""Build projects (Planka Kanban) image from source."""
env = _get_build_env()
projects_dir = _get_repo_root() / "projects"
if not projects_dir.is_dir():
die(f"projects source not found at {projects_dir}")
image = f"{env.registry}/studio/projects:latest"
step(f"Building projects -> {image} ...")
_build_image(env, image, projects_dir / "Dockerfile", projects_dir, push=push)
if deploy:
_deploy_rollout(env, ["projects"], "lasuite", timeout="180s",
images=[image])
def _build_sol(push: bool = False, deploy: bool = False):
"""Build Sol virtual librarian image from source.
# TODO: first deploy requires registration enabled on tuwunel to create
# the @sol:sunbeam.pt bot account. Flow:
# 1. Set allow_registration = true in tuwunel-config.yaml
# 2. Apply + restart tuwunel
# 3. Register bot via POST /_matrix/client/v3/register with registration token
# 4. Store access_token + device_id in OpenBao at secret/sol
# 5. Set allow_registration = false, re-apply
# 6. Then build + deploy sol
# This should be automated as `sunbeam user create-bot <name>`.
"""
env = _get_build_env()
sol_dir = _get_repo_root() / "sol"
if not sol_dir.is_dir():
die(f"Sol source not found at {sol_dir}")
image = f"{env.registry}/studio/sol:latest"
step(f"Building sol -> {image} ...")
_build_image(env, image, sol_dir / "Dockerfile", sol_dir, push=push)
if deploy:
_deploy_rollout(env, ["sol"], "matrix", timeout="120s")
def _build_calendars(push: bool = False, deploy: bool = False):
env = _get_build_env()
cal_dir = _get_repo_root() / "calendars"
if not cal_dir.is_dir():
die(f"calendars source not found at {cal_dir}")
backend_dir = cal_dir / "src" / "backend"
backend_image = f"{env.registry}/studio/calendars-backend:latest"
step(f"Building calendars-backend -> {backend_image} ...")
# Stage translations.json into the build context so the production image
# has it at /data/translations.json (Docker Compose mounts it; we bake it in).
translations_src = (cal_dir / "src" / "frontend" / "apps" / "calendars"
/ "src" / "features" / "i18n" / "translations.json")
translations_dst = backend_dir / "_translations.json"
cleanup: list[Path] = []
dockerfile = backend_dir / "Dockerfile"
if translations_src.exists():
shutil.copy(str(translations_src), str(translations_dst))
cleanup.append(translations_dst)
# Patch Dockerfile to COPY translations into production image
patched = dockerfile.read_text() + (
"\n# Sunbeam: bake translations.json for default calendar names\n"
"COPY _translations.json /data/translations.json\n"
)
patched_df = backend_dir / "Dockerfile._sunbeam_patched"
patched_df.write_text(patched)
cleanup.append(patched_df)
dockerfile = patched_df
_build_image(env, backend_image,
dockerfile,
backend_dir,
target="backend-production",
push=push,
cleanup_paths=cleanup)
caldav_image = f"{env.registry}/studio/calendars-caldav:latest"
step(f"Building calendars-caldav -> {caldav_image} ...")
_build_image(env, caldav_image,
cal_dir / "src" / "caldav" / "Dockerfile",
cal_dir / "src" / "caldav",
push=push)
frontend_image = f"{env.registry}/studio/calendars-frontend:latest"
step(f"Building calendars-frontend -> {frontend_image} ...")
integration_base = f"https://integration.{env.domain}"
_build_image(env, frontend_image,
cal_dir / "src" / "frontend" / "Dockerfile",
cal_dir / "src" / "frontend",
target="frontend-production",
build_args={
"VISIO_BASE_URL": f"https://meet.{env.domain}",
"GAUFRE_WIDGET_PATH": f"{integration_base}/api/v2/lagaufre.js",
"GAUFRE_API_URL": f"{integration_base}/api/v2/services.json",
"THEME_CSS_URL": f"{integration_base}/api/v2/theme.css",
},
push=push)
if deploy:
_deploy_rollout(env,
["calendars-backend", "calendars-worker",
"calendars-caldav", "calendars-frontend"],
"lasuite", timeout="180s",
images=[backend_image, caldav_image, frontend_image])

View File

@@ -227,6 +227,7 @@ def cmd_bao(bao_args: list[str]) -> int:
def kustomize_build(overlay: Path, domain: str, email: str = "") -> str: def kustomize_build(overlay: Path, domain: str, email: str = "") -> str:
"""Run kustomize build --enable-helm and apply domain/email substitution.""" """Run kustomize build --enable-helm and apply domain/email substitution."""
import socket as _socket
r = run_tool( r = run_tool(
"kustomize", "build", "--enable-helm", str(overlay), "kustomize", "build", "--enable-helm", str(overlay),
capture_output=True, text=True, check=True, capture_output=True, text=True, check=True,
@@ -235,5 +236,22 @@ def kustomize_build(overlay: Path, domain: str, email: str = "") -> str:
text = domain_replace(text, domain) text = domain_replace(text, domain)
if email: if email:
text = text.replace("ACME_EMAIL", email) text = text.replace("ACME_EMAIL", email)
if "REGISTRY_HOST_IP" in text:
registry_ip = ""
try:
registry_ip = _socket.gethostbyname(f"src.{domain}")
except _socket.gaierror:
pass
if not registry_ip:
# DNS not resolvable locally (VPN, split-horizon, etc.) — derive IP from SSH host config
from sunbeam.config import get_production_host as _get_host
ssh_host = _get_host()
# ssh_host may be "user@host" or just "host"
raw = ssh_host.split("@")[-1].split(":")[0]
try:
registry_ip = _socket.gethostbyname(raw)
except _socket.gaierror:
registry_ip = raw # raw is already an IP in typical config
text = text.replace("REGISTRY_HOST_IP", registry_ip)
text = text.replace("\n annotations: null", "") text = text.replace("\n annotations: null", "")
return text return text

View File

@@ -5,9 +5,10 @@ from pathlib import Path
from sunbeam.kube import kube, kube_out, kube_ok, kube_apply, kustomize_build, get_lima_ip, get_domain from sunbeam.kube import kube, kube_out, kube_ok, kube_apply, kustomize_build, get_lima_ip, get_domain
from sunbeam.output import step, ok, warn from sunbeam.output import step, ok, warn
REPO_ROOT = Path(__file__).parents[2] / "infrastructure" from sunbeam.config import get_infra_dir as _get_infra_dir
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "media", "monitoring", "ory", REPO_ROOT = _get_infra_dir()
"storage", "vault-secrets-operator"] MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "monitoring",
"ory", "storage", "vault-secrets-operator"]
def pre_apply_cleanup(namespaces=None): def pre_apply_cleanup(namespaces=None):
@@ -156,6 +157,219 @@ def _filter_by_namespace(manifests: str, namespace: str) -> str:
return "---\n" + "\n---\n".join(kept) + "\n" return "---\n" + "\n---\n".join(kept) + "\n"
def _patch_tuwunel_oauth2_redirect(domain: str):
"""Patch the tuwunel OAuth2Client redirect URI with the actual client_id.
Hydra-maester generates the client_id when it first reconciles the
OAuth2Client CRD, storing it in the oidc-tuwunel Secret. We read that
secret and patch the CRD's redirectUris to include the correct callback
path that tuwunel will use.
"""
import base64, json
client_id_b64 = kube_out("get", "secret", "oidc-tuwunel", "-n", "matrix",
"-o=jsonpath={.data.CLIENT_ID}", "--ignore-not-found")
if not client_id_b64:
warn("oidc-tuwunel secret not yet available — skipping redirect URI patch. "
"Re-run 'sunbeam apply matrix' after hydra-maester has reconciled.")
return
client_id = base64.b64decode(client_id_b64).decode()
redirect_uri = f"https://messages.{domain}/_matrix/client/unstable/login/sso/callback/{client_id}"
# Check current redirect URIs to avoid unnecessary patches.
current = kube_out("get", "oauth2client", "tuwunel", "-n", "matrix",
"-o=jsonpath={.spec.redirectUris[*]}", "--ignore-not-found")
if redirect_uri in current.split():
return
patch = json.dumps({"spec": {"redirectUris": [redirect_uri]}})
kube("patch", "oauth2client", "tuwunel", "-n", "matrix",
"--type=merge", f"-p={patch}", check=False)
ok(f"Patched tuwunel OAuth2Client redirect URI.")
def _os_api(path: str, method: str = "GET", data: str | None = None) -> str:
"""Call OpenSearch API via kubectl exec. Returns response body."""
cmd = ["exec", "deploy/opensearch", "-n", "data", "-c", "opensearch", "--"]
curl = ["curl", "-sf", f"http://localhost:9200{path}"]
if method != "GET":
curl += ["-X", method]
if data is not None:
curl += ["-H", "Content-Type: application/json", "-d", data]
return kube_out(*cmd, *curl)
def _ensure_opensearch_ml():
"""Idempotently configure OpenSearch ML Commons for neural search.
1. Sets cluster settings to allow ML on data nodes.
2. Registers and deploys all-mpnet-base-v2 (pre-trained, 384-dim).
3. Creates ingest + search pipelines for hybrid BM25+neural scoring.
"""
import json, time
# Check OpenSearch is reachable.
if not _os_api("/_cluster/health"):
warn("OpenSearch not reachable — skipping ML setup.")
return
# 1. Ensure ML Commons cluster settings (idempotent PUT).
_os_api("/_cluster/settings", "PUT", json.dumps({"persistent": {
"plugins.ml_commons.only_run_on_ml_node": False,
"plugins.ml_commons.native_memory_threshold": 90,
"plugins.ml_commons.model_access_control_enabled": False,
"plugins.ml_commons.allow_registering_model_via_url": True,
}}))
# 2. Check if model already registered and deployed.
search_resp = _os_api("/_plugins/_ml/models/_search", "POST",
'{"query":{"match":{"name":"huggingface/sentence-transformers/all-mpnet-base-v2"}}}')
if not search_resp:
warn("OpenSearch ML search API failed — skipping ML setup.")
return
resp = json.loads(search_resp)
hits = resp.get("hits", {}).get("hits", [])
model_id = None
for hit in hits:
state = hit.get("_source", {}).get("model_state", "")
if state == "DEPLOYED":
model_id = hit["_id"]
break
elif state in ("REGISTERED", "DEPLOYING"):
model_id = hit["_id"]
if model_id and any(h["_source"].get("model_state") == "DEPLOYED" for h in hits):
pass # Already deployed, skip to pipelines.
elif model_id:
# Registered but not deployed — deploy it.
ok("Deploying OpenSearch ML model...")
_os_api(f"/_plugins/_ml/models/{model_id}/_deploy", "POST")
for _ in range(30):
time.sleep(5)
r = _os_api(f"/_plugins/_ml/models/{model_id}")
if r and '"DEPLOYED"' in r:
break
else:
# Register from pre-trained hub.
ok("Registering OpenSearch ML model (all-mpnet-base-v2)...")
reg_resp = _os_api("/_plugins/_ml/models/_register", "POST", json.dumps({
"name": "huggingface/sentence-transformers/all-mpnet-base-v2",
"version": "1.0.1",
"model_format": "TORCH_SCRIPT",
}))
if not reg_resp:
warn("Failed to register ML model — skipping.")
return
task_id = json.loads(reg_resp).get("task_id", "")
if not task_id:
warn("No task_id from model registration — skipping.")
return
# Wait for registration.
ok("Waiting for model registration...")
for _ in range(60):
time.sleep(10)
task_resp = _os_api(f"/_plugins/_ml/tasks/{task_id}")
if not task_resp:
continue
task = json.loads(task_resp)
state = task.get("state", "")
if state == "COMPLETED":
model_id = task.get("model_id", "")
break
if state == "FAILED":
warn(f"ML model registration failed: {task_resp}")
return
if not model_id:
warn("ML model registration timed out.")
return
# Deploy.
ok("Deploying ML model...")
_os_api(f"/_plugins/_ml/models/{model_id}/_deploy", "POST")
for _ in range(30):
time.sleep(5)
r = _os_api(f"/_plugins/_ml/models/{model_id}")
if r and '"DEPLOYED"' in r:
break
if not model_id:
warn("No ML model available — skipping pipeline setup.")
return
# 3. Create/update ingest pipeline (PUT is idempotent).
_os_api("/_ingest/pipeline/tuwunel_embedding_pipeline", "PUT", json.dumps({
"description": "Tuwunel message embedding pipeline",
"processors": [{"text_embedding": {
"model_id": model_id,
"field_map": {"body": "embedding"},
}}],
}))
# 4. Create/update search pipeline (PUT is idempotent).
_os_api("/_search/pipeline/tuwunel_hybrid_pipeline", "PUT", json.dumps({
"description": "Tuwunel hybrid BM25+neural search pipeline",
"phase_results_processors": [{"normalization-processor": {
"normalization": {"technique": "min_max"},
"combination": {"technique": "arithmetic_mean", "parameters": {"weights": [0.3, 0.7]}},
}}],
}))
ok(f"OpenSearch ML ready (model: {model_id}).")
return model_id
def _inject_opensearch_model_id():
"""Read deployed ML model_id from OpenSearch, write to ConfigMap in matrix ns.
The tuwunel deployment reads TUWUNEL_SEARCH_OPENSEARCH_MODEL_ID from this
ConfigMap. Creates or updates the ConfigMap idempotently.
Reads the model_id from the ingest pipeline (which _ensure_opensearch_ml
already configured with the correct model_id).
"""
import json
# Read model_id from the ingest pipeline that _ensure_opensearch_ml created.
pipe_resp = _os_api("/_ingest/pipeline/tuwunel_embedding_pipeline")
if not pipe_resp:
warn("OpenSearch ingest pipeline not found — skipping model_id injection. "
"Run 'sunbeam apply data' first.")
return
pipe = json.loads(pipe_resp)
processors = (pipe.get("tuwunel_embedding_pipeline", {})
.get("processors", []))
model_id = None
for proc in processors:
model_id = proc.get("text_embedding", {}).get("model_id")
if model_id:
break
if not model_id:
warn("No model_id in ingest pipeline — tuwunel hybrid search will be unavailable.")
return
# Check if ConfigMap already has this value.
current = kube_out("get", "configmap", "opensearch-ml-config", "-n", "matrix",
"-o=jsonpath={.data.model_id}", "--ignore-not-found")
if current == model_id:
return
cm = json.dumps({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": "opensearch-ml-config", "namespace": "matrix"},
"data": {"model_id": model_id},
})
kube("apply", "--server-side", "-f", "-", input=cm)
ok(f"Injected OpenSearch model_id ({model_id}) into matrix/opensearch-ml-config.")
def cmd_apply(env: str = "local", domain: str = "", email: str = "", namespace: str = ""): def cmd_apply(env: str = "local", domain: str = "", email: str = "", namespace: str = ""):
"""Build kustomize overlay for env, substitute domain/email, kubectl apply. """Build kustomize overlay for env, substitute domain/email, kubectl apply.
@@ -163,6 +377,11 @@ def cmd_apply(env: str = "local", domain: str = "", email: str = "", namespace:
cert-manager registers a ValidatingWebhook that must be running before cert-manager registers a ValidatingWebhook that must be running before
ClusterIssuer / Certificate resources can be created. ClusterIssuer / Certificate resources can be created.
""" """
# Fall back to config for ACME email if not provided via CLI flag.
if not email:
from sunbeam.config import load_config
email = load_config().acme_email
if env == "production": if env == "production":
if not domain: if not domain:
# Try to discover domain from running cluster # Try to discover domain from running cluster
@@ -207,4 +426,12 @@ def cmd_apply(env: str = "local", domain: str = "", email: str = "", namespace:
kube("apply", "--server-side", "--force-conflicts", "-f", "-", input=manifests2) kube("apply", "--server-side", "--force-conflicts", "-f", "-", input=manifests2)
_restart_for_changed_configmaps(before, _snapshot_configmaps()) _restart_for_changed_configmaps(before, _snapshot_configmaps())
# Post-apply hooks for namespaces that need runtime patching.
if not namespace or namespace == "matrix":
_patch_tuwunel_oauth2_redirect(domain)
_inject_opensearch_model_id()
if not namespace or namespace == "data":
_ensure_opensearch_ml()
ok("Applied.") ok("Applied.")

View File

@@ -15,6 +15,11 @@ from sunbeam.output import step, ok, warn, die
ADMIN_USERNAME = "estudio-admin" ADMIN_USERNAME = "estudio-admin"
def _gen_fernet_key() -> str:
"""Generate a Fernet-compatible key (32 random bytes, URL-safe base64)."""
return base64.urlsafe_b64encode(_secrets.token_bytes(32)).decode()
def _gen_dkim_key_pair() -> tuple[str, str]: def _gen_dkim_key_pair() -> tuple[str, str]:
"""Generate an RSA 2048-bit DKIM key pair using openssl. """Generate an RSA 2048-bit DKIM key pair using openssl.
@@ -45,7 +50,7 @@ GITEA_ADMIN_USER = "gitea_admin"
PG_USERS = [ PG_USERS = [
"kratos", "hydra", "gitea", "hive", "kratos", "hydra", "gitea", "hive",
"docs", "meet", "drive", "messages", "conversations", "docs", "meet", "drive", "messages", "conversations",
"people", "find", "people", "find", "calendars", "projects",
] ]
@@ -133,6 +138,9 @@ def _seed_openbao() -> dict:
return {} return {}
# Read-or-generate helper: preserves existing KV values; only generates missing ones. # Read-or-generate helper: preserves existing KV values; only generates missing ones.
# Tracks which paths had new values so we only write back when necessary.
_dirty_paths: set = set()
def get_or_create(path, **fields): def get_or_create(path, **fields):
raw = bao( raw = bao(
f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' "
@@ -145,7 +153,12 @@ def _seed_openbao() -> dict:
pass pass
result = {} result = {}
for key, default_fn in fields.items(): for key, default_fn in fields.items():
result[key] = existing.get(key) or default_fn() val = existing.get(key)
if val:
result[key] = val
else:
result[key] = default_fn()
_dirty_paths.add(path)
return result return result
def rand(): def rand():
@@ -193,7 +206,9 @@ def _seed_openbao() -> dict:
kratos_admin = get_or_create("kratos-admin", kratos_admin = get_or_create("kratos-admin",
**{"cookie-secret": rand, **{"cookie-secret": rand,
"csrf-cookie-secret": rand, "csrf-cookie-secret": rand,
"admin-identity-ids": lambda: ""}) "admin-identity-ids": lambda: "",
"s3-access-key": lambda: seaweedfs["access-key"],
"s3-secret-key": lambda: seaweedfs["secret-key"]})
docs = get_or_create("docs", docs = get_or_create("docs",
**{"django-secret-key": rand, **{"django-secret-key": rand,
@@ -206,6 +221,16 @@ def _seed_openbao() -> dict:
drive = get_or_create("drive", drive = get_or_create("drive",
**{"django-secret-key": rand}) **{"django-secret-key": rand})
projects = get_or_create("projects",
**{"secret-key": rand})
calendars = get_or_create("calendars",
**{"django-secret-key": lambda: _secrets.token_urlsafe(50),
"salt-key": rand,
"caldav-inbound-api-key": rand,
"caldav-outbound-api-key": rand,
"caldav-internal-api-key": rand})
# DKIM key pair -- generated together since private and public keys are coupled. # DKIM key pair -- generated together since private and public keys are coupled.
# Read existing keys first; only generate a new pair when absent. # Read existing keys first; only generate a new pair when absent.
existing_messages_raw = bao( existing_messages_raw = bao(
@@ -225,15 +250,16 @@ def _seed_openbao() -> dict:
_dkim_private, _dkim_public = _gen_dkim_key_pair() _dkim_private, _dkim_public = _gen_dkim_key_pair()
messages = get_or_create("messages", messages = get_or_create("messages",
**{"django-secret-key": rand, **{"django-secret-key": rand,
"salt-key": rand, "salt-key": rand,
"mda-api-secret": rand, "mda-api-secret": rand,
"dkim-private-key": lambda: _dkim_private, "oidc-refresh-token-key": _gen_fernet_key,
"dkim-public-key": lambda: _dkim_public, "dkim-private-key": lambda: _dkim_private,
"rspamd-password": rand, "dkim-public-key": lambda: _dkim_public,
"socks-proxy-users": lambda: f"sunbeam:{rand()}", "rspamd-password": rand,
"mta-out-smtp-username": lambda: "sunbeam", "socks-proxy-users": lambda: f"sunbeam:{rand()}",
"mta-out-smtp-password": rand}) "mta-out-smtp-username": lambda: "sunbeam",
"mta-out-smtp-password": rand})
collabora = get_or_create("collabora", collabora = get_or_create("collabora",
**{"username": lambda: "admin", **{"username": lambda: "admin",
@@ -262,48 +288,100 @@ def _seed_openbao() -> dict:
**{"access-key-id": lambda: _scw_config("access-key"), **{"access-key-id": lambda: _scw_config("access-key"),
"secret-access-key": lambda: _scw_config("secret-key")}) "secret-access-key": lambda: _scw_config("secret-key")})
# Write all secrets to KV (idempotent -- puts same values back) # Only write secrets to OpenBao KV for paths that have new/missing values.
# messages secrets written separately first (multi-field KV, avoids line-length issues) # This avoids unnecessary KV version bumps which trigger VSO re-syncs and
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '" # rollout restarts across the cluster.
f"bao kv put secret/messages" if not _dirty_paths:
f" django-secret-key=\"{messages['django-secret-key']}\"" ok("All OpenBao KV secrets already present -- skipping writes.")
f" salt-key=\"{messages['salt-key']}\"" else:
f" mda-api-secret=\"{messages['mda-api-secret']}\"" ok(f"Writing new secrets to OpenBao KV ({', '.join(sorted(_dirty_paths))})...")
f" rspamd-password=\"{messages['rspamd-password']}\""
f" socks-proxy-users=\"{messages['socks-proxy-users']}\""
f" mta-out-smtp-username=\"{messages['mta-out-smtp-username']}\""
f" mta-out-smtp-password=\"{messages['mta-out-smtp-password']}\""
f"'")
# DKIM keys stored separately (large PEM values)
dkim_priv_b64 = base64.b64encode(messages['dkim-private-key'].encode()).decode()
dkim_pub_b64 = base64.b64encode(messages['dkim-public-key'].encode()).decode()
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
f"echo {dkim_priv_b64} | base64 -d > /tmp/dkim_priv.pem && "
f"echo {dkim_pub_b64} | base64 -d > /tmp/dkim_pub.pem && "
f"bao kv patch secret/messages"
f" dkim-private-key=\"$(cat /tmp/dkim_priv.pem)\""
f" dkim-public-key=\"$(cat /tmp/dkim_pub.pem)\" && "
f"rm /tmp/dkim_priv.pem /tmp/dkim_pub.pem"
f"'")
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '" def _kv_put(path, **kv):
f"bao kv put secret/hydra system-secret=\"{hydra['system-secret']}\" cookie-secret=\"{hydra['cookie-secret']}\" pairwise-salt=\"{hydra['pairwise-salt']}\" && " pairs = " ".join(f'{k}="{v}"' for k, v in kv.items())
f"bao kv put secret/kratos secrets-default=\"{kratos['secrets-default']}\" secrets-cookie=\"{kratos['secrets-cookie']}\" smtp-connection-uri=\"{kratos['smtp-connection-uri']}\" && " bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' "
f"bao kv put secret/gitea admin-username=\"{gitea['admin-username']}\" admin-password=\"{gitea['admin-password']}\" && " f"bao kv put secret/{path} {pairs}")
f"bao kv put secret/seaweedfs access-key=\"{seaweedfs['access-key']}\" secret-key=\"{seaweedfs['secret-key']}\" && "
f"bao kv put secret/hive oidc-client-id=\"{hive['oidc-client-id']}\" oidc-client-secret=\"{hive['oidc-client-secret']}\" && " if "messages" in _dirty_paths:
f"bao kv put secret/livekit api-key=\"{livekit['api-key']}\" api-secret=\"{livekit['api-secret']}\" && " _kv_put("messages",
f"bao kv put secret/people django-secret-key=\"{people['django-secret-key']}\" && " **{"django-secret-key": messages["django-secret-key"],
f"bao kv put secret/login-ui cookie-secret=\"{login_ui['cookie-secret']}\" csrf-cookie-secret=\"{login_ui['csrf-cookie-secret']}\" && " "salt-key": messages["salt-key"],
f"bao kv put secret/kratos-admin cookie-secret=\"{kratos_admin['cookie-secret']}\" csrf-cookie-secret=\"{kratos_admin['csrf-cookie-secret']}\" admin-identity-ids=\"{kratos_admin['admin-identity-ids']}\" && " "mda-api-secret": messages["mda-api-secret"],
f"bao kv put secret/docs django-secret-key=\"{docs['django-secret-key']}\" collaboration-secret=\"{docs['collaboration-secret']}\" && " "oidc-refresh-token-key": messages["oidc-refresh-token-key"],
f"bao kv put secret/meet django-secret-key=\"{meet['django-secret-key']}\" application-jwt-secret-key=\"{meet['application-jwt-secret-key']}\" && " "rspamd-password": messages["rspamd-password"],
f"bao kv put secret/drive django-secret-key=\"{drive['django-secret-key']}\" && " "socks-proxy-users": messages["socks-proxy-users"],
f"bao kv put secret/collabora username=\"{collabora['username']}\" password=\"{collabora['password']}\" && " "mta-out-smtp-username": messages["mta-out-smtp-username"],
f"bao kv put secret/grafana admin-password=\"{grafana['admin-password']}\" && " "mta-out-smtp-password": messages["mta-out-smtp-password"]})
f"bao kv put secret/scaleway-s3 access-key-id=\"{scaleway_s3['access-key-id']}\" secret-access-key=\"{scaleway_s3['secret-access-key']}\" && " # DKIM keys stored separately (large PEM values)
f"bao kv put secret/tuwunel oidc-client-id=\"{tuwunel['oidc-client-id']}\" oidc-client-secret=\"{tuwunel['oidc-client-secret']}\" turn-secret=\"{tuwunel['turn-secret']}\" registration-token=\"{tuwunel['registration-token']}\"" dkim_priv_b64 = base64.b64encode(messages['dkim-private-key'].encode()).decode()
f"'") dkim_pub_b64 = base64.b64encode(messages['dkim-public-key'].encode()).decode()
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
f"echo {dkim_priv_b64} | base64 -d > /tmp/dkim_priv.pem && "
f"echo {dkim_pub_b64} | base64 -d > /tmp/dkim_pub.pem && "
f"bao kv patch secret/messages"
f" dkim-private-key=\"$(cat /tmp/dkim_priv.pem)\""
f" dkim-public-key=\"$(cat /tmp/dkim_pub.pem)\" && "
f"rm /tmp/dkim_priv.pem /tmp/dkim_pub.pem"
f"'")
if "hydra" in _dirty_paths:
_kv_put("hydra", **{"system-secret": hydra["system-secret"],
"cookie-secret": hydra["cookie-secret"],
"pairwise-salt": hydra["pairwise-salt"]})
if "kratos" in _dirty_paths:
_kv_put("kratos", **{"secrets-default": kratos["secrets-default"],
"secrets-cookie": kratos["secrets-cookie"],
"smtp-connection-uri": kratos["smtp-connection-uri"]})
if "gitea" in _dirty_paths:
_kv_put("gitea", **{"admin-username": gitea["admin-username"],
"admin-password": gitea["admin-password"]})
if "seaweedfs" in _dirty_paths:
_kv_put("seaweedfs", **{"access-key": seaweedfs["access-key"],
"secret-key": seaweedfs["secret-key"]})
if "hive" in _dirty_paths:
_kv_put("hive", **{"oidc-client-id": hive["oidc-client-id"],
"oidc-client-secret": hive["oidc-client-secret"]})
if "livekit" in _dirty_paths:
_kv_put("livekit", **{"api-key": livekit["api-key"],
"api-secret": livekit["api-secret"]})
if "people" in _dirty_paths:
_kv_put("people", **{"django-secret-key": people["django-secret-key"]})
if "login-ui" in _dirty_paths:
_kv_put("login-ui", **{"cookie-secret": login_ui["cookie-secret"],
"csrf-cookie-secret": login_ui["csrf-cookie-secret"]})
if "kratos-admin" in _dirty_paths:
_kv_put("kratos-admin", **{"cookie-secret": kratos_admin["cookie-secret"],
"csrf-cookie-secret": kratos_admin["csrf-cookie-secret"],
"admin-identity-ids": kratos_admin["admin-identity-ids"],
"s3-access-key": kratos_admin["s3-access-key"],
"s3-secret-key": kratos_admin["s3-secret-key"]})
if "docs" in _dirty_paths:
_kv_put("docs", **{"django-secret-key": docs["django-secret-key"],
"collaboration-secret": docs["collaboration-secret"]})
if "meet" in _dirty_paths:
_kv_put("meet", **{"django-secret-key": meet["django-secret-key"],
"application-jwt-secret-key": meet["application-jwt-secret-key"]})
if "drive" in _dirty_paths:
_kv_put("drive", **{"django-secret-key": drive["django-secret-key"]})
if "projects" in _dirty_paths:
_kv_put("projects", **{"secret-key": projects["secret-key"]})
if "calendars" in _dirty_paths:
_kv_put("calendars", **{"django-secret-key": calendars["django-secret-key"],
"salt-key": calendars["salt-key"],
"caldav-inbound-api-key": calendars["caldav-inbound-api-key"],
"caldav-outbound-api-key": calendars["caldav-outbound-api-key"],
"caldav-internal-api-key": calendars["caldav-internal-api-key"]})
if "collabora" in _dirty_paths:
_kv_put("collabora", **{"username": collabora["username"],
"password": collabora["password"]})
if "grafana" in _dirty_paths:
_kv_put("grafana", **{"admin-password": grafana["admin-password"]})
if "scaleway-s3" in _dirty_paths:
_kv_put("scaleway-s3", **{"access-key-id": scaleway_s3["access-key-id"],
"secret-access-key": scaleway_s3["secret-access-key"]})
if "tuwunel" in _dirty_paths:
_kv_put("tuwunel", **{"oidc-client-id": tuwunel["oidc-client-id"],
"oidc-client-secret": tuwunel["oidc-client-secret"],
"turn-secret": tuwunel["turn-secret"],
"registration-token": tuwunel["registration-token"]})
# Configure Kubernetes auth method so VSO can authenticate with OpenBao # Configure Kubernetes auth method so VSO can authenticate with OpenBao
ok("Configuring Kubernetes auth for VSO...") ok("Configuring Kubernetes auth for VSO...")
@@ -519,7 +597,7 @@ def _seed_kratos_admin_identity(ob_pod: str, root_token: str) -> tuple[str, str]
ok(f" admin identity exists ({identity_id[:8]}...)") ok(f" admin identity exists ({identity_id[:8]}...)")
else: else:
identity = _kratos_api(base, "/identities", method="POST", body={ identity = _kratos_api(base, "/identities", method="POST", body={
"schema_id": "default", "schema_id": "employee",
"traits": {"email": admin_email}, "traits": {"email": admin_email},
"state": "active", "state": "active",
}) })
@@ -600,6 +678,7 @@ def cmd_seed() -> dict:
"drive": "drive_db", "messages": "messages_db", "drive": "drive_db", "messages": "messages_db",
"conversations": "conversations_db", "conversations": "conversations_db",
"people": "people_db", "find": "find_db", "people": "people_db", "find": "find_db",
"calendars": "calendars_db", "projects": "projects_db",
} }
for user in PG_USERS: for user in PG_USERS:
# Only CREATE if missing -- passwords are managed by OpenBao static roles. # Only CREATE if missing -- passwords are managed by OpenBao static roles.

View File

@@ -8,8 +8,8 @@ from sunbeam.kube import kube, kube_out, parse_target
from sunbeam.tools import ensure_tool from sunbeam.tools import ensure_tool
from sunbeam.output import step, ok, warn, die from sunbeam.output import step, ok, warn, die
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "media", "ory", "storage", MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "ory",
"vault-secrets-operator"] "storage", "vault-secrets-operator"]
SERVICES_TO_RESTART = [ SERVICES_TO_RESTART = [
("ory", "hydra"), ("ory", "hydra"),
@@ -22,6 +22,8 @@ SERVICES_TO_RESTART = [
("lasuite", "people-frontend"), ("lasuite", "people-frontend"),
("lasuite", "people-celery-worker"), ("lasuite", "people-celery-worker"),
("lasuite", "people-celery-beat"), ("lasuite", "people-celery-beat"),
("lasuite", "projects"),
("matrix", "tuwunel"),
("media", "livekit-server"), ("media", "livekit-server"),
] ]
@@ -186,8 +188,9 @@ def cmd_logs(target: str, follow: bool):
if not name: if not name:
die("Logs require a service name, e.g. 'ory/kratos'.") die("Logs require a service name, e.g. 'ory/kratos'.")
_kube_mod.ensure_tunnel()
kubectl = str(ensure_tool("kubectl")) kubectl = str(ensure_tool("kubectl"))
cmd = [kubectl, "--context=sunbeam", "-n", ns, "logs", cmd = [kubectl, _kube_mod.context_arg(), "-n", ns, "logs",
"-l", f"app={name}", "--tail=100"] "-l", f"app={name}", "--tail=100"]
if follow: if follow:
cmd.append("--follow") cmd.append("--follow")

1050
sunbeam/src/cli.rs Normal file

File diff suppressed because it is too large Load Diff

39
sunbeam/src/main.rs Normal file
View File

@@ -0,0 +1,39 @@
mod cli;
#[tokio::main]
async fn main() {
// Install rustls crypto provider (ring) before any TLS operations.
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
// Initialize tracing subscriber.
// Respects RUST_LOG env var (e.g. RUST_LOG=debug, RUST_LOG=sunbeam=trace).
// Default: warn for dependencies, info for sunbeam + sunbeam_sdk.
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("sunbeam=info,sunbeam_sdk=info,warn")
}),
)
.with_target(false)
.with_writer(std::io::stderr)
.init();
match cli::dispatch().await {
Ok(()) => {}
Err(e) => {
let code = e.exit_code();
tracing::error!("{e}");
// Print source chain for non-trivial errors
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
tracing::debug!("caused by: {cause}");
source = std::error::Error::source(cause);
}
std::process::exit(code);
}
}
}

View File

@@ -63,7 +63,20 @@ class TestArgParsing(unittest.TestCase):
p_user_set_pw = user_sub.add_parser("set-password") p_user_set_pw = user_sub.add_parser("set-password")
p_user_set_pw.add_argument("target") p_user_set_pw.add_argument("target")
p_user_set_pw.add_argument("password") p_user_set_pw.add_argument("password")
p_user_onboard = user_sub.add_parser("onboard")
p_user_onboard.add_argument("email")
p_user_onboard.add_argument("--name", default="")
p_user_onboard.add_argument("--schema", default="employee")
p_user_onboard.add_argument("--no-email", action="store_true")
p_user_onboard.add_argument("--notify", default="")
p_user_onboard.add_argument("--job-title", default="")
p_user_onboard.add_argument("--department", default="")
p_user_onboard.add_argument("--office-location", default="")
p_user_onboard.add_argument("--hire-date", default="")
p_user_onboard.add_argument("--manager", default="")
p_user_offboard = user_sub.add_parser("offboard")
p_user_offboard.add_argument("target")
# Add config subcommand for testing # Add config subcommand for testing
p_config = sub.add_parser("config") p_config = sub.add_parser("config")
config_sub = p_config.add_subparsers(dest="config_action") config_sub = p_config.add_subparsers(dest="config_action")
@@ -155,6 +168,42 @@ class TestArgParsing(unittest.TestCase):
self.assertEqual(args.email, "x@example.com") self.assertEqual(args.email, "x@example.com")
self.assertEqual(args.name, "X Y") self.assertEqual(args.name, "X Y")
def test_user_onboard_basic(self):
args = self._parse(["user", "onboard", "a@b.com"])
self.assertEqual(args.user_action, "onboard")
self.assertEqual(args.email, "a@b.com")
self.assertEqual(args.name, "")
self.assertEqual(args.schema, "employee")
self.assertFalse(args.no_email)
self.assertEqual(args.notify, "")
def test_user_onboard_full(self):
args = self._parse(["user", "onboard", "a@b.com", "--name", "A B", "--schema", "default",
"--no-email", "--job-title", "Engineer", "--department", "Dev",
"--office-location", "Paris", "--hire-date", "2026-01-15",
"--manager", "boss@b.com"])
self.assertEqual(args.user_action, "onboard")
self.assertEqual(args.email, "a@b.com")
self.assertEqual(args.name, "A B")
self.assertEqual(args.schema, "default")
self.assertTrue(args.no_email)
self.assertEqual(args.job_title, "Engineer")
self.assertEqual(args.department, "Dev")
self.assertEqual(args.office_location, "Paris")
self.assertEqual(args.hire_date, "2026-01-15")
self.assertEqual(args.manager, "boss@b.com")
def test_user_onboard_notify(self):
args = self._parse(["user", "onboard", "a@work.com", "--notify", "a@personal.com"])
self.assertEqual(args.email, "a@work.com")
self.assertEqual(args.notify, "a@personal.com")
self.assertFalse(args.no_email)
def test_user_offboard(self):
args = self._parse(["user", "offboard", "a@b.com"])
self.assertEqual(args.user_action, "offboard")
self.assertEqual(args.target, "a@b.com")
def test_get_with_target(self): def test_get_with_target(self):
args = self._parse(["get", "ory/kratos-abc"]) args = self._parse(["get", "ory/kratos-abc"])
self.assertEqual(args.verb, "get") self.assertEqual(args.verb, "get")
@@ -259,6 +308,16 @@ class TestArgParsing(unittest.TestCase):
class TestCliDispatch(unittest.TestCase): class TestCliDispatch(unittest.TestCase):
"""Test that main() dispatches to the correct command function.""" """Test that main() dispatches to the correct command function."""
@staticmethod
def _mock_users(**overrides):
defaults = {f: MagicMock() for f in [
"cmd_user_list", "cmd_user_get", "cmd_user_create", "cmd_user_delete",
"cmd_user_recover", "cmd_user_disable", "cmd_user_enable",
"cmd_user_set_password", "cmd_user_onboard", "cmd_user_offboard",
]}
defaults.update(overrides)
return MagicMock(**defaults)
def test_no_verb_exits_0(self): def test_no_verb_exits_0(self):
with patch.object(sys, "argv", ["sunbeam"]): with patch.object(sys, "argv", ["sunbeam"]):
from sunbeam import cli from sunbeam import cli
@@ -356,7 +415,7 @@ class TestCliDispatch(unittest.TestCase):
cli_mod.main() cli_mod.main()
except SystemExit: except SystemExit:
pass pass
mock_build.assert_called_once_with("proxy", push=False, deploy=False) mock_build.assert_called_once_with("proxy", push=False, deploy=False, no_cache=False)
def test_build_with_push_flag(self): def test_build_with_push_flag(self):
mock_build = MagicMock() mock_build = MagicMock()
@@ -368,7 +427,7 @@ class TestCliDispatch(unittest.TestCase):
cli_mod.main() cli_mod.main()
except SystemExit: except SystemExit:
pass pass
mock_build.assert_called_once_with("integration", push=True, deploy=False) mock_build.assert_called_once_with("integration", push=True, deploy=False, no_cache=False)
def test_build_with_deploy_flag_implies_push(self): def test_build_with_deploy_flag_implies_push(self):
mock_build = MagicMock() mock_build = MagicMock()
@@ -380,16 +439,11 @@ class TestCliDispatch(unittest.TestCase):
cli_mod.main() cli_mod.main()
except SystemExit: except SystemExit:
pass pass
mock_build.assert_called_once_with("proxy", push=True, deploy=True) mock_build.assert_called_once_with("proxy", push=True, deploy=True, no_cache=False)
def test_user_set_password_dispatches(self): def test_user_set_password_dispatches(self):
mock_set_pw = MagicMock() mock_set_pw = MagicMock()
mock_users = MagicMock( mock_users = self._mock_users(cmd_user_set_password=mock_set_pw)
cmd_user_list=MagicMock(), cmd_user_get=MagicMock(),
cmd_user_create=MagicMock(), cmd_user_delete=MagicMock(),
cmd_user_recover=MagicMock(), cmd_user_disable=MagicMock(),
cmd_user_enable=MagicMock(), cmd_user_set_password=mock_set_pw,
)
with patch.object(sys, "argv", ["sunbeam", "user", "set-password", with patch.object(sys, "argv", ["sunbeam", "user", "set-password",
"admin@sunbeam.pt", "s3cr3t"]): "admin@sunbeam.pt", "s3cr3t"]):
with patch.dict("sys.modules", {"sunbeam.users": mock_users}): with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
@@ -403,12 +457,7 @@ class TestCliDispatch(unittest.TestCase):
def test_user_disable_dispatches(self): def test_user_disable_dispatches(self):
mock_disable = MagicMock() mock_disable = MagicMock()
mock_users = MagicMock( mock_users = self._mock_users(cmd_user_disable=mock_disable)
cmd_user_list=MagicMock(), cmd_user_get=MagicMock(),
cmd_user_create=MagicMock(), cmd_user_delete=MagicMock(),
cmd_user_recover=MagicMock(), cmd_user_disable=mock_disable,
cmd_user_enable=MagicMock(), cmd_user_set_password=MagicMock(),
)
with patch.object(sys, "argv", ["sunbeam", "user", "disable", "x@sunbeam.pt"]): with patch.object(sys, "argv", ["sunbeam", "user", "disable", "x@sunbeam.pt"]):
with patch.dict("sys.modules", {"sunbeam.users": mock_users}): with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
import importlib, sunbeam.cli as cli_mod import importlib, sunbeam.cli as cli_mod
@@ -421,12 +470,7 @@ class TestCliDispatch(unittest.TestCase):
def test_user_enable_dispatches(self): def test_user_enable_dispatches(self):
mock_enable = MagicMock() mock_enable = MagicMock()
mock_users = MagicMock( mock_users = self._mock_users(cmd_user_enable=mock_enable)
cmd_user_list=MagicMock(), cmd_user_get=MagicMock(),
cmd_user_create=MagicMock(), cmd_user_delete=MagicMock(),
cmd_user_recover=MagicMock(), cmd_user_disable=MagicMock(),
cmd_user_enable=mock_enable, cmd_user_set_password=MagicMock(),
)
with patch.object(sys, "argv", ["sunbeam", "user", "enable", "x@sunbeam.pt"]): with patch.object(sys, "argv", ["sunbeam", "user", "enable", "x@sunbeam.pt"]):
with patch.dict("sys.modules", {"sunbeam.users": mock_users}): with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
import importlib, sunbeam.cli as cli_mod import importlib, sunbeam.cli as cli_mod
@@ -471,7 +515,7 @@ class TestCliDispatch(unittest.TestCase):
cli_mod.main() cli_mod.main()
except SystemExit: except SystemExit:
pass pass
mock_build.assert_called_once_with("people", push=False, deploy=False) mock_build.assert_called_once_with("people", push=False, deploy=False, no_cache=False)
def test_build_people_push_dispatches(self): def test_build_people_push_dispatches(self):
mock_build = MagicMock() mock_build = MagicMock()
@@ -483,7 +527,7 @@ class TestCliDispatch(unittest.TestCase):
cli_mod.main() cli_mod.main()
except SystemExit: except SystemExit:
pass pass
mock_build.assert_called_once_with("people", push=True, deploy=False) mock_build.assert_called_once_with("people", push=True, deploy=False, no_cache=False)
def test_build_people_deploy_implies_push(self): def test_build_people_deploy_implies_push(self):
mock_build = MagicMock() mock_build = MagicMock()
@@ -495,7 +539,7 @@ class TestCliDispatch(unittest.TestCase):
cli_mod.main() cli_mod.main()
except SystemExit: except SystemExit:
pass pass
mock_build.assert_called_once_with("people", push=True, deploy=True) mock_build.assert_called_once_with("people", push=True, deploy=True, no_cache=False)
def test_build_meet_dispatches(self): def test_build_meet_dispatches(self):
mock_build = MagicMock() mock_build = MagicMock()
@@ -507,7 +551,7 @@ class TestCliDispatch(unittest.TestCase):
cli_mod.main() cli_mod.main()
except SystemExit: except SystemExit:
pass pass
mock_build.assert_called_once_with("meet", push=False, deploy=False) mock_build.assert_called_once_with("meet", push=False, deploy=False, no_cache=False)
def test_check_no_target(self): def test_check_no_target(self):
mock_check = MagicMock() mock_check = MagicMock()
@@ -534,6 +578,56 @@ class TestCliDispatch(unittest.TestCase):
mock_check.assert_called_once_with("lasuite/people") mock_check.assert_called_once_with("lasuite/people")
def test_user_onboard_dispatches(self):
mock_onboard = MagicMock()
mock_users = self._mock_users(cmd_user_onboard=mock_onboard)
with patch.object(sys, "argv", ["sunbeam", "user", "onboard",
"new@sunbeam.pt", "--name", "New User"]):
with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
import importlib, sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_onboard.assert_called_once_with("new@sunbeam.pt", name="New User",
schema_id="employee", send_email=True,
notify="", job_title="", department="",
office_location="", hire_date="",
manager="")
def test_user_onboard_no_email_dispatches(self):
mock_onboard = MagicMock()
mock_users = self._mock_users(cmd_user_onboard=mock_onboard)
with patch.object(sys, "argv", ["sunbeam", "user", "onboard",
"new@sunbeam.pt", "--no-email"]):
with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
import importlib, sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_onboard.assert_called_once_with("new@sunbeam.pt", name="",
schema_id="employee", send_email=False,
notify="", job_title="", department="",
office_location="", hire_date="",
manager="")
def test_user_offboard_dispatches(self):
mock_offboard = MagicMock()
mock_users = self._mock_users(cmd_user_offboard=mock_offboard)
with patch.object(sys, "argv", ["sunbeam", "user", "offboard", "x@sunbeam.pt"]):
with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
import importlib, sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_offboard.assert_called_once_with("x@sunbeam.pt")
class TestConfigCli(unittest.TestCase): class TestConfigCli(unittest.TestCase):
"""Test config subcommand functionality.""" """Test config subcommand functionality."""
@@ -643,14 +737,18 @@ class TestConfigCli(unittest.TestCase):
def test_config_cli_set_dispatch(self): def test_config_cli_set_dispatch(self):
"""Test that config set CLI dispatches correctly.""" """Test that config set CLI dispatches correctly."""
mock_existing = MagicMock()
mock_existing.production_host = "old@example.com"
mock_existing.infra_directory = "/old/infra"
mock_existing.acme_email = ""
mock_save = MagicMock() mock_save = MagicMock()
mock_config = MagicMock( mock_config = MagicMock(
SunbeamConfig=MagicMock(return_value="mock_config"), load_config=MagicMock(return_value=mock_existing),
save_config=mock_save save_config=mock_save
) )
with patch.object(sys, "argv", ["sunbeam", "config", "set", with patch.object(sys, "argv", ["sunbeam", "config", "set",
"--host", "cli@example.com", "--host", "cli@example.com",
"--infra-dir", "/cli/infra"]): "--infra-dir", "/cli/infra"]):
with patch.dict("sys.modules", {"sunbeam.config": mock_config}): with patch.dict("sys.modules", {"sunbeam.config": mock_config}):
import importlib, sunbeam.cli as cli_mod import importlib, sunbeam.cli as cli_mod
@@ -659,14 +757,12 @@ class TestConfigCli(unittest.TestCase):
cli_mod.main() cli_mod.main()
except SystemExit: except SystemExit:
pass pass
# Verify SunbeamConfig was called with correct args # Verify existing config was loaded and updated
mock_config.SunbeamConfig.assert_called_once_with( self.assertEqual(mock_existing.production_host, "cli@example.com")
production_host="cli@example.com", self.assertEqual(mock_existing.infra_directory, "/cli/infra")
infra_directory="/cli/infra" # Verify save_config was called with the updated config
) mock_save.assert_called_once_with(mock_existing)
# Verify save_config was called
mock_save.assert_called_once_with("mock_config")
def test_config_cli_get_dispatch(self): def test_config_cli_get_dispatch(self):
"""Test that config get CLI dispatches correctly.""" """Test that config get CLI dispatches correctly."""

View File

@@ -1,10 +1,13 @@
"""Binary bundler — downloads kubectl, kustomize, helm at pinned versions. """Binary bundler — downloads kubectl, kustomize, helm, buildctl at pinned versions.
Binaries are cached in ~/.local/share/sunbeam/bin/ and SHA256-verified. Binaries are cached in ~/.local/share/sunbeam/bin/ and SHA256-verified.
Platform (OS + arch) is detected at runtime so the same package works on
darwin/arm64 (development Mac), darwin/amd64, linux/arm64, and linux/amd64.
""" """
import hashlib import hashlib
import io import io
import os import os
import platform
import stat import stat
import subprocess import subprocess
import tarfile import tarfile
@@ -13,26 +16,79 @@ from pathlib import Path
CACHE_DIR = Path.home() / ".local/share/sunbeam/bin" CACHE_DIR = Path.home() / ".local/share/sunbeam/bin"
TOOLS: dict[str, dict] = { # Tool specs — URL and extract templates use {version}, {os}, {arch}.
# {os} : darwin | linux
# {arch} : arm64 | amd64
_TOOL_SPECS: dict[str, dict] = {
"kubectl": { "kubectl": {
"version": "v1.32.2", "version": "v1.32.2",
"url": "https://dl.k8s.io/release/v1.32.2/bin/darwin/arm64/kubectl", "url": "https://dl.k8s.io/release/{version}/bin/{os}/{arch}/kubectl",
"sha256": "", # set to actual hash; empty = skip verify # plain binary, no archive
}, },
"kustomize": { "kustomize": {
"version": "v5.8.1", "version": "v5.8.1",
"url": "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.8.1/kustomize_v5.8.1_darwin_arm64.tar.gz", "url": (
"sha256": "", "https://github.com/kubernetes-sigs/kustomize/releases/download/"
"kustomize%2F{version}/kustomize_{version}_{os}_{arch}.tar.gz"
),
"extract": "kustomize", "extract": "kustomize",
}, },
"helm": { "helm": {
"version": "v4.1.0", "version": "v4.1.0",
"url": "https://get.helm.sh/helm-v4.1.0-darwin-arm64.tar.gz", "url": "https://get.helm.sh/helm-{version}-{os}-{arch}.tar.gz",
"sha256": "82f7065bf4e08d4c8d7881b85c0a080581ef4968a4ae6df4e7b432f8f7a88d0c", "extract": "{os}-{arch}/helm",
"extract": "darwin-arm64/helm", "sha256": {
"darwin_arm64": "82f7065bf4e08d4c8d7881b85c0a080581ef4968a4ae6df4e7b432f8f7a88d0c",
},
},
"buildctl": {
"version": "v0.28.0",
# BuildKit releases: buildkit-v0.28.0.linux.amd64.tar.gz
"url": (
"https://github.com/moby/buildkit/releases/download/{version}/"
"buildkit-{version}.{os}-{arch}.tar.gz"
),
"extract": "bin/buildctl",
}, },
} }
# Expose as TOOLS for callers that do `if "helm" in TOOLS`.
TOOLS = _TOOL_SPECS
def _detect_platform() -> tuple[str, str]:
"""Return (os_name, arch) for the current host."""
sys_os = platform.system().lower()
machine = platform.machine().lower()
os_name = {"darwin": "darwin", "linux": "linux"}.get(sys_os)
if not os_name:
raise RuntimeError(f"Unsupported OS: {sys_os}")
arch = "arm64" if machine in ("arm64", "aarch64") else "amd64"
return os_name, arch
def _resolve_spec(name: str) -> dict:
"""Return a tool spec with {os} / {arch} / {version} substituted.
Uses the module-level TOOLS dict so that tests can patch it.
"""
if name not in TOOLS:
raise ValueError(f"Unknown tool: {name}")
os_name, arch = _detect_platform()
raw = TOOLS[name]
version = raw.get("version", "")
fmt = {"version": version, "os": os_name, "arch": arch}
spec = dict(raw)
spec["version"] = version
spec["url"] = raw["url"].format(**fmt)
if "extract" in raw:
spec["extract"] = raw["extract"].format(**fmt)
# sha256 may be a per-platform dict {"darwin_arm64": "..."} or a plain string.
sha256_val = raw.get("sha256", {})
if isinstance(sha256_val, dict):
spec["sha256"] = sha256_val.get(f"{os_name}_{arch}", "")
return spec
def _sha256(path: Path) -> str: def _sha256(path: Path) -> str:
h = hashlib.sha256() h = hashlib.sha256()
@@ -45,12 +101,10 @@ def _sha256(path: Path) -> str:
def ensure_tool(name: str) -> Path: def ensure_tool(name: str) -> Path:
"""Return path to cached binary, downloading + verifying if needed. """Return path to cached binary, downloading + verifying if needed.
Re-downloads automatically when the pinned version in TOOLS changes. Re-downloads automatically when the pinned version in _TOOL_SPECS changes.
A <name>.version sidecar file records the version of the cached binary. A <name>.version sidecar file records the version of the cached binary.
""" """
if name not in TOOLS: spec = _resolve_spec(name)
raise ValueError(f"Unknown tool: {name}")
spec = TOOLS[name]
CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True)
dest = CACHE_DIR / name dest = CACHE_DIR / name
version_file = CACHE_DIR / f"{name}.version" version_file = CACHE_DIR / f"{name}.version"
@@ -58,7 +112,6 @@ def ensure_tool(name: str) -> Path:
expected_sha = spec.get("sha256", "") expected_sha = spec.get("sha256", "")
expected_version = spec.get("version", "") expected_version = spec.get("version", "")
# Use cached binary if version matches (or no version pinned) and SHA passes
if dest.exists(): if dest.exists():
version_ok = ( version_ok = (
not expected_version not expected_version
@@ -67,18 +120,17 @@ def ensure_tool(name: str) -> Path:
sha_ok = not expected_sha or _sha256(dest) == expected_sha sha_ok = not expected_sha or _sha256(dest) == expected_sha
if version_ok and sha_ok: if version_ok and sha_ok:
return dest return dest
# Version mismatch or SHA mismatch — re-download # Version mismatch or SHA mismatch — re-download
if dest.exists(): if dest.exists():
dest.unlink() dest.unlink()
if version_file.exists(): if version_file.exists():
version_file.unlink() version_file.unlink()
# Download
url = spec["url"] url = spec["url"]
with urllib.request.urlopen(url) as resp: # noqa: S310 with urllib.request.urlopen(url) as resp: # noqa: S310
data = resp.read() data = resp.read()
# Extract from tar.gz if needed
extract_path = spec.get("extract") extract_path = spec.get("extract")
if extract_path: if extract_path:
with tarfile.open(fileobj=io.BytesIO(data)) as tf: with tarfile.open(fileobj=io.BytesIO(data)) as tf:
@@ -88,10 +140,8 @@ def ensure_tool(name: str) -> Path:
else: else:
binary_data = data binary_data = data
# Write to cache
dest.write_bytes(binary_data) dest.write_bytes(binary_data)
# Verify SHA256 (after extraction)
if expected_sha: if expected_sha:
actual = _sha256(dest) actual = _sha256(dest)
if actual != expected_sha: if actual != expected_sha:
@@ -100,9 +150,7 @@ def ensure_tool(name: str) -> Path:
f"SHA256 mismatch for {name}: expected {expected_sha}, got {actual}" f"SHA256 mismatch for {name}: expected {expected_sha}, got {actual}"
) )
# Make executable
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# Record version so future calls skip re-download when version unchanged
version_file.write_text(expected_version) version_file.write_text(expected_version)
return dest return dest
@@ -116,9 +164,8 @@ def run_tool(name: str, *args, **kwargs) -> subprocess.CompletedProcess:
env = kwargs.pop("env", None) env = kwargs.pop("env", None)
if env is None: if env is None:
env = os.environ.copy() env = os.environ.copy()
# kustomize needs helm on PATH for helm chart rendering
if name == "kustomize": if name == "kustomize":
if "helm" in TOOLS: if "helm" in TOOLS:
ensure_tool("helm") # ensure bundled helm is present before kustomize runs ensure_tool("helm")
env["PATH"] = str(CACHE_DIR) + os.pathsep + env.get("PATH", "") env["PATH"] = str(CACHE_DIR) + os.pathsep + env.get("PATH", "")
return subprocess.run([str(bin_path), *args], env=env, **kwargs) return subprocess.run([str(bin_path), *args], env=env, **kwargs)

View File

@@ -1,19 +1,23 @@
"""User management — Kratos identity operations via port-forwarded admin API.""" """User management — Kratos identity operations via port-forwarded admin API."""
import json import json
import smtplib
import subprocess import subprocess
import sys import sys
import time import time
import urllib.request import urllib.request
import urllib.error import urllib.error
from contextlib import contextmanager from contextlib import contextmanager
from email.message import EmailMessage
import sunbeam.kube as _kube_mod import sunbeam.kube as _kube_mod
from sunbeam.output import step, ok, warn, die, table from sunbeam.output import step, ok, warn, die, table
_SMTP_LOCAL_PORT = 10025
@contextmanager @contextmanager
def _port_forward(ns="ory", svc="kratos-admin", local_port=4434, remote_port=80): def _port_forward(ns="ory", svc="kratos-admin", local_port=4434, remote_port=80):
"""Port-forward directly to the Kratos admin HTTP API and yield the local URL.""" """Port-forward to a cluster service and yield the local base URL."""
proc = subprocess.Popen( proc = subprocess.Popen(
["kubectl", _kube_mod.context_arg(), "-n", ns, "port-forward", ["kubectl", _kube_mod.context_arg(), "-n", ns, "port-forward",
f"svc/{svc}", f"{local_port}:{remote_port}"], f"svc/{svc}", f"{local_port}:{remote_port}"],
@@ -28,23 +32,25 @@ def _port_forward(ns="ory", svc="kratos-admin", local_port=4434, remote_port=80)
proc.wait() proc.wait()
def _api(base_url, path, method="GET", body=None): def _api(base_url, path, method="GET", body=None, prefix="/admin", ok_statuses=()):
"""Make a request to the Kratos admin API via port-forward.""" """Make a request to an admin API via port-forward."""
url = f"{base_url}/admin{path}" url = f"{base_url}{prefix}{path}"
data = json.dumps(body).encode() if body is not None else None data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json", "Accept": "application/json"} headers = {"Content-Type": "application/json", "Accept": "application/json"}
req = urllib.request.Request(url, data=data, headers=headers, method=method) req = urllib.request.Request(url, data=data, headers=headers, method=method)
try: try:
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
body = resp.read() resp_body = resp.read()
return json.loads(body) if body else None return json.loads(resp_body) if resp_body else None
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
body_text = e.read().decode() if e.code in ok_statuses:
die(f"API error {e.code}: {body_text}") return None
err_text = e.read().decode()
die(f"API error {e.code}: {err_text}")
def _find_identity(base_url, target): def _find_identity(base_url, target, required=True):
"""Find identity by email or ID. Returns identity dict.""" """Find identity by email or ID. Returns identity dict or None if not required."""
# Try as ID first # Try as ID first
if len(target) == 36 and target.count("-") == 4: if len(target) == 36 and target.count("-") == 4:
return _api(base_url, f"/identities/{target}") return _api(base_url, f"/identities/{target}")
@@ -52,7 +58,31 @@ def _find_identity(base_url, target):
result = _api(base_url, f"/identities?credentials_identifier={target}&page_size=1") result = _api(base_url, f"/identities?credentials_identifier={target}&page_size=1")
if isinstance(result, list) and result: if isinstance(result, list) and result:
return result[0] return result[0]
die(f"Identity not found: {target}") if required:
die(f"Identity not found: {target}")
return None
def _identity_put_body(identity, state=None, **extra):
"""Build the PUT body for updating an identity, preserving all required fields."""
body = {
"schema_id": identity["schema_id"],
"traits": identity["traits"],
"state": state or identity.get("state", "active"),
"metadata_public": identity.get("metadata_public"),
"metadata_admin": identity.get("metadata_admin"),
}
body.update(extra)
return body
def _generate_recovery(base_url, identity_id):
"""Generate a 24h recovery code. Returns (link, code)."""
recovery = _api(base_url, "/recovery/code", method="POST", body={
"identity_id": identity_id,
"expires_in": "24h",
})
return recovery.get("recovery_link", ""), recovery.get("recovery_code", "")
def cmd_user_list(search=""): def cmd_user_list(search=""):
@@ -67,11 +97,17 @@ def cmd_user_list(search=""):
for i in identities or []: for i in identities or []:
traits = i.get("traits", {}) traits = i.get("traits", {})
email = traits.get("email", "") email = traits.get("email", "")
name = traits.get("name", {}) # Support both employee (given_name/family_name) and default (name.first/last) schemas
if isinstance(name, dict): given = traits.get("given_name", "")
display_name = f"{name.get('first', '')} {name.get('last', '')}".strip() family = traits.get("family_name", "")
if given or family:
display_name = f"{given} {family}".strip()
else: else:
display_name = str(name) if name else "" name = traits.get("name", {})
if isinstance(name, dict):
display_name = f"{name.get('first', '')} {name.get('last', '')}".strip()
else:
display_name = str(name) if name else ""
rows.append([i["id"][:8] + "...", email, display_name, i.get("state", "active")]) rows.append([i["id"][:8] + "...", email, display_name, i.get("state", "active")])
print(table(rows, ["ID", "Email", "Name", "State"])) print(table(rows, ["ID", "Email", "Name", "State"]))
@@ -100,17 +136,12 @@ def cmd_user_create(email, name="", schema_id="default"):
with _port_forward() as base: with _port_forward() as base:
identity = _api(base, "/identities", method="POST", body=body) identity = _api(base, "/identities", method="POST", body=body)
ok(f"Created identity: {identity['id']}") ok(f"Created identity: {identity['id']}")
link, code = _generate_recovery(base, identity["id"])
# Generate recovery code (link is deprecated in Kratos v1.x)
recovery = _api(base, "/recovery/code", method="POST", body={
"identity_id": identity["id"],
"expires_in": "24h",
})
ok("Recovery link (valid 24h):") ok("Recovery link (valid 24h):")
print(recovery.get("recovery_link", "")) print(link)
ok("Recovery code (enter on the page above):") ok("Recovery code (enter on the page above):")
print(recovery.get("recovery_code", "")) print(code)
def cmd_user_delete(target): def cmd_user_delete(target):
@@ -131,14 +162,11 @@ def cmd_user_recover(target):
step(f"Generating recovery link for: {target}") step(f"Generating recovery link for: {target}")
with _port_forward() as base: with _port_forward() as base:
identity = _find_identity(base, target) identity = _find_identity(base, target)
recovery = _api(base, "/recovery/code", method="POST", body={ link, code = _generate_recovery(base, identity["id"])
"identity_id": identity["id"],
"expires_in": "24h",
})
ok("Recovery link (valid 24h):") ok("Recovery link (valid 24h):")
print(recovery.get("recovery_link", "")) print(link)
ok("Recovery code (enter on the page above):") ok("Recovery code (enter on the page above):")
print(recovery.get("recovery_code", "")) print(code)
def cmd_user_disable(target): def cmd_user_disable(target):
@@ -153,13 +181,8 @@ def cmd_user_disable(target):
with _port_forward() as base: with _port_forward() as base:
identity = _find_identity(base, target) identity = _find_identity(base, target)
iid = identity["id"] iid = identity["id"]
_api(base, f"/identities/{iid}", method="PUT", body={ _api(base, f"/identities/{iid}", method="PUT",
"schema_id": identity["schema_id"], body=_identity_put_body(identity, state="inactive"))
"traits": identity["traits"],
"state": "inactive",
"metadata_public": identity.get("metadata_public"),
"metadata_admin": identity.get("metadata_admin"),
})
_api(base, f"/identities/{iid}/sessions", method="DELETE") _api(base, f"/identities/{iid}/sessions", method="DELETE")
ok(f"Identity {iid[:8]}... disabled and all Kratos sessions revoked.") ok(f"Identity {iid[:8]}... disabled and all Kratos sessions revoked.")
warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE — currently 1h.") warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE — currently 1h.")
@@ -171,18 +194,10 @@ def cmd_user_set_password(target, password):
with _port_forward() as base: with _port_forward() as base:
identity = _find_identity(base, target) identity = _find_identity(base, target)
iid = identity["id"] iid = identity["id"]
_api(base, f"/identities/{iid}", method="PUT", body={ _api(base, f"/identities/{iid}", method="PUT",
"schema_id": identity["schema_id"], body=_identity_put_body(identity, credentials={
"traits": identity["traits"], "password": {"config": {"password": password}},
"state": identity.get("state", "active"), }))
"metadata_public": identity.get("metadata_public"),
"metadata_admin": identity.get("metadata_admin"),
"credentials": {
"password": {
"config": {"password": password},
},
},
})
ok(f"Password set for {iid[:8]}...") ok(f"Password set for {iid[:8]}...")
@@ -192,11 +207,322 @@ def cmd_user_enable(target):
with _port_forward() as base: with _port_forward() as base:
identity = _find_identity(base, target) identity = _find_identity(base, target)
iid = identity["id"] iid = identity["id"]
_api(base, f"/identities/{iid}", method="PUT", body={ _api(base, f"/identities/{iid}", method="PUT",
"schema_id": identity["schema_id"], body=_identity_put_body(identity, state="active"))
"traits": identity["traits"],
"state": "active",
"metadata_public": identity.get("metadata_public"),
"metadata_admin": identity.get("metadata_admin"),
})
ok(f"Identity {iid[:8]}... re-enabled.") ok(f"Identity {iid[:8]}... re-enabled.")
def _send_welcome_email(domain, email, name, recovery_link, recovery_code,
job_title="", department=""):
"""Send a welcome email via cluster Postfix (port-forward to svc/postfix in lasuite)."""
greeting = f"Hi {name}" if name else "Hi"
body_text = f"""{greeting},
Welcome to Sunbeam Studios!{f" You're joining as {job_title} in the {department} department." if job_title and department else ""} Your account has been created.
To set your password, open this link and enter the recovery code below:
Link: {recovery_link}
Code: {recovery_code}
This link expires in 24 hours.
Once signed in you will be prompted to set up 2FA (mandatory).
After that, head to https://auth.{domain}/settings to set up your
profile — add your name, profile picture, and any other details.
Your services:
Calendar: https://cal.{domain}
Drive: https://drive.{domain}
Mail: https://mail.{domain}
Meet: https://meet.{domain}
Projects: https://projects.{domain}
Source Code: https://src.{domain}
Messages (Matrix):
Download Element for your platform:
Desktop: https://element.io/download
iOS: https://apps.apple.com/app/element-messenger/id1083446067
Android: https://play.google.com/store/apps/details?id=im.vector.app
Setup:
1. Open Element and tap "Sign in"
2. Tap "Edit" next to the homeserver field (matrix.org)
3. Enter: https://messages.{domain}
4. Tap "Continue" — you'll be redirected to Sunbeam Studios SSO
5. Sign in with your {domain} email and password
\u2014 With Love & Warmth, Sunbeam Studios
"""
msg = EmailMessage()
msg["Subject"] = "Welcome to Sunbeam Studios — Set Your Password"
msg["From"] = f"Sunbeam Studios <noreply@{domain}>"
msg["To"] = email
msg.set_content(body_text)
with _port_forward(ns="lasuite", svc="postfix", local_port=_SMTP_LOCAL_PORT, remote_port=25):
with smtplib.SMTP("localhost", _SMTP_LOCAL_PORT) as smtp:
smtp.send_message(msg)
ok(f"Welcome email sent to {email}")
def _next_employee_id(base_url):
"""Find the next sequential employee ID by scanning all employee identities."""
identities = _api(base_url, "/identities?page_size=200") or []
max_num = 0
for ident in identities:
eid = ident.get("traits", {}).get("employee_id", "")
if eid and eid.isdigit():
max_num = max(max_num, int(eid))
return str(max_num + 1)
def _create_mailbox(email, name=""):
"""Create a mailbox in Messages via kubectl exec into the backend."""
local_part, domain_part = email.split("@", 1)
display_name = name or local_part
step(f"Creating mailbox: {email}")
result = _kube_mod.kube_out(
"exec", "deployment/messages-backend", "-n", "lasuite",
"-c", "messages-backend", "--",
"python", "manage.py", "shell", "-c",
f"""
mb, created = Mailbox.objects.get_or_create(
local_part="{local_part}",
domain=MailDomain.objects.get(name="{domain_part}"),
)
print("created" if created else "exists")
""",
)
if "created" in (result or ""):
ok(f"Mailbox {email} created.")
elif "exists" in (result or ""):
ok(f"Mailbox {email} already exists.")
else:
warn(f"Could not create mailbox (Messages backend may not be running): {result}")
def _delete_mailbox(email):
"""Delete a mailbox and associated Django user in Messages."""
local_part, domain_part = email.split("@", 1)
step(f"Cleaning up mailbox: {email}")
result = _kube_mod.kube_out(
"exec", "deployment/messages-backend", "-n", "lasuite",
"-c", "messages-backend", "--",
"python", "manage.py", "shell", "-c",
f"""
from django.contrib.auth import get_user_model
User = get_user_model()
# Delete mailbox + access + contacts
deleted = 0
for mb in Mailbox.objects.filter(local_part="{local_part}", domain__name="{domain_part}"):
mb.delete()
deleted += 1
# Delete Django user
try:
u = User.objects.get(email="{email}")
u.delete()
deleted += 1
except User.DoesNotExist:
pass
print(f"deleted {{deleted}}")
""",
)
if "deleted" in (result or ""):
ok(f"Mailbox and user cleaned up.")
else:
warn(f"Could not clean up mailbox: {result}")
def _setup_projects_user(email, name=""):
"""Create a Projects (Planka) user and add them as manager of the Default project."""
step(f"Setting up Projects user: {email}")
js = f"""
const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});
async function go() {{
// Create or find user
let user = await knex('user_account').where({{email: '{email}'}}).first();
if (!user) {{
const id = Date.now().toString();
await knex('user_account').insert({{
id, email: '{email}', name: '{name}', password: '',
is_admin: true, is_sso: true, language: 'en-US',
created_at: new Date(), updated_at: new Date()
}});
user = {{id}};
console.log('user_created');
}} else {{
console.log('user_exists');
}}
// Add to Default project
const project = await knex('project').where({{name: 'Default'}}).first();
if (project) {{
const exists = await knex('project_manager').where({{project_id: project.id, user_id: user.id}}).first();
if (!exists) {{
await knex('project_manager').insert({{
id: (Date.now()+1).toString(), project_id: project.id,
user_id: user.id, created_at: new Date()
}});
console.log('manager_added');
}} else {{
console.log('manager_exists');
}}
}} else {{
console.log('no_default_project');
}}
}}
go().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});
"""
result = _kube_mod.kube_out(
"exec", "deployment/projects", "-n", "lasuite",
"-c", "projects", "--", "node", "-e", js,
)
if "manager_added" in (result or "") or "manager_exists" in (result or ""):
ok(f"Projects user ready.")
elif "no_default_project" in (result or ""):
warn("No Default project found in Projects — skip.")
else:
warn(f"Could not set up Projects user: {result}")
def _cleanup_projects_user(email):
"""Remove a user from Projects (Planka) — delete memberships and user record."""
step(f"Cleaning up Projects user: {email}")
js = f"""
const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});
async function go() {{
const user = await knex('user_account').where({{email: '{email}'}}).first();
if (!user) {{ console.log('not_found'); return; }}
await knex('board_membership').where({{user_id: user.id}}).del();
await knex('project_manager').where({{user_id: user.id}}).del();
await knex('user_account').where({{id: user.id}}).update({{deleted_at: new Date()}});
console.log('cleaned');
}}
go().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});
"""
result = _kube_mod.kube_out(
"exec", "deployment/projects", "-n", "lasuite",
"-c", "projects", "--", "node", "-e", js,
)
if "cleaned" in (result or ""):
ok("Projects user cleaned up.")
else:
warn(f"Could not clean up Projects user: {result}")
def cmd_user_onboard(email, name="", schema_id="employee", send_email=True,
notify="", job_title="", department="", office_location="",
hire_date="", manager=""):
"""Onboard a new user: create identity, generate recovery link, optionally send welcome email."""
step(f"Onboarding: {email}")
with _port_forward() as base:
existing = _find_identity(base, email, required=False)
if existing:
warn(f"Identity already exists: {existing['id'][:8]}...")
step("Generating fresh recovery link...")
iid = existing["id"]
recovery_link, recovery_code = _generate_recovery(base, iid)
else:
traits = {"email": email}
if name:
parts = name.split(" ", 1)
traits["given_name"] = parts[0]
traits["family_name"] = parts[1] if len(parts) > 1 else ""
# Auto-assign employee ID if not provided and using employee schema
employee_id = ""
if schema_id == "employee":
employee_id = _next_employee_id(base)
traits["employee_id"] = employee_id
if job_title:
traits["job_title"] = job_title
if department:
traits["department"] = department
if office_location:
traits["office_location"] = office_location
if hire_date:
traits["hire_date"] = hire_date
if manager:
traits["manager"] = manager
identity = _api(base, "/identities", method="POST", body={
"schema_id": schema_id,
"traits": traits,
"state": "active",
"verifiable_addresses": [{
"value": email,
"verified": True,
"via": "email",
}],
})
iid = identity["id"]
ok(f"Created identity: {iid}")
if employee_id:
ok(f"Employee #{employee_id}")
# Kratos ignores verifiable_addresses on POST — PATCH is required
_api(base, f"/identities/{iid}", method="PATCH", body=[
{"op": "replace", "path": "/verifiable_addresses/0/verified", "value": True},
{"op": "replace", "path": "/verifiable_addresses/0/status", "value": "completed"},
])
recovery_link, recovery_code = _generate_recovery(base, iid)
# Provision app-level accounts
if not existing:
_create_mailbox(email, name)
_setup_projects_user(email, name)
if send_email:
domain = _kube_mod.get_domain()
recipient = notify or email
_send_welcome_email(domain, recipient, name, recovery_link, recovery_code,
job_title=job_title, department=department)
ok(f"Identity ID: {iid}")
ok("Recovery link (valid 24h):")
print(recovery_link)
ok("Recovery code:")
print(recovery_code)
def cmd_user_offboard(target):
"""Offboard a user: disable identity, revoke all Kratos + Hydra sessions."""
step(f"Offboarding: {target}")
confirm = input(f"Offboard '{target}'? This will disable the account and revoke all sessions. [y/N] ").strip().lower()
if confirm != "y":
ok("Cancelled.")
return
with _port_forward() as base:
identity = _find_identity(base, target)
iid = identity["id"]
step("Disabling identity...")
_api(base, f"/identities/{iid}", method="PUT",
body=_identity_put_body(identity, state="inactive"))
ok(f"Identity {iid[:8]}... disabled.")
step("Revoking Kratos sessions...")
_api(base, f"/identities/{iid}/sessions", method="DELETE", ok_statuses=(404,))
ok("Kratos sessions revoked.")
step("Revoking Hydra consent sessions...")
with _port_forward(svc="hydra-admin", local_port=14445, remote_port=4445) as hydra_base:
_api(hydra_base, f"/oauth2/auth/sessions/consent?subject={iid}&all=true",
method="DELETE", prefix="/admin", ok_statuses=(404,))
ok("Hydra consent sessions revoked.")
# Clean up Messages Django user and mailbox
email = identity.get("traits", {}).get("email", "")
if email:
_delete_mailbox(email)
_cleanup_projects_user(email)
ok(f"Offboarding complete for {iid[:8]}...")
warn("Existing access tokens expire within ~1h (Hydra TTL).")
warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE (~1h).")