refactor: SDK users, pm, and checks modules with submodule splits

Split users.rs (1157L) into mod.rs + provisioning.rs (mailbox,
projects user, welcome email). Split pm.rs (1664L) into mod.rs +
planka.rs (PlankaClient) + gitea_issues.rs (GiteaClient). Split
checks.rs (1214L) into mod.rs + probes.rs (11 check functions + S3).
This commit is contained in:
2026-03-21 14:38:18 +00:00
parent bc65b9157d
commit 6c7e1cd064
7 changed files with 4092 additions and 0 deletions

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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