rust-rewrite #2
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,3 +5,6 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Rust
|
||||
/target/
|
||||
|
||||
4882
Cargo.lock
generated
Normal file
4882
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["sunbeam-sdk", "sunbeam"]
|
||||
resolver = "3"
|
||||
132
build.rs
Normal file
132
build.rs
Normal 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
952
src/auth.rs
Normal 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
1214
src/checks.rs
Normal file
File diff suppressed because it is too large
Load Diff
1097
src/cli.rs
Normal file
1097
src/cli.rs
Normal file
File diff suppressed because it is too large
Load Diff
461
src/cluster.rs
Normal file
461
src/cluster.rs
Normal 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
404
src/config.rs
Normal 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
16
src/constants.rs
Normal 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
365
src/error.rs
Normal 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
429
src/gitea.rs
Normal 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
1809
src/images.rs
Normal file
File diff suppressed because it is too large
Load Diff
758
src/kube.rs
Normal file
758
src/kube.rs
Normal 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", ®istry_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
59
src/main.rs
Normal 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
880
src/manifests.rs
Normal 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(®_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>(®_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
498
src/openbao.rs
Normal 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
92
src/output.rs
Normal 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 "));
|
||||
}
|
||||
}
|
||||
}
|
||||
1727
src/secrets.rs
Normal file
1727
src/secrets.rs
Normal file
File diff suppressed because it is too large
Load Diff
573
src/services.rs
Normal file
573
src/services.rs
Normal 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
180
src/tools.rs
Normal 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
443
src/update.rs
Normal 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(¤t_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
1157
src/users.rs
Normal file
File diff suppressed because it is too large
Load Diff
65
sunbeam-sdk/Cargo.toml
Normal file
65
sunbeam-sdk/Cargo.toml
Normal 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
132
sunbeam-sdk/build.rs
Normal 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
952
sunbeam-sdk/src/auth/mod.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
792
sunbeam-sdk/src/checks/mod.rs
Normal file
792
sunbeam-sdk/src/checks/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
433
sunbeam-sdk/src/checks/probes.rs
Normal file
433
sunbeam-sdk/src/checks/probes.rs
Normal 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
|
||||
}
|
||||
461
sunbeam-sdk/src/cluster/mod.rs
Normal file
461
sunbeam-sdk/src/cluster/mod.rs
Normal 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
404
sunbeam-sdk/src/config.rs
Normal 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
sunbeam-sdk/src/constants.rs
Normal file
16
sunbeam-sdk/src/constants.rs
Normal 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
365
sunbeam-sdk/src/error.rs
Normal 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
sunbeam-sdk/src/gitea/mod.rs
Normal file
429
sunbeam-sdk/src/gitea/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
806
sunbeam-sdk/src/images/builders.rs
Normal file
806
sunbeam-sdk/src/images/builders.rs
Normal 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(())
|
||||
}
|
||||
1070
sunbeam-sdk/src/images/mod.rs
Normal file
1070
sunbeam-sdk/src/images/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
761
sunbeam-sdk/src/kube/mod.rs
Normal file
761
sunbeam-sdk/src/kube/mod.rs
Normal 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", ®istry_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")
|
||||
}
|
||||
}
|
||||
180
sunbeam-sdk/src/kube/tools.rs
Normal file
180
sunbeam-sdk/src/kube/tools.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
sunbeam-sdk/src/kube/tunnel.rs
Normal file
1
sunbeam-sdk/src/kube/tunnel.rs
Normal file
@@ -0,0 +1 @@
|
||||
// SSH tunnel management — reserved for future extraction.
|
||||
19
sunbeam-sdk/src/lib.rs
Normal file
19
sunbeam-sdk/src/lib.rs
Normal 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;
|
||||
880
sunbeam-sdk/src/manifests/mod.rs
Normal file
880
sunbeam-sdk/src/manifests/mod.rs
Normal 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(®_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>(®_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
sunbeam-sdk/src/openbao/mod.rs
Normal file
498
sunbeam-sdk/src/openbao/mod.rs
Normal 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
92
sunbeam-sdk/src/output.rs
Normal 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 "));
|
||||
}
|
||||
}
|
||||
}
|
||||
420
sunbeam-sdk/src/pm/gitea_issues.rs
Normal file
420
sunbeam-sdk/src/pm/gitea_issues.rs
Normal 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
729
sunbeam-sdk/src/pm/mod.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
546
sunbeam-sdk/src/pm/planka.rs
Normal file
546
sunbeam-sdk/src/pm/planka.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
107
sunbeam-sdk/src/secrets/db_engine.rs
Normal file
107
sunbeam-sdk/src/secrets/db_engine.rs
Normal 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
|
||||
}
|
||||
1106
sunbeam-sdk/src/secrets/mod.rs
Normal file
1106
sunbeam-sdk/src/secrets/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
542
sunbeam-sdk/src/secrets/seeding.rs
Normal file
542
sunbeam-sdk/src/secrets/seeding.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
573
sunbeam-sdk/src/services/mod.rs
Normal file
573
sunbeam-sdk/src/services/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
443
sunbeam-sdk/src/update/mod.rs
Normal file
443
sunbeam-sdk/src/update/mod.rs
Normal 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(¤t_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);
|
||||
}
|
||||
}
|
||||
656
sunbeam-sdk/src/users/mod.rs
Normal file
656
sunbeam-sdk/src/users/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
516
sunbeam-sdk/src/users/provisioning.rs
Normal file
516
sunbeam-sdk/src/users/provisioning.rs
Normal 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
18
sunbeam/Cargo.toml
Normal 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"] }
|
||||
@@ -1,8 +1,20 @@
|
||||
"""CLI entry point — argparse dispatch table for all sunbeam verbs."""
|
||||
import argparse
|
||||
import datetime
|
||||
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 = {
|
||||
"local": "sunbeam",
|
||||
"production": "production",
|
||||
@@ -48,6 +60,8 @@ def main() -> None:
|
||||
p_apply = sub.add_parser("apply", help="kustomize build + domain subst + kubectl apply")
|
||||
p_apply.add_argument("namespace", nargs="?", default="",
|
||||
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("--email", default="", help="ACME email for cert-manager")
|
||||
|
||||
@@ -82,12 +96,15 @@ def main() -> None:
|
||||
"docs-frontend", "people-frontend", "people",
|
||||
"messages", "messages-backend", "messages-frontend",
|
||||
"messages-mta-in", "messages-mta-out",
|
||||
"messages-mpa", "messages-socks-proxy"],
|
||||
"messages-mpa", "messages-socks-proxy",
|
||||
"tuwunel", "calendars", "projects", "sol"],
|
||||
help="What to build")
|
||||
p_build.add_argument("--push", action="store_true",
|
||||
help="Push image to registry after building")
|
||||
p_build.add_argument("--deploy", action="store_true",
|
||||
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]]
|
||||
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")
|
||||
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.add_argument("--host", default="",
|
||||
help="Production SSH host (e.g. user@server.example.com)")
|
||||
p_config_set.add_argument("--infra-dir", default="",
|
||||
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
|
||||
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("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()
|
||||
|
||||
|
||||
@@ -196,11 +230,25 @@ def main() -> None:
|
||||
cmd_status(args.target)
|
||||
|
||||
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 = getattr(args, "domain", "") or ""
|
||||
email = getattr(args, "email", "") 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)
|
||||
|
||||
elif args.verb == "seed":
|
||||
@@ -226,7 +274,7 @@ def main() -> None:
|
||||
elif args.verb == "build":
|
||||
from sunbeam.images import cmd_build
|
||||
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":
|
||||
from sunbeam.checks import cmd_check
|
||||
@@ -249,17 +297,21 @@ def main() -> None:
|
||||
p_config.print_help()
|
||||
sys.exit(0)
|
||||
elif action == "set":
|
||||
config = SunbeamConfig(
|
||||
production_host=args.host if args.host else "",
|
||||
infra_directory=args.infra_dir if args.infra_dir else ""
|
||||
)
|
||||
config = load_config()
|
||||
if args.host:
|
||||
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)
|
||||
elif action == "get":
|
||||
from sunbeam.output import ok
|
||||
config = load_config()
|
||||
ok(f"Production host: {config.production_host 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)
|
||||
effective_host = get_production_host()
|
||||
if effective_host:
|
||||
@@ -287,7 +339,8 @@ def main() -> None:
|
||||
from sunbeam.users import (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_set_password,
|
||||
cmd_user_onboard, cmd_user_offboard)
|
||||
action = getattr(args, "user_action", None)
|
||||
if action is None:
|
||||
p_user.print_help()
|
||||
@@ -308,6 +361,14 @@ def main() -> None:
|
||||
cmd_user_enable(args.target)
|
||||
elif action == "set-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:
|
||||
parser.print_help()
|
||||
|
||||
@@ -12,7 +12,8 @@ from sunbeam.tools import run_tool, CACHE_DIR
|
||||
from sunbeam.output import step, ok, warn, die
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -11,15 +11,18 @@ CONFIG_PATH = Path.home() / ".sunbeam.json"
|
||||
class SunbeamConfig:
|
||||
"""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.infra_directory = infra_directory
|
||||
self.acme_email = acme_email
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert configuration to dictionary for JSON serialization."""
|
||||
return {
|
||||
"production_host": self.production_host,
|
||||
"infra_directory": self.infra_directory,
|
||||
"acme_email": self.acme_email,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -28,6 +31,7 @@ class SunbeamConfig:
|
||||
return cls(
|
||||
production_host=data.get("production_host", ""),
|
||||
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."""
|
||||
config = load_config()
|
||||
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
|
||||
|
||||
@@ -259,6 +259,7 @@ def _buildctl_build_and_push(
|
||||
*,
|
||||
target: str | None = None,
|
||||
build_args: dict[str, str] | None = None,
|
||||
no_cache: bool = False,
|
||||
) -> None:
|
||||
"""Build and push an image via buildkitd running in k3s.
|
||||
|
||||
@@ -320,6 +321,8 @@ def _buildctl_build_and_push(
|
||||
]
|
||||
if target:
|
||||
cmd += ["--opt", f"target={target}"]
|
||||
if no_cache:
|
||||
cmd += ["--no-cache"]
|
||||
if build_args:
|
||||
for k, v in build_args.items():
|
||||
cmd += ["--opt", f"build-arg:{k}={v}"]
|
||||
@@ -343,6 +346,7 @@ def _build_image(
|
||||
target: str | None = None,
|
||||
build_args: dict[str, str] | None = None,
|
||||
push: bool = False,
|
||||
no_cache: bool = False,
|
||||
cleanup_paths: list[Path] | None = None,
|
||||
) -> None:
|
||||
"""Build a container image via buildkitd and push to the Gitea registry.
|
||||
@@ -364,6 +368,7 @@ def _build_image(
|
||||
context_dir=context_dir,
|
||||
target=target,
|
||||
build_args=build_args,
|
||||
no_cache=no_cache,
|
||||
)
|
||||
finally:
|
||||
for p in (cleanup_paths or []):
|
||||
@@ -514,16 +519,16 @@ def cmd_mirror(domain: str = "", gitea_admin_pass: str = ""):
|
||||
# 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."""
|
||||
try:
|
||||
_cmd_build(what, push=push, deploy=deploy)
|
||||
_cmd_build(what, push=push, deploy=deploy, no_cache=no_cache)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
cmd_str = " ".join(str(a) for a in exc.cmd)
|
||||
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":
|
||||
_build_proxy(push=push, deploy=deploy)
|
||||
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)
|
||||
elif what == "tuwunel":
|
||||
_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:
|
||||
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} ...")
|
||||
|
||||
if env.is_prod:
|
||||
# Cross-compile Deno for x86_64 and package into a minimal image.
|
||||
if not shutil.which("deno"):
|
||||
die("deno not found — install Deno: https://deno.land/")
|
||||
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,
|
||||
)
|
||||
_build_image(
|
||||
env, image,
|
||||
kratos_admin_dir / "Dockerfile", kratos_admin_dir,
|
||||
push=push,
|
||||
)
|
||||
|
||||
if deploy:
|
||||
_deploy_rollout(env, ["kratos-admin-ui"], "ory", timeout="120s")
|
||||
@@ -964,3 +934,116 @@ def _patch_dockerfile_uv(
|
||||
except Exception as exc:
|
||||
warn(f"Failed to stage uv binaries: {exc}")
|
||||
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])
|
||||
|
||||
@@ -227,6 +227,7 @@ def cmd_bao(bao_args: list[str]) -> int:
|
||||
|
||||
def kustomize_build(overlay: Path, domain: str, email: str = "") -> str:
|
||||
"""Run kustomize build --enable-helm and apply domain/email substitution."""
|
||||
import socket as _socket
|
||||
r = run_tool(
|
||||
"kustomize", "build", "--enable-helm", str(overlay),
|
||||
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)
|
||||
if 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", "")
|
||||
return text
|
||||
|
||||
@@ -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.output import step, ok, warn
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2] / "infrastructure"
|
||||
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "media", "monitoring", "ory",
|
||||
"storage", "vault-secrets-operator"]
|
||||
from sunbeam.config import get_infra_dir as _get_infra_dir
|
||||
REPO_ROOT = _get_infra_dir()
|
||||
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "monitoring",
|
||||
"ory", "storage", "vault-secrets-operator"]
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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 = ""):
|
||||
"""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
|
||||
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 not domain:
|
||||
# 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)
|
||||
|
||||
_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.")
|
||||
|
||||
@@ -15,6 +15,11 @@ from sunbeam.output import step, ok, warn, die
|
||||
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]:
|
||||
"""Generate an RSA 2048-bit DKIM key pair using openssl.
|
||||
|
||||
@@ -45,7 +50,7 @@ GITEA_ADMIN_USER = "gitea_admin"
|
||||
PG_USERS = [
|
||||
"kratos", "hydra", "gitea", "hive",
|
||||
"docs", "meet", "drive", "messages", "conversations",
|
||||
"people", "find",
|
||||
"people", "find", "calendars", "projects",
|
||||
]
|
||||
|
||||
|
||||
@@ -133,6 +138,9 @@ def _seed_openbao() -> dict:
|
||||
return {}
|
||||
|
||||
# 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):
|
||||
raw = bao(
|
||||
f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' "
|
||||
@@ -145,7 +153,12 @@ def _seed_openbao() -> dict:
|
||||
pass
|
||||
result = {}
|
||||
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
|
||||
|
||||
def rand():
|
||||
@@ -193,7 +206,9 @@ def _seed_openbao() -> dict:
|
||||
kratos_admin = get_or_create("kratos-admin",
|
||||
**{"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",
|
||||
**{"django-secret-key": rand,
|
||||
@@ -206,6 +221,16 @@ def _seed_openbao() -> dict:
|
||||
drive = get_or_create("drive",
|
||||
**{"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.
|
||||
# Read existing keys first; only generate a new pair when absent.
|
||||
existing_messages_raw = bao(
|
||||
@@ -225,15 +250,16 @@ def _seed_openbao() -> dict:
|
||||
_dkim_private, _dkim_public = _gen_dkim_key_pair()
|
||||
|
||||
messages = get_or_create("messages",
|
||||
**{"django-secret-key": rand,
|
||||
"salt-key": rand,
|
||||
"mda-api-secret": rand,
|
||||
"dkim-private-key": lambda: _dkim_private,
|
||||
"dkim-public-key": lambda: _dkim_public,
|
||||
"rspamd-password": rand,
|
||||
"socks-proxy-users": lambda: f"sunbeam:{rand()}",
|
||||
"mta-out-smtp-username": lambda: "sunbeam",
|
||||
"mta-out-smtp-password": rand})
|
||||
**{"django-secret-key": rand,
|
||||
"salt-key": rand,
|
||||
"mda-api-secret": rand,
|
||||
"oidc-refresh-token-key": _gen_fernet_key,
|
||||
"dkim-private-key": lambda: _dkim_private,
|
||||
"dkim-public-key": lambda: _dkim_public,
|
||||
"rspamd-password": rand,
|
||||
"socks-proxy-users": lambda: f"sunbeam:{rand()}",
|
||||
"mta-out-smtp-username": lambda: "sunbeam",
|
||||
"mta-out-smtp-password": rand})
|
||||
|
||||
collabora = get_or_create("collabora",
|
||||
**{"username": lambda: "admin",
|
||||
@@ -262,48 +288,100 @@ def _seed_openbao() -> dict:
|
||||
**{"access-key-id": lambda: _scw_config("access-key"),
|
||||
"secret-access-key": lambda: _scw_config("secret-key")})
|
||||
|
||||
# Write all secrets to KV (idempotent -- puts same values back)
|
||||
# messages secrets written separately first (multi-field KV, avoids line-length issues)
|
||||
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
|
||||
f"bao kv put secret/messages"
|
||||
f" django-secret-key=\"{messages['django-secret-key']}\""
|
||||
f" salt-key=\"{messages['salt-key']}\""
|
||||
f" mda-api-secret=\"{messages['mda-api-secret']}\""
|
||||
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"'")
|
||||
# Only write secrets to OpenBao KV for paths that have new/missing values.
|
||||
# This avoids unnecessary KV version bumps which trigger VSO re-syncs and
|
||||
# rollout restarts across the cluster.
|
||||
if not _dirty_paths:
|
||||
ok("All OpenBao KV secrets already present -- skipping writes.")
|
||||
else:
|
||||
ok(f"Writing new secrets to OpenBao KV ({', '.join(sorted(_dirty_paths))})...")
|
||||
|
||||
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '"
|
||||
f"bao kv put secret/hydra system-secret=\"{hydra['system-secret']}\" cookie-secret=\"{hydra['cookie-secret']}\" pairwise-salt=\"{hydra['pairwise-salt']}\" && "
|
||||
f"bao kv put secret/kratos secrets-default=\"{kratos['secrets-default']}\" secrets-cookie=\"{kratos['secrets-cookie']}\" smtp-connection-uri=\"{kratos['smtp-connection-uri']}\" && "
|
||||
f"bao kv put secret/gitea admin-username=\"{gitea['admin-username']}\" admin-password=\"{gitea['admin-password']}\" && "
|
||||
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']}\" && "
|
||||
f"bao kv put secret/livekit api-key=\"{livekit['api-key']}\" api-secret=\"{livekit['api-secret']}\" && "
|
||||
f"bao kv put secret/people django-secret-key=\"{people['django-secret-key']}\" && "
|
||||
f"bao kv put secret/login-ui cookie-secret=\"{login_ui['cookie-secret']}\" csrf-cookie-secret=\"{login_ui['csrf-cookie-secret']}\" && "
|
||||
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']}\" && "
|
||||
f"bao kv put secret/docs django-secret-key=\"{docs['django-secret-key']}\" collaboration-secret=\"{docs['collaboration-secret']}\" && "
|
||||
f"bao kv put secret/meet django-secret-key=\"{meet['django-secret-key']}\" application-jwt-secret-key=\"{meet['application-jwt-secret-key']}\" && "
|
||||
f"bao kv put secret/drive django-secret-key=\"{drive['django-secret-key']}\" && "
|
||||
f"bao kv put secret/collabora username=\"{collabora['username']}\" password=\"{collabora['password']}\" && "
|
||||
f"bao kv put secret/grafana admin-password=\"{grafana['admin-password']}\" && "
|
||||
f"bao kv put secret/scaleway-s3 access-key-id=\"{scaleway_s3['access-key-id']}\" secret-access-key=\"{scaleway_s3['secret-access-key']}\" && "
|
||||
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']}\""
|
||||
f"'")
|
||||
def _kv_put(path, **kv):
|
||||
pairs = " ".join(f'{k}="{v}"' for k, v in kv.items())
|
||||
bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' "
|
||||
f"bao kv put secret/{path} {pairs}")
|
||||
|
||||
if "messages" in _dirty_paths:
|
||||
_kv_put("messages",
|
||||
**{"django-secret-key": messages["django-secret-key"],
|
||||
"salt-key": messages["salt-key"],
|
||||
"mda-api-secret": messages["mda-api-secret"],
|
||||
"oidc-refresh-token-key": messages["oidc-refresh-token-key"],
|
||||
"rspamd-password": messages["rspamd-password"],
|
||||
"socks-proxy-users": messages["socks-proxy-users"],
|
||||
"mta-out-smtp-username": messages["mta-out-smtp-username"],
|
||||
"mta-out-smtp-password": messages["mta-out-smtp-password"]})
|
||||
# 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"'")
|
||||
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
|
||||
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]}...)")
|
||||
else:
|
||||
identity = _kratos_api(base, "/identities", method="POST", body={
|
||||
"schema_id": "default",
|
||||
"schema_id": "employee",
|
||||
"traits": {"email": admin_email},
|
||||
"state": "active",
|
||||
})
|
||||
@@ -600,6 +678,7 @@ def cmd_seed() -> dict:
|
||||
"drive": "drive_db", "messages": "messages_db",
|
||||
"conversations": "conversations_db",
|
||||
"people": "people_db", "find": "find_db",
|
||||
"calendars": "calendars_db", "projects": "projects_db",
|
||||
}
|
||||
for user in PG_USERS:
|
||||
# Only CREATE if missing -- passwords are managed by OpenBao static roles.
|
||||
|
||||
@@ -8,8 +8,8 @@ from sunbeam.kube import kube, kube_out, parse_target
|
||||
from sunbeam.tools import ensure_tool
|
||||
from sunbeam.output import step, ok, warn, die
|
||||
|
||||
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "media", "ory", "storage",
|
||||
"vault-secrets-operator"]
|
||||
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "ory",
|
||||
"storage", "vault-secrets-operator"]
|
||||
|
||||
SERVICES_TO_RESTART = [
|
||||
("ory", "hydra"),
|
||||
@@ -22,6 +22,8 @@ SERVICES_TO_RESTART = [
|
||||
("lasuite", "people-frontend"),
|
||||
("lasuite", "people-celery-worker"),
|
||||
("lasuite", "people-celery-beat"),
|
||||
("lasuite", "projects"),
|
||||
("matrix", "tuwunel"),
|
||||
("media", "livekit-server"),
|
||||
]
|
||||
|
||||
@@ -186,8 +188,9 @@ def cmd_logs(target: str, follow: bool):
|
||||
if not name:
|
||||
die("Logs require a service name, e.g. 'ory/kratos'.")
|
||||
|
||||
_kube_mod.ensure_tunnel()
|
||||
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"]
|
||||
if follow:
|
||||
cmd.append("--follow")
|
||||
|
||||
1050
sunbeam/src/cli.rs
Normal file
1050
sunbeam/src/cli.rs
Normal file
File diff suppressed because it is too large
Load Diff
39
sunbeam/src/main.rs
Normal file
39
sunbeam/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,20 @@ class TestArgParsing(unittest.TestCase):
|
||||
p_user_set_pw = user_sub.add_parser("set-password")
|
||||
p_user_set_pw.add_argument("target")
|
||||
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
|
||||
p_config = sub.add_parser("config")
|
||||
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.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):
|
||||
args = self._parse(["get", "ory/kratos-abc"])
|
||||
self.assertEqual(args.verb, "get")
|
||||
@@ -259,6 +308,16 @@ class TestArgParsing(unittest.TestCase):
|
||||
class TestCliDispatch(unittest.TestCase):
|
||||
"""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):
|
||||
with patch.object(sys, "argv", ["sunbeam"]):
|
||||
from sunbeam import cli
|
||||
@@ -356,7 +415,7 @@ class TestCliDispatch(unittest.TestCase):
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
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):
|
||||
mock_build = MagicMock()
|
||||
@@ -368,7 +427,7 @@ class TestCliDispatch(unittest.TestCase):
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
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):
|
||||
mock_build = MagicMock()
|
||||
@@ -380,16 +439,11 @@ class TestCliDispatch(unittest.TestCase):
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
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):
|
||||
mock_set_pw = MagicMock()
|
||||
mock_users = MagicMock(
|
||||
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,
|
||||
)
|
||||
mock_users = self._mock_users(cmd_user_set_password=mock_set_pw)
|
||||
with patch.object(sys, "argv", ["sunbeam", "user", "set-password",
|
||||
"admin@sunbeam.pt", "s3cr3t"]):
|
||||
with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
|
||||
@@ -403,12 +457,7 @@ class TestCliDispatch(unittest.TestCase):
|
||||
|
||||
def test_user_disable_dispatches(self):
|
||||
mock_disable = MagicMock()
|
||||
mock_users = MagicMock(
|
||||
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(),
|
||||
)
|
||||
mock_users = self._mock_users(cmd_user_disable=mock_disable)
|
||||
with patch.object(sys, "argv", ["sunbeam", "user", "disable", "x@sunbeam.pt"]):
|
||||
with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
|
||||
import importlib, sunbeam.cli as cli_mod
|
||||
@@ -421,12 +470,7 @@ class TestCliDispatch(unittest.TestCase):
|
||||
|
||||
def test_user_enable_dispatches(self):
|
||||
mock_enable = MagicMock()
|
||||
mock_users = MagicMock(
|
||||
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(),
|
||||
)
|
||||
mock_users = self._mock_users(cmd_user_enable=mock_enable)
|
||||
with patch.object(sys, "argv", ["sunbeam", "user", "enable", "x@sunbeam.pt"]):
|
||||
with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
|
||||
import importlib, sunbeam.cli as cli_mod
|
||||
@@ -471,7 +515,7 @@ class TestCliDispatch(unittest.TestCase):
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
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):
|
||||
mock_build = MagicMock()
|
||||
@@ -483,7 +527,7 @@ class TestCliDispatch(unittest.TestCase):
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
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):
|
||||
mock_build = MagicMock()
|
||||
@@ -495,7 +539,7 @@ class TestCliDispatch(unittest.TestCase):
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
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):
|
||||
mock_build = MagicMock()
|
||||
@@ -507,7 +551,7 @@ class TestCliDispatch(unittest.TestCase):
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
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):
|
||||
mock_check = MagicMock()
|
||||
@@ -534,6 +578,56 @@ class TestCliDispatch(unittest.TestCase):
|
||||
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):
|
||||
"""Test config subcommand functionality."""
|
||||
|
||||
@@ -643,14 +737,18 @@ class TestConfigCli(unittest.TestCase):
|
||||
|
||||
def test_config_cli_set_dispatch(self):
|
||||
"""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_config = MagicMock(
|
||||
SunbeamConfig=MagicMock(return_value="mock_config"),
|
||||
load_config=MagicMock(return_value=mock_existing),
|
||||
save_config=mock_save
|
||||
)
|
||||
|
||||
with patch.object(sys, "argv", ["sunbeam", "config", "set",
|
||||
"--host", "cli@example.com",
|
||||
|
||||
with patch.object(sys, "argv", ["sunbeam", "config", "set",
|
||||
"--host", "cli@example.com",
|
||||
"--infra-dir", "/cli/infra"]):
|
||||
with patch.dict("sys.modules", {"sunbeam.config": mock_config}):
|
||||
import importlib, sunbeam.cli as cli_mod
|
||||
@@ -659,14 +757,12 @@ class TestConfigCli(unittest.TestCase):
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
# Verify SunbeamConfig was called with correct args
|
||||
mock_config.SunbeamConfig.assert_called_once_with(
|
||||
production_host="cli@example.com",
|
||||
infra_directory="/cli/infra"
|
||||
)
|
||||
# Verify save_config was called
|
||||
mock_save.assert_called_once_with("mock_config")
|
||||
|
||||
# Verify existing config was loaded and updated
|
||||
self.assertEqual(mock_existing.production_host, "cli@example.com")
|
||||
self.assertEqual(mock_existing.infra_directory, "/cli/infra")
|
||||
# Verify save_config was called with the updated config
|
||||
mock_save.assert_called_once_with(mock_existing)
|
||||
|
||||
def test_config_cli_get_dispatch(self):
|
||||
"""Test that config get CLI dispatches correctly."""
|
||||
|
||||
@@ -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.
|
||||
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 io
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
@@ -13,26 +16,79 @@ from pathlib import Path
|
||||
|
||||
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": {
|
||||
"version": "v1.32.2",
|
||||
"url": "https://dl.k8s.io/release/v1.32.2/bin/darwin/arm64/kubectl",
|
||||
"sha256": "", # set to actual hash; empty = skip verify
|
||||
"url": "https://dl.k8s.io/release/{version}/bin/{os}/{arch}/kubectl",
|
||||
# plain binary, no archive
|
||||
},
|
||||
"kustomize": {
|
||||
"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",
|
||||
"sha256": "",
|
||||
"url": (
|
||||
"https://github.com/kubernetes-sigs/kustomize/releases/download/"
|
||||
"kustomize%2F{version}/kustomize_{version}_{os}_{arch}.tar.gz"
|
||||
),
|
||||
"extract": "kustomize",
|
||||
},
|
||||
"helm": {
|
||||
"version": "v4.1.0",
|
||||
"url": "https://get.helm.sh/helm-v4.1.0-darwin-arm64.tar.gz",
|
||||
"sha256": "82f7065bf4e08d4c8d7881b85c0a080581ef4968a4ae6df4e7b432f8f7a88d0c",
|
||||
"extract": "darwin-arm64/helm",
|
||||
"url": "https://get.helm.sh/helm-{version}-{os}-{arch}.tar.gz",
|
||||
"extract": "{os}-{arch}/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:
|
||||
h = hashlib.sha256()
|
||||
@@ -45,12 +101,10 @@ def _sha256(path: Path) -> str:
|
||||
def ensure_tool(name: str) -> Path:
|
||||
"""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.
|
||||
"""
|
||||
if name not in TOOLS:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
spec = TOOLS[name]
|
||||
spec = _resolve_spec(name)
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = CACHE_DIR / name
|
||||
version_file = CACHE_DIR / f"{name}.version"
|
||||
@@ -58,7 +112,6 @@ def ensure_tool(name: str) -> Path:
|
||||
expected_sha = spec.get("sha256", "")
|
||||
expected_version = spec.get("version", "")
|
||||
|
||||
# Use cached binary if version matches (or no version pinned) and SHA passes
|
||||
if dest.exists():
|
||||
version_ok = (
|
||||
not expected_version
|
||||
@@ -67,18 +120,17 @@ def ensure_tool(name: str) -> Path:
|
||||
sha_ok = not expected_sha or _sha256(dest) == expected_sha
|
||||
if version_ok and sha_ok:
|
||||
return dest
|
||||
|
||||
# Version mismatch or SHA mismatch — re-download
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
if version_file.exists():
|
||||
version_file.unlink()
|
||||
|
||||
# Download
|
||||
url = spec["url"]
|
||||
with urllib.request.urlopen(url) as resp: # noqa: S310
|
||||
data = resp.read()
|
||||
|
||||
# Extract from tar.gz if needed
|
||||
extract_path = spec.get("extract")
|
||||
if extract_path:
|
||||
with tarfile.open(fileobj=io.BytesIO(data)) as tf:
|
||||
@@ -88,10 +140,8 @@ def ensure_tool(name: str) -> Path:
|
||||
else:
|
||||
binary_data = data
|
||||
|
||||
# Write to cache
|
||||
dest.write_bytes(binary_data)
|
||||
|
||||
# Verify SHA256 (after extraction)
|
||||
if expected_sha:
|
||||
actual = _sha256(dest)
|
||||
if actual != expected_sha:
|
||||
@@ -100,9 +150,7 @@ def ensure_tool(name: str) -> Path:
|
||||
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)
|
||||
# Record version so future calls skip re-download when version unchanged
|
||||
version_file.write_text(expected_version)
|
||||
return dest
|
||||
|
||||
@@ -116,9 +164,8 @@ def run_tool(name: str, *args, **kwargs) -> subprocess.CompletedProcess:
|
||||
env = kwargs.pop("env", None)
|
||||
if env is None:
|
||||
env = os.environ.copy()
|
||||
# kustomize needs helm on PATH for helm chart rendering
|
||||
if name == "kustomize":
|
||||
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", "")
|
||||
return subprocess.run([str(bin_path), *args], env=env, **kwargs)
|
||||
|
||||
436
sunbeam/users.py
436
sunbeam/users.py
@@ -1,19 +1,23 @@
|
||||
"""User management — Kratos identity operations via port-forwarded admin API."""
|
||||
import json
|
||||
import smtplib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from contextlib import contextmanager
|
||||
from email.message import EmailMessage
|
||||
|
||||
import sunbeam.kube as _kube_mod
|
||||
from sunbeam.output import step, ok, warn, die, table
|
||||
|
||||
_SMTP_LOCAL_PORT = 10025
|
||||
|
||||
|
||||
@contextmanager
|
||||
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(
|
||||
["kubectl", _kube_mod.context_arg(), "-n", ns, "port-forward",
|
||||
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()
|
||||
|
||||
|
||||
def _api(base_url, path, method="GET", body=None):
|
||||
"""Make a request to the Kratos admin API via port-forward."""
|
||||
url = f"{base_url}/admin{path}"
|
||||
def _api(base_url, path, method="GET", body=None, prefix="/admin", ok_statuses=()):
|
||||
"""Make a request to an admin API via port-forward."""
|
||||
url = f"{base_url}{prefix}{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
body = resp.read()
|
||||
return json.loads(body) if body else None
|
||||
resp_body = resp.read()
|
||||
return json.loads(resp_body) if resp_body else None
|
||||
except urllib.error.HTTPError as e:
|
||||
body_text = e.read().decode()
|
||||
die(f"API error {e.code}: {body_text}")
|
||||
if e.code in ok_statuses:
|
||||
return None
|
||||
err_text = e.read().decode()
|
||||
die(f"API error {e.code}: {err_text}")
|
||||
|
||||
|
||||
def _find_identity(base_url, target):
|
||||
"""Find identity by email or ID. Returns identity dict."""
|
||||
def _find_identity(base_url, target, required=True):
|
||||
"""Find identity by email or ID. Returns identity dict or None if not required."""
|
||||
# Try as ID first
|
||||
if len(target) == 36 and target.count("-") == 4:
|
||||
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")
|
||||
if isinstance(result, list) and result:
|
||||
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=""):
|
||||
@@ -67,11 +97,17 @@ def cmd_user_list(search=""):
|
||||
for i in identities or []:
|
||||
traits = i.get("traits", {})
|
||||
email = traits.get("email", "")
|
||||
name = traits.get("name", {})
|
||||
if isinstance(name, dict):
|
||||
display_name = f"{name.get('first', '')} {name.get('last', '')}".strip()
|
||||
# Support both employee (given_name/family_name) and default (name.first/last) schemas
|
||||
given = traits.get("given_name", "")
|
||||
family = traits.get("family_name", "")
|
||||
if given or family:
|
||||
display_name = f"{given} {family}".strip()
|
||||
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")])
|
||||
|
||||
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:
|
||||
identity = _api(base, "/identities", method="POST", body=body)
|
||||
ok(f"Created identity: {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",
|
||||
})
|
||||
link, code = _generate_recovery(base, identity["id"])
|
||||
|
||||
ok("Recovery link (valid 24h):")
|
||||
print(recovery.get("recovery_link", ""))
|
||||
print(link)
|
||||
ok("Recovery code (enter on the page above):")
|
||||
print(recovery.get("recovery_code", ""))
|
||||
print(code)
|
||||
|
||||
|
||||
def cmd_user_delete(target):
|
||||
@@ -131,14 +162,11 @@ def cmd_user_recover(target):
|
||||
step(f"Generating recovery link for: {target}")
|
||||
with _port_forward() as base:
|
||||
identity = _find_identity(base, target)
|
||||
recovery = _api(base, "/recovery/code", method="POST", body={
|
||||
"identity_id": identity["id"],
|
||||
"expires_in": "24h",
|
||||
})
|
||||
link, code = _generate_recovery(base, identity["id"])
|
||||
ok("Recovery link (valid 24h):")
|
||||
print(recovery.get("recovery_link", ""))
|
||||
print(link)
|
||||
ok("Recovery code (enter on the page above):")
|
||||
print(recovery.get("recovery_code", ""))
|
||||
print(code)
|
||||
|
||||
|
||||
def cmd_user_disable(target):
|
||||
@@ -153,13 +181,8 @@ def cmd_user_disable(target):
|
||||
with _port_forward() as base:
|
||||
identity = _find_identity(base, target)
|
||||
iid = identity["id"]
|
||||
_api(base, f"/identities/{iid}", method="PUT", body={
|
||||
"schema_id": identity["schema_id"],
|
||||
"traits": identity["traits"],
|
||||
"state": "inactive",
|
||||
"metadata_public": identity.get("metadata_public"),
|
||||
"metadata_admin": identity.get("metadata_admin"),
|
||||
})
|
||||
_api(base, f"/identities/{iid}", method="PUT",
|
||||
body=_identity_put_body(identity, state="inactive"))
|
||||
_api(base, f"/identities/{iid}/sessions", method="DELETE")
|
||||
ok(f"Identity {iid[:8]}... disabled and all Kratos sessions revoked.")
|
||||
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:
|
||||
identity = _find_identity(base, target)
|
||||
iid = identity["id"]
|
||||
_api(base, f"/identities/{iid}", method="PUT", body={
|
||||
"schema_id": identity["schema_id"],
|
||||
"traits": identity["traits"],
|
||||
"state": identity.get("state", "active"),
|
||||
"metadata_public": identity.get("metadata_public"),
|
||||
"metadata_admin": identity.get("metadata_admin"),
|
||||
"credentials": {
|
||||
"password": {
|
||||
"config": {"password": password},
|
||||
},
|
||||
},
|
||||
})
|
||||
_api(base, f"/identities/{iid}", method="PUT",
|
||||
body=_identity_put_body(identity, credentials={
|
||||
"password": {"config": {"password": password}},
|
||||
}))
|
||||
ok(f"Password set for {iid[:8]}...")
|
||||
|
||||
|
||||
@@ -192,11 +207,322 @@ def cmd_user_enable(target):
|
||||
with _port_forward() as base:
|
||||
identity = _find_identity(base, target)
|
||||
iid = identity["id"]
|
||||
_api(base, f"/identities/{iid}", method="PUT", body={
|
||||
"schema_id": identity["schema_id"],
|
||||
"traits": identity["traits"],
|
||||
"state": "active",
|
||||
"metadata_public": identity.get("metadata_public"),
|
||||
"metadata_admin": identity.get("metadata_admin"),
|
||||
})
|
||||
_api(base, f"/identities/{iid}", method="PUT",
|
||||
body=_identity_put_body(identity, state="active"))
|
||||
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).")
|
||||
|
||||
Reference in New Issue
Block a user