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:
792
sunbeam-sdk/src/checks/mod.rs
Normal file
792
sunbeam-sdk/src/checks/mod.rs
Normal file
@@ -0,0 +1,792 @@
|
||||
//! Service-level health checks -- functional probes beyond pod readiness.
|
||||
|
||||
mod probes;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::kube::parse_target;
|
||||
use crate::output::{ok, step, warn};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CheckResult
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of a single health check.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CheckResult {
|
||||
pub name: String,
|
||||
pub ns: String,
|
||||
pub svc: String,
|
||||
pub passed: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl CheckResult {
|
||||
fn ok(name: &str, ns: &str, svc: &str, detail: &str) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
ns: ns.into(),
|
||||
svc: svc.into(),
|
||||
passed: true,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(name: &str, ns: &str, svc: &str, detail: &str) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
ns: ns.into(),
|
||||
svc: svc.into(),
|
||||
passed: false,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP client builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a reqwest client that trusts the mkcert local CA if available,
|
||||
/// does not follow redirects, and has a 5s timeout.
|
||||
fn build_http_client() -> Result<reqwest::Client> {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(5));
|
||||
|
||||
// Try mkcert root CA
|
||||
if let Ok(output) = std::process::Command::new("mkcert")
|
||||
.arg("-CAROOT")
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let ca_root = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let ca_file = std::path::Path::new(&ca_root).join("rootCA.pem");
|
||||
if ca_file.exists() {
|
||||
if let Ok(pem_bytes) = std::fs::read(&ca_file) {
|
||||
if let Ok(cert) = reqwest::Certificate::from_pem(&pem_bytes) {
|
||||
builder = builder.add_root_certificate(cert);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
|
||||
/// Helper: GET a URL, return (status_code, body_bytes). Does not follow redirects.
|
||||
async fn http_get(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
headers: Option<&[(&str, &str)]>,
|
||||
) -> std::result::Result<(u16, Vec<u8>), String> {
|
||||
let mut req = client.get(url);
|
||||
if let Some(hdrs) = headers {
|
||||
for (k, v) in hdrs {
|
||||
req = req.header(*k, *v);
|
||||
}
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.bytes().await.unwrap_or_default().to_vec();
|
||||
Ok((status, body))
|
||||
}
|
||||
Err(e) => Err(format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a K8s secret field, returning empty string on failure.
|
||||
async fn kube_secret(ns: &str, name: &str, key: &str) -> String {
|
||||
crate::kube::kube_get_secret_field(ns, name, key)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check registry -- function pointer + metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CheckFn = for<'a> fn(
|
||||
&'a str,
|
||||
&'a reqwest::Client,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = CheckResult> + Send + 'a>>;
|
||||
|
||||
struct CheckEntry {
|
||||
func: CheckFn,
|
||||
ns: &'static str,
|
||||
svc: &'static str,
|
||||
}
|
||||
|
||||
fn check_registry() -> Vec<CheckEntry> {
|
||||
use probes::*;
|
||||
vec![
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_gitea_version(d, c)),
|
||||
ns: "devtools",
|
||||
svc: "gitea",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_gitea_auth(d, c)),
|
||||
ns: "devtools",
|
||||
svc: "gitea",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_postgres(d, c)),
|
||||
ns: "data",
|
||||
svc: "postgres",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_valkey(d, c)),
|
||||
ns: "data",
|
||||
svc: "valkey",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_openbao(d, c)),
|
||||
ns: "data",
|
||||
svc: "openbao",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_seaweedfs(d, c)),
|
||||
ns: "storage",
|
||||
svc: "seaweedfs",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_kratos(d, c)),
|
||||
ns: "ory",
|
||||
svc: "kratos",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_hydra_oidc(d, c)),
|
||||
ns: "ory",
|
||||
svc: "hydra",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_people(d, c)),
|
||||
ns: "lasuite",
|
||||
svc: "people",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_people_api(d, c)),
|
||||
ns: "lasuite",
|
||||
svc: "people",
|
||||
},
|
||||
CheckEntry {
|
||||
func: |d, c| Box::pin(check_livekit(d, c)),
|
||||
ns: "media",
|
||||
svc: "livekit",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cmd_check -- concurrent execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run service-level health checks, optionally scoped to a namespace or service.
|
||||
pub async fn cmd_check(target: Option<&str>) -> Result<()> {
|
||||
step("Service health checks...");
|
||||
|
||||
let domain = crate::kube::get_domain().await?;
|
||||
let http_client = build_http_client()?;
|
||||
|
||||
let (ns_filter, svc_filter) = parse_target(target)?;
|
||||
|
||||
let all_checks = check_registry();
|
||||
let selected: Vec<&CheckEntry> = all_checks
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
(ns_filter.is_none() || ns_filter == Some(e.ns))
|
||||
&& (svc_filter.is_none() || svc_filter == Some(e.svc))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if selected.is_empty() {
|
||||
warn(&format!(
|
||||
"No checks match target: {}",
|
||||
target.unwrap_or("(none)")
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Run all checks concurrently
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
for entry in &selected {
|
||||
let domain = domain.clone();
|
||||
let client = http_client.clone();
|
||||
let func = entry.func;
|
||||
join_set.spawn(async move { func(&domain, &client).await });
|
||||
}
|
||||
|
||||
let mut results: Vec<CheckResult> = Vec::new();
|
||||
while let Some(res) = join_set.join_next().await {
|
||||
match res {
|
||||
Ok(cr) => results.push(cr),
|
||||
Err(e) => results.push(CheckResult::fail("unknown", "?", "?", &format!("{e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Sort to match the registry order for consistent output
|
||||
let registry = check_registry();
|
||||
results.sort_by(|a, b| {
|
||||
let idx_a = registry
|
||||
.iter()
|
||||
.position(|e| e.ns == a.ns && e.svc == a.svc)
|
||||
.unwrap_or(usize::MAX);
|
||||
let idx_b = registry
|
||||
.iter()
|
||||
.position(|e| e.ns == b.ns && e.svc == b.svc)
|
||||
.unwrap_or(usize::MAX);
|
||||
idx_a.cmp(&idx_b).then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
// Print grouped by namespace
|
||||
let name_w = results.iter().map(|r| r.name.len()).max().unwrap_or(0);
|
||||
let mut cur_ns: Option<&str> = None;
|
||||
for r in &results {
|
||||
if cur_ns != Some(&r.ns) {
|
||||
println!(" {}:", r.ns);
|
||||
cur_ns = Some(&r.ns);
|
||||
}
|
||||
let icon = if r.passed { "\u{2713}" } else { "\u{2717}" };
|
||||
let detail = if r.detail.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", r.detail)
|
||||
};
|
||||
println!(" {icon} {:<name_w$}{detail}", r.name);
|
||||
}
|
||||
|
||||
println!();
|
||||
let failed: Vec<&CheckResult> = results.iter().filter(|r| !r.passed).collect();
|
||||
if failed.is_empty() {
|
||||
ok(&format!("All {} check(s) passed.", results.len()));
|
||||
} else {
|
||||
warn(&format!("{} check(s) failed.", failed.len()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::probes::*;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
// ── S3 auth header tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_s3_auth_headers_format() {
|
||||
let (auth, amzdate) = s3_auth_headers(
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"s3.example.com",
|
||||
);
|
||||
|
||||
// Verify header structure
|
||||
assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/"));
|
||||
assert!(auth.contains("us-east-1/s3/aws4_request"));
|
||||
assert!(auth.contains("SignedHeaders=host;x-amz-date"));
|
||||
assert!(auth.contains("Signature="));
|
||||
|
||||
// amzdate format: YYYYMMDDTHHMMSSZ
|
||||
assert_eq!(amzdate.len(), 16);
|
||||
assert!(amzdate.ends_with('Z'));
|
||||
assert!(amzdate.contains('T'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_s3_auth_headers_signature_changes_with_key() {
|
||||
let (auth1, _) = s3_auth_headers("key1", "secret1", "host1");
|
||||
let (auth2, _) = s3_auth_headers("key2", "secret2", "host2");
|
||||
// Different keys produce different signatures
|
||||
let sig1 = auth1.split("Signature=").nth(1).unwrap();
|
||||
let sig2 = auth2.split("Signature=").nth(1).unwrap();
|
||||
assert_ne!(sig1, sig2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_s3_auth_headers_credential_scope() {
|
||||
let (auth, amzdate) = s3_auth_headers("AK", "SK", "s3.example.com");
|
||||
let datestamp = &amzdate[..8];
|
||||
let expected_scope = format!("{datestamp}/us-east-1/s3/aws4_request");
|
||||
assert!(auth.contains(&expected_scope));
|
||||
}
|
||||
|
||||
// ── hex encoding ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_hex_encode_empty() {
|
||||
assert_eq!(hex_encode(b""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_encode_zero() {
|
||||
assert_eq!(hex_encode(b"\x00"), "00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_encode_ff() {
|
||||
assert_eq!(hex_encode(b"\xff"), "ff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_encode_deadbeef() {
|
||||
assert_eq!(hex_encode(b"\xde\xad\xbe\xef"), "deadbeef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_encode_hello() {
|
||||
assert_eq!(hex_encode(b"hello"), "68656c6c6f");
|
||||
}
|
||||
|
||||
// ── CheckResult ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_check_result_ok() {
|
||||
let r = CheckResult::ok("gitea-version", "devtools", "gitea", "v1.21.0");
|
||||
assert!(r.passed);
|
||||
assert_eq!(r.name, "gitea-version");
|
||||
assert_eq!(r.ns, "devtools");
|
||||
assert_eq!(r.svc, "gitea");
|
||||
assert_eq!(r.detail, "v1.21.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_result_fail() {
|
||||
let r = CheckResult::fail("postgres", "data", "postgres", "cluster not found");
|
||||
assert!(!r.passed);
|
||||
assert_eq!(r.detail, "cluster not found");
|
||||
}
|
||||
|
||||
// ── Check registry ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_check_registry_has_all_checks() {
|
||||
let registry = check_registry();
|
||||
assert_eq!(registry.len(), 11);
|
||||
|
||||
// Verify order matches Python CHECKS list
|
||||
assert_eq!(registry[0].ns, "devtools");
|
||||
assert_eq!(registry[0].svc, "gitea");
|
||||
assert_eq!(registry[1].ns, "devtools");
|
||||
assert_eq!(registry[1].svc, "gitea");
|
||||
assert_eq!(registry[2].ns, "data");
|
||||
assert_eq!(registry[2].svc, "postgres");
|
||||
assert_eq!(registry[3].ns, "data");
|
||||
assert_eq!(registry[3].svc, "valkey");
|
||||
assert_eq!(registry[4].ns, "data");
|
||||
assert_eq!(registry[4].svc, "openbao");
|
||||
assert_eq!(registry[5].ns, "storage");
|
||||
assert_eq!(registry[5].svc, "seaweedfs");
|
||||
assert_eq!(registry[6].ns, "ory");
|
||||
assert_eq!(registry[6].svc, "kratos");
|
||||
assert_eq!(registry[7].ns, "ory");
|
||||
assert_eq!(registry[7].svc, "hydra");
|
||||
assert_eq!(registry[8].ns, "lasuite");
|
||||
assert_eq!(registry[8].svc, "people");
|
||||
assert_eq!(registry[9].ns, "lasuite");
|
||||
assert_eq!(registry[9].svc, "people");
|
||||
assert_eq!(registry[10].ns, "media");
|
||||
assert_eq!(registry[10].svc, "livekit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_registry_filter_namespace() {
|
||||
let all = check_registry();
|
||||
let filtered: Vec<&CheckEntry> = all.iter().filter(|e| e.ns == "ory").collect();
|
||||
assert_eq!(filtered.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_registry_filter_service() {
|
||||
let all = check_registry();
|
||||
let filtered: Vec<&CheckEntry> = all
|
||||
.iter()
|
||||
.filter(|e| e.ns == "ory" && e.svc == "kratos")
|
||||
.collect();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_registry_filter_no_match() {
|
||||
let all = check_registry();
|
||||
let filtered: Vec<&CheckEntry> =
|
||||
all.iter().filter(|e| e.ns == "nonexistent").collect();
|
||||
assert!(filtered.is_empty());
|
||||
}
|
||||
|
||||
// ── HMAC-SHA256 verification ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_hmac_sha256_known_vector() {
|
||||
// RFC 4231 Test Case 2
|
||||
let key = b"Jefe";
|
||||
let data = b"what do ya want for nothing?";
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key");
|
||||
mac.update(data);
|
||||
let result = hex_encode(mac.finalize().into_bytes());
|
||||
assert_eq!(
|
||||
result,
|
||||
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
|
||||
);
|
||||
}
|
||||
|
||||
// ── SHA256 verification ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_sha256_empty() {
|
||||
let hash = hex_encode(Sha256::digest(b""));
|
||||
assert_eq!(
|
||||
hash,
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha256_hello() {
|
||||
let hash = hex_encode(Sha256::digest(b"hello"));
|
||||
assert_eq!(
|
||||
hash,
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Additional CheckResult tests ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_check_result_ok_empty_detail() {
|
||||
let r = CheckResult::ok("test", "ns", "svc", "");
|
||||
assert!(r.passed);
|
||||
assert!(r.detail.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_result_fail_contains_status_code() {
|
||||
let r = CheckResult::fail("gitea-version", "devtools", "gitea", "HTTP 502");
|
||||
assert!(!r.passed);
|
||||
assert!(r.detail.contains("502"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_result_fail_contains_secret_message() {
|
||||
let r = CheckResult::fail(
|
||||
"gitea-auth",
|
||||
"devtools",
|
||||
"gitea",
|
||||
"password not found in secret",
|
||||
);
|
||||
assert!(!r.passed);
|
||||
assert!(r.detail.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_result_ok_with_version() {
|
||||
let r = CheckResult::ok("gitea-version", "devtools", "gitea", "v1.21.0");
|
||||
assert!(r.passed);
|
||||
assert!(r.detail.contains("1.21.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_result_ok_with_login() {
|
||||
let r = CheckResult::ok("gitea-auth", "devtools", "gitea", "user=gitea_admin");
|
||||
assert!(r.passed);
|
||||
assert!(r.detail.contains("gitea_admin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_result_ok_authenticated() {
|
||||
let r = CheckResult::ok("seaweedfs", "storage", "seaweedfs", "S3 authenticated");
|
||||
assert!(r.passed);
|
||||
assert!(r.detail.contains("authenticated"));
|
||||
}
|
||||
|
||||
// ── Additional registry tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_check_registry_expected_namespaces() {
|
||||
let registry = check_registry();
|
||||
let namespaces: std::collections::HashSet<&str> =
|
||||
registry.iter().map(|e| e.ns).collect();
|
||||
for expected in &["devtools", "data", "storage", "ory", "lasuite", "media"] {
|
||||
assert!(
|
||||
namespaces.contains(expected),
|
||||
"registry missing namespace: {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_registry_expected_services() {
|
||||
let registry = check_registry();
|
||||
let services: std::collections::HashSet<&str> =
|
||||
registry.iter().map(|e| e.svc).collect();
|
||||
for expected in &[
|
||||
"gitea", "postgres", "valkey", "openbao", "seaweedfs", "kratos", "hydra",
|
||||
"people", "livekit",
|
||||
] {
|
||||
assert!(
|
||||
services.contains(expected),
|
||||
"registry missing service: {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_registry_devtools_has_two_gitea_entries() {
|
||||
let registry = check_registry();
|
||||
let gitea: Vec<_> = registry
|
||||
.iter()
|
||||
.filter(|e| e.ns == "devtools" && e.svc == "gitea")
|
||||
.collect();
|
||||
assert_eq!(gitea.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_registry_lasuite_has_two_people_entries() {
|
||||
let registry = check_registry();
|
||||
let people: Vec<_> = registry
|
||||
.iter()
|
||||
.filter(|e| e.ns == "lasuite" && e.svc == "people")
|
||||
.collect();
|
||||
assert_eq!(people.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_registry_data_has_three_entries() {
|
||||
let registry = check_registry();
|
||||
let data: Vec<_> = registry.iter().filter(|e| e.ns == "data").collect();
|
||||
assert_eq!(data.len(), 3); // postgres, valkey, openbao
|
||||
}
|
||||
|
||||
// ── Filter logic (mirrors Python TestCmdCheck) ────────────────────
|
||||
|
||||
/// Helper: apply the same filter logic as cmd_check to the registry.
|
||||
fn filter_registry(
|
||||
ns_filter: Option<&str>,
|
||||
svc_filter: Option<&str>,
|
||||
) -> Vec<(&'static str, &'static str)> {
|
||||
let all = check_registry();
|
||||
all.into_iter()
|
||||
.filter(|e| ns_filter.map_or(true, |ns| e.ns == ns))
|
||||
.filter(|e| svc_filter.map_or(true, |svc| e.svc == svc))
|
||||
.map(|e| (e.ns, e.svc))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_target_runs_all() {
|
||||
let selected = filter_registry(None, None);
|
||||
assert_eq!(selected.len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ns_filter_devtools_selects_two() {
|
||||
let selected = filter_registry(Some("devtools"), None);
|
||||
assert_eq!(selected.len(), 2);
|
||||
assert!(selected.iter().all(|(ns, _)| *ns == "devtools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ns_filter_skips_other_namespaces() {
|
||||
let selected = filter_registry(Some("devtools"), None);
|
||||
// Should NOT contain data/postgres
|
||||
assert!(selected.iter().all(|(ns, _)| *ns != "data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_svc_filter_ory_kratos() {
|
||||
let selected = filter_registry(Some("ory"), Some("kratos"));
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0], ("ory", "kratos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_svc_filter_ory_hydra() {
|
||||
let selected = filter_registry(Some("ory"), Some("hydra"));
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0], ("ory", "hydra"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_svc_filter_people_returns_both() {
|
||||
let selected = filter_registry(Some("lasuite"), Some("people"));
|
||||
assert_eq!(selected.len(), 2);
|
||||
assert!(selected.iter().all(|(ns, svc)| *ns == "lasuite" && *svc == "people"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_nonexistent_ns_returns_empty() {
|
||||
let selected = filter_registry(Some("nonexistent"), None);
|
||||
assert!(selected.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_ns_match_svc_mismatch_returns_empty() {
|
||||
// ory namespace exists but postgres service does not live there
|
||||
let selected = filter_registry(Some("ory"), Some("postgres"));
|
||||
assert!(selected.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_data_namespace() {
|
||||
let selected = filter_registry(Some("data"), None);
|
||||
assert_eq!(selected.len(), 3);
|
||||
let svcs: Vec<&str> = selected.iter().map(|(_, svc)| *svc).collect();
|
||||
assert!(svcs.contains(&"postgres"));
|
||||
assert!(svcs.contains(&"valkey"));
|
||||
assert!(svcs.contains(&"openbao"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_storage_namespace() {
|
||||
let selected = filter_registry(Some("storage"), None);
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0], ("storage", "seaweedfs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_media_namespace() {
|
||||
let selected = filter_registry(Some("media"), None);
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0], ("media", "livekit"));
|
||||
}
|
||||
|
||||
// ── S3 auth AWS reference vector test ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_s3_auth_headers_aws_reference_vector() {
|
||||
// Uses AWS test values with a fixed timestamp to verify signature
|
||||
// correctness against a known reference (AWS SigV4 documentation).
|
||||
use chrono::TimeZone;
|
||||
|
||||
let access_key = "AKIAIOSFODNN7EXAMPLE";
|
||||
let secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
|
||||
let host = "examplebucket.s3.amazonaws.com";
|
||||
let now = chrono::Utc.with_ymd_and_hms(2013, 5, 24, 0, 0, 0).unwrap();
|
||||
|
||||
let (auth, amzdate) = s3_auth_headers_at(access_key, secret_key, host, now);
|
||||
|
||||
// 1. Verify the date header
|
||||
assert_eq!(amzdate, "20130524T000000Z");
|
||||
|
||||
// 2. Verify canonical request intermediate values.
|
||||
// Canonical request for GET / with empty body:
|
||||
// GET\n/\n\nhost:examplebucket.s3.amazonaws.com\n
|
||||
// x-amz-date:20130524T000000Z\n\nhost;x-amz-date\n<sha256("")>
|
||||
let payload_hash =
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
let canonical = format!(
|
||||
"GET\n/\n\nhost:{host}\nx-amz-date:{amzdate}\n\nhost;x-amz-date\n{payload_hash}"
|
||||
);
|
||||
let canonical_hash = hex_encode(&Sha256::digest(canonical.as_bytes()));
|
||||
|
||||
// 3. Verify the string to sign
|
||||
let credential_scope = "20130524/us-east-1/s3/aws4_request";
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{amzdate}\n{credential_scope}\n{canonical_hash}"
|
||||
);
|
||||
|
||||
// 4. Compute the expected signing key and signature to pin the value.
|
||||
fn hmac_sign(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(msg);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
let k = hmac_sign(
|
||||
format!("AWS4{secret_key}").as_bytes(),
|
||||
b"20130524",
|
||||
);
|
||||
let k = hmac_sign(&k, b"us-east-1");
|
||||
let k = hmac_sign(&k, b"s3");
|
||||
let k = hmac_sign(&k, b"aws4_request");
|
||||
|
||||
let expected_sig = {
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(&k).expect("HMAC accepts any key length");
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
hex_encode(&mac.finalize().into_bytes())
|
||||
};
|
||||
|
||||
// 5. Verify the full Authorization header matches
|
||||
let expected_auth = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \
|
||||
SignedHeaders=host;x-amz-date, Signature={expected_sig}"
|
||||
);
|
||||
assert_eq!(auth, expected_auth);
|
||||
|
||||
// 6. Pin the exact signature value so any regression is caught
|
||||
// immediately without needing to recompute.
|
||||
let sig = auth.split("Signature=").nth(1).unwrap();
|
||||
assert_eq!(sig, expected_sig);
|
||||
assert_eq!(sig.len(), 64, "SHA-256 HMAC signature must be 64 hex chars");
|
||||
}
|
||||
|
||||
// ── Additional S3 auth header tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_s3_auth_headers_deterministic() {
|
||||
// Same inputs at the same point in time produce identical output.
|
||||
// (Time may advance between calls, but the format is still valid.)
|
||||
let (auth1, date1) = s3_auth_headers("AK", "SK", "host");
|
||||
let (auth2, date2) = s3_auth_headers("AK", "SK", "host");
|
||||
// If both calls happen within the same second, they must be identical.
|
||||
if date1 == date2 {
|
||||
assert_eq!(auth1, auth2, "same inputs at same time must produce same signature");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_s3_auth_headers_different_hosts_differ() {
|
||||
let (auth1, d1) = s3_auth_headers("AK", "SK", "s3.a.com");
|
||||
let (auth2, d2) = s3_auth_headers("AK", "SK", "s3.b.com");
|
||||
let sig1 = auth1.split("Signature=").nth(1).unwrap();
|
||||
let sig2 = auth2.split("Signature=").nth(1).unwrap();
|
||||
// Different hosts -> different canonical request -> different signature
|
||||
// (only guaranteed when timestamps match)
|
||||
if d1 == d2 {
|
||||
assert_ne!(sig1, sig2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_s3_auth_headers_signature_is_64_hex_chars() {
|
||||
let (auth, _) = s3_auth_headers("AK", "SK", "host");
|
||||
let sig = auth.split("Signature=").nth(1).unwrap();
|
||||
assert_eq!(sig.len(), 64, "SHA-256 HMAC hex signature is 64 chars");
|
||||
assert!(
|
||||
sig.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"signature must be lowercase hex: {sig}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── hex_encode edge cases ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_hex_encode_all_byte_values() {
|
||||
// Verify 0x00..0xff all produce 2-char lowercase hex
|
||||
for b in 0u8..=255 {
|
||||
let encoded = hex_encode([b]);
|
||||
assert_eq!(encoded.len(), 2);
|
||||
assert!(encoded.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_encode_matches_format() {
|
||||
// Cross-check against Rust's built-in formatting
|
||||
let bytes: Vec<u8> = (0..32).collect();
|
||||
let expected: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(hex_encode(&bytes), expected);
|
||||
}
|
||||
}
|
||||
433
sunbeam-sdk/src/checks/probes.rs
Normal file
433
sunbeam-sdk/src/checks/probes.rs
Normal file
@@ -0,0 +1,433 @@
|
||||
//! Individual service health check probe functions.
|
||||
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use k8s_openapi::api::core::v1::Pod;
|
||||
use kube::api::{Api, ListParams};
|
||||
use kube::ResourceExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{CheckResult, http_get, kube_secret};
|
||||
use crate::kube::{get_client, kube_exec};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/v1/version -> JSON with version field.
|
||||
pub(super) async fn check_gitea_version(domain: &str, client: &reqwest::Client) -> CheckResult {
|
||||
let url = format!("https://src.{domain}/api/v1/version");
|
||||
match http_get(client, &url, None).await {
|
||||
Ok((200, body)) => {
|
||||
let ver = serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("version").and_then(|v| v.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| "?".into());
|
||||
CheckResult::ok("gitea-version", "devtools", "gitea", &format!("v{ver}"))
|
||||
}
|
||||
Ok((status, _)) => {
|
||||
CheckResult::fail("gitea-version", "devtools", "gitea", &format!("HTTP {status}"))
|
||||
}
|
||||
Err(e) => CheckResult::fail("gitea-version", "devtools", "gitea", &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1/user with admin credentials -> 200 and login field.
|
||||
pub(super) async fn check_gitea_auth(domain: &str, client: &reqwest::Client) -> CheckResult {
|
||||
let username = {
|
||||
let u = kube_secret("devtools", "gitea-admin-credentials", "username").await;
|
||||
if u.is_empty() {
|
||||
"gitea_admin".to_string()
|
||||
} else {
|
||||
u
|
||||
}
|
||||
};
|
||||
let password =
|
||||
kube_secret("devtools", "gitea-admin-credentials", "password").await;
|
||||
if password.is_empty() {
|
||||
return CheckResult::fail(
|
||||
"gitea-auth",
|
||||
"devtools",
|
||||
"gitea",
|
||||
"password not found in secret",
|
||||
);
|
||||
}
|
||||
|
||||
let creds =
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
|
||||
let auth_hdr = format!("Basic {creds}");
|
||||
let url = format!("https://src.{domain}/api/v1/user");
|
||||
|
||||
match http_get(client, &url, Some(&[("Authorization", &auth_hdr)])).await {
|
||||
Ok((200, body)) => {
|
||||
let login = serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("login").and_then(|v| v.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| "?".into());
|
||||
CheckResult::ok("gitea-auth", "devtools", "gitea", &format!("user={login}"))
|
||||
}
|
||||
Ok((status, _)) => {
|
||||
CheckResult::fail("gitea-auth", "devtools", "gitea", &format!("HTTP {status}"))
|
||||
}
|
||||
Err(e) => CheckResult::fail("gitea-auth", "devtools", "gitea", &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// CNPG Cluster readyInstances == instances.
|
||||
pub(super) async fn check_postgres(_domain: &str, _client: &reqwest::Client) -> CheckResult {
|
||||
let kube_client = match get_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return CheckResult::fail("postgres", "data", "postgres", &format!("{e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let ar = kube::api::ApiResource {
|
||||
group: "postgresql.cnpg.io".into(),
|
||||
version: "v1".into(),
|
||||
api_version: "postgresql.cnpg.io/v1".into(),
|
||||
kind: "Cluster".into(),
|
||||
plural: "clusters".into(),
|
||||
};
|
||||
|
||||
let api: Api<kube::api::DynamicObject> =
|
||||
Api::namespaced_with(kube_client.clone(), "data", &ar);
|
||||
|
||||
match api.get_opt("postgres").await {
|
||||
Ok(Some(obj)) => {
|
||||
let ready = obj
|
||||
.data
|
||||
.get("status")
|
||||
.and_then(|s| s.get("readyInstances"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default();
|
||||
let total = obj
|
||||
.data
|
||||
.get("status")
|
||||
.and_then(|s| s.get("instances"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !ready.is_empty() && !total.is_empty() && ready == total {
|
||||
CheckResult::ok(
|
||||
"postgres",
|
||||
"data",
|
||||
"postgres",
|
||||
&format!("{ready}/{total} ready"),
|
||||
)
|
||||
} else {
|
||||
let r = if ready.is_empty() { "?" } else { &ready };
|
||||
let t = if total.is_empty() { "?" } else { &total };
|
||||
CheckResult::fail("postgres", "data", "postgres", &format!("{r}/{t} ready"))
|
||||
}
|
||||
}
|
||||
Ok(None) => CheckResult::fail("postgres", "data", "postgres", "cluster not found"),
|
||||
Err(e) => CheckResult::fail("postgres", "data", "postgres", &format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// kubectl exec valkey pod -- valkey-cli ping -> PONG.
|
||||
pub(super) async fn check_valkey(_domain: &str, _client: &reqwest::Client) -> CheckResult {
|
||||
let kube_client = match get_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return CheckResult::fail("valkey", "data", "valkey", &format!("{e}")),
|
||||
};
|
||||
|
||||
let api: Api<Pod> = Api::namespaced(kube_client.clone(), "data");
|
||||
let lp = ListParams::default().labels("app=valkey");
|
||||
let pod_list = match api.list(&lp).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => return CheckResult::fail("valkey", "data", "valkey", &format!("{e}")),
|
||||
};
|
||||
|
||||
let pod_name = match pod_list.items.first() {
|
||||
Some(p) => p.name_any(),
|
||||
None => return CheckResult::fail("valkey", "data", "valkey", "no valkey pod"),
|
||||
};
|
||||
|
||||
match kube_exec("data", &pod_name, &["valkey-cli", "ping"], Some("valkey")).await {
|
||||
Ok((_, out)) => {
|
||||
let passed = out == "PONG";
|
||||
let detail = if out.is_empty() {
|
||||
"no response".to_string()
|
||||
} else {
|
||||
out
|
||||
};
|
||||
CheckResult {
|
||||
name: "valkey".into(),
|
||||
ns: "data".into(),
|
||||
svc: "valkey".into(),
|
||||
passed,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
Err(e) => CheckResult::fail("valkey", "data", "valkey", &format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// kubectl exec openbao-0 -- bao status -format=json -> initialized + unsealed.
|
||||
pub(super) async fn check_openbao(_domain: &str, _client: &reqwest::Client) -> CheckResult {
|
||||
match kube_exec(
|
||||
"data",
|
||||
"openbao-0",
|
||||
&["bao", "status", "-format=json"],
|
||||
Some("openbao"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((_, out)) => {
|
||||
if out.is_empty() {
|
||||
return CheckResult::fail("openbao", "data", "openbao", "no response");
|
||||
}
|
||||
match serde_json::from_str::<serde_json::Value>(&out) {
|
||||
Ok(data) => {
|
||||
let init = data
|
||||
.get("initialized")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let sealed = data
|
||||
.get("sealed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let passed = init && !sealed;
|
||||
CheckResult {
|
||||
name: "openbao".into(),
|
||||
ns: "data".into(),
|
||||
svc: "openbao".into(),
|
||||
passed,
|
||||
detail: format!("init={init}, sealed={sealed}"),
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let truncated: String = out.chars().take(80).collect();
|
||||
CheckResult::fail("openbao", "data", "openbao", &truncated)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => CheckResult::fail("openbao", "data", "openbao", &format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3 auth (AWS4-HMAC-SHA256)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate AWS4-HMAC-SHA256 Authorization and x-amz-date headers for an unsigned
|
||||
/// GET / request, matching the Python `_s3_auth_headers` function exactly.
|
||||
pub(crate) fn s3_auth_headers(access_key: &str, secret_key: &str, host: &str) -> (String, String) {
|
||||
s3_auth_headers_at(access_key, secret_key, host, chrono::Utc::now())
|
||||
}
|
||||
|
||||
/// Deterministic inner implementation that accepts an explicit timestamp.
|
||||
pub(crate) fn s3_auth_headers_at(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
host: &str,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) -> (String, String) {
|
||||
let amzdate = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let datestamp = now.format("%Y%m%d").to_string();
|
||||
|
||||
let payload_hash = hex_encode(&Sha256::digest(b""));
|
||||
let canonical = format!(
|
||||
"GET\n/\n\nhost:{host}\nx-amz-date:{amzdate}\n\nhost;x-amz-date\n{payload_hash}"
|
||||
);
|
||||
let credential_scope = format!("{datestamp}/us-east-1/s3/aws4_request");
|
||||
let canonical_hash = hex_encode(&Sha256::digest(canonical.as_bytes()));
|
||||
let string_to_sign =
|
||||
format!("AWS4-HMAC-SHA256\n{amzdate}\n{credential_scope}\n{canonical_hash}");
|
||||
|
||||
fn hmac_sign(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(msg);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
let k = hmac_sign(
|
||||
format!("AWS4{secret_key}").as_bytes(),
|
||||
datestamp.as_bytes(),
|
||||
);
|
||||
let k = hmac_sign(&k, b"us-east-1");
|
||||
let k = hmac_sign(&k, b"s3");
|
||||
let k = hmac_sign(&k, b"aws4_request");
|
||||
|
||||
let sig = {
|
||||
let mut mac = HmacSha256::new_from_slice(&k).expect("HMAC accepts any key length");
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
hex_encode(&mac.finalize().into_bytes())
|
||||
};
|
||||
|
||||
let auth = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders=host;x-amz-date, Signature={sig}"
|
||||
);
|
||||
(auth, amzdate)
|
||||
}
|
||||
|
||||
/// GET https://s3.{domain}/ with S3 credentials -> 200 list-buckets response.
|
||||
pub(super) async fn check_seaweedfs(domain: &str, client: &reqwest::Client) -> CheckResult {
|
||||
let access_key =
|
||||
kube_secret("storage", "seaweedfs-s3-credentials", "S3_ACCESS_KEY").await;
|
||||
let secret_key =
|
||||
kube_secret("storage", "seaweedfs-s3-credentials", "S3_SECRET_KEY").await;
|
||||
|
||||
if access_key.is_empty() || secret_key.is_empty() {
|
||||
return CheckResult::fail(
|
||||
"seaweedfs",
|
||||
"storage",
|
||||
"seaweedfs",
|
||||
"credentials not found in seaweedfs-s3-credentials secret",
|
||||
);
|
||||
}
|
||||
|
||||
let host = format!("s3.{domain}");
|
||||
let url = format!("https://{host}/");
|
||||
let (auth, amzdate) = s3_auth_headers(&access_key, &secret_key, &host);
|
||||
|
||||
match http_get(
|
||||
client,
|
||||
&url,
|
||||
Some(&[("Authorization", &auth), ("x-amz-date", &amzdate)]),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((200, _)) => {
|
||||
CheckResult::ok("seaweedfs", "storage", "seaweedfs", "S3 authenticated")
|
||||
}
|
||||
Ok((status, _)) => CheckResult::fail(
|
||||
"seaweedfs",
|
||||
"storage",
|
||||
"seaweedfs",
|
||||
&format!("HTTP {status}"),
|
||||
),
|
||||
Err(e) => CheckResult::fail("seaweedfs", "storage", "seaweedfs", &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /kratos/health/ready -> 200.
|
||||
pub(super) async fn check_kratos(domain: &str, client: &reqwest::Client) -> CheckResult {
|
||||
let url = format!("https://auth.{domain}/kratos/health/ready");
|
||||
match http_get(client, &url, None).await {
|
||||
Ok((status, body)) => {
|
||||
let ok_flag = status == 200;
|
||||
let mut detail = format!("HTTP {status}");
|
||||
if !ok_flag && !body.is_empty() {
|
||||
let body_str: String =
|
||||
String::from_utf8_lossy(&body).chars().take(80).collect();
|
||||
detail = format!("{detail}: {body_str}");
|
||||
}
|
||||
CheckResult {
|
||||
name: "kratos".into(),
|
||||
ns: "ory".into(),
|
||||
svc: "kratos".into(),
|
||||
passed: ok_flag,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
Err(e) => CheckResult::fail("kratos", "ory", "kratos", &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /.well-known/openid-configuration -> 200 with issuer field.
|
||||
pub(super) async fn check_hydra_oidc(domain: &str, client: &reqwest::Client) -> CheckResult {
|
||||
let url = format!("https://auth.{domain}/.well-known/openid-configuration");
|
||||
match http_get(client, &url, None).await {
|
||||
Ok((200, body)) => {
|
||||
let issuer = serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("issuer").and_then(|v| v.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| "?".into());
|
||||
CheckResult::ok("hydra-oidc", "ory", "hydra", &format!("issuer={issuer}"))
|
||||
}
|
||||
Ok((status, _)) => {
|
||||
CheckResult::fail("hydra-oidc", "ory", "hydra", &format!("HTTP {status}"))
|
||||
}
|
||||
Err(e) => CheckResult::fail("hydra-oidc", "ory", "hydra", &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET https://people.{domain}/ -> any response < 500 (302 to OIDC is fine).
|
||||
pub(super) async fn check_people(domain: &str, client: &reqwest::Client) -> CheckResult {
|
||||
let url = format!("https://people.{domain}/");
|
||||
match http_get(client, &url, None).await {
|
||||
Ok((status, _)) => CheckResult {
|
||||
name: "people".into(),
|
||||
ns: "lasuite".into(),
|
||||
svc: "people".into(),
|
||||
passed: status < 500,
|
||||
detail: format!("HTTP {status}"),
|
||||
},
|
||||
Err(e) => CheckResult::fail("people", "lasuite", "people", &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1.0/config/ -> any response < 500 (401 auth-required is fine).
|
||||
pub(super) async fn check_people_api(domain: &str, client: &reqwest::Client) -> CheckResult {
|
||||
let url = format!("https://people.{domain}/api/v1.0/config/");
|
||||
match http_get(client, &url, None).await {
|
||||
Ok((status, _)) => CheckResult {
|
||||
name: "people-api".into(),
|
||||
ns: "lasuite".into(),
|
||||
svc: "people".into(),
|
||||
passed: status < 500,
|
||||
detail: format!("HTTP {status}"),
|
||||
},
|
||||
Err(e) => CheckResult::fail("people-api", "lasuite", "people", &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// kubectl exec livekit-server pod -- wget localhost:7880/ -> rc 0.
|
||||
pub(super) async fn check_livekit(_domain: &str, _client: &reqwest::Client) -> CheckResult {
|
||||
let kube_client = match get_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return CheckResult::fail("livekit", "media", "livekit", &format!("{e}")),
|
||||
};
|
||||
|
||||
let api: Api<Pod> = Api::namespaced(kube_client.clone(), "media");
|
||||
let lp = ListParams::default().labels("app.kubernetes.io/name=livekit-server");
|
||||
let pod_list = match api.list(&lp).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => return CheckResult::fail("livekit", "media", "livekit", &format!("{e}")),
|
||||
};
|
||||
|
||||
let pod_name = match pod_list.items.first() {
|
||||
Some(p) => p.name_any(),
|
||||
None => return CheckResult::fail("livekit", "media", "livekit", "no livekit pod"),
|
||||
};
|
||||
|
||||
match kube_exec(
|
||||
"media",
|
||||
&pod_name,
|
||||
&["wget", "-qO-", "http://localhost:7880/"],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((exit_code, _)) => {
|
||||
if exit_code == 0 {
|
||||
CheckResult::ok("livekit", "media", "livekit", "server responding")
|
||||
} else {
|
||||
CheckResult::fail("livekit", "media", "livekit", "server not responding")
|
||||
}
|
||||
}
|
||||
Err(e) => CheckResult::fail("livekit", "media", "livekit", &format!("{e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hex encoding helper (avoids adding the `hex` crate)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
|
||||
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
|
||||
let bytes = bytes.as_ref();
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for &b in bytes {
|
||||
s.push(HEX_CHARS[(b >> 4) as usize] as char);
|
||||
s.push(HEX_CHARS[(b & 0xf) as usize] as char);
|
||||
}
|
||||
s
|
||||
}
|
||||
420
sunbeam-sdk/src/pm/gitea_issues.rs
Normal file
420
sunbeam-sdk/src/pm/gitea_issues.rs
Normal file
@@ -0,0 +1,420 @@
|
||||
//! Gitea issues client.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Result, SunbeamError};
|
||||
use super::{Ticket, Source, Status};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueUpdate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Update payload for a Gitea issue.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct IssueUpdate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GiteaClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(super) struct GiteaClient {
|
||||
base_url: String,
|
||||
token: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
/// Serde helpers for Gitea JSON responses.
|
||||
pub(super) mod gitea_json {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Issue {
|
||||
pub number: u64,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
#[serde(default)]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub assignees: Option<Vec<GiteaUser>>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<GiteaLabel>>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub html_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub repository: Option<Repository>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GiteaUser {
|
||||
#[serde(default)]
|
||||
pub login: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GiteaLabel {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Repository {
|
||||
#[serde(default)]
|
||||
pub full_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Issue {
|
||||
pub fn to_ticket(self, base_url: &str, org: &str, repo: &str) -> Ticket {
|
||||
let status = state_to_status(&self.state);
|
||||
let assignees = self
|
||||
.assignees
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|u| u.login)
|
||||
.collect();
|
||||
let labels = self
|
||||
.labels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|l| l.name)
|
||||
.collect();
|
||||
let web_base = base_url.trim_end_matches("/api/v1");
|
||||
let url = self
|
||||
.html_url
|
||||
.unwrap_or_else(|| format!("{web_base}/{org}/{repo}/issues/{}", self.number));
|
||||
|
||||
Ticket {
|
||||
id: format!("g:{org}/{repo}#{}", self.number),
|
||||
source: Source::Gitea,
|
||||
title: self.title,
|
||||
description: self.body.unwrap_or_default(),
|
||||
status,
|
||||
assignees,
|
||||
labels,
|
||||
created_at: self.created_at.unwrap_or_default(),
|
||||
updated_at: self.updated_at.unwrap_or_default(),
|
||||
url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map Gitea issue state to normalised status.
|
||||
pub fn state_to_status(state: &str) -> Status {
|
||||
match state {
|
||||
"open" => Status::Open,
|
||||
"closed" => Status::Closed,
|
||||
_ => Status::Open,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GiteaClient {
|
||||
/// Create a new Gitea client using the Hydra OAuth2 token.
|
||||
pub(super) async fn new(domain: &str) -> Result<Self> {
|
||||
let base_url = format!("https://src.{domain}/api/v1");
|
||||
// Gitea needs its own PAT, not the Hydra access token
|
||||
let token = crate::auth::get_gitea_token()?;
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| SunbeamError::network(format!("Failed to build HTTP client: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
token,
|
||||
http,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a request with Gitea PAT auth (`Authorization: token <pat>`).
|
||||
#[allow(dead_code)]
|
||||
fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
|
||||
self.http
|
||||
.get(url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
|
||||
self.http
|
||||
.post(url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn authed_patch(&self, url: &str) -> reqwest::RequestBuilder {
|
||||
self.http
|
||||
.patch(url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
}
|
||||
|
||||
/// List issues for an org/repo (or search across an org).
|
||||
pub(super) fn list_issues<'a>(
|
||||
&'a self,
|
||||
org: &'a str,
|
||||
repo: Option<&'a str>,
|
||||
state: &'a str,
|
||||
) -> futures::future::BoxFuture<'a, Result<Vec<Ticket>>> {
|
||||
Box::pin(self.list_issues_inner(org, repo, state))
|
||||
}
|
||||
|
||||
async fn list_issues_inner(
|
||||
&self,
|
||||
org: &str,
|
||||
repo: Option<&str>,
|
||||
state: &str,
|
||||
) -> Result<Vec<Ticket>> {
|
||||
match repo {
|
||||
Some(r) => {
|
||||
let url = format!("{}/repos/{org}/{r}/issues", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.query(&[("state", state), ("type", "issues"), ("limit", "50")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea list_issues: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Gitea GET issues for {org}/{r} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let issues: Vec<gitea_json::Issue> = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea issues parse error: {e}")))?;
|
||||
|
||||
Ok(issues
|
||||
.into_iter()
|
||||
.map(|i| i.to_ticket(&self.base_url, org, r))
|
||||
.collect())
|
||||
}
|
||||
None => {
|
||||
// Search across the entire org by listing org repos, then issues.
|
||||
let repos_url = format!("{}/orgs/{org}/repos", self.base_url);
|
||||
let repos_resp = self
|
||||
.http
|
||||
.get(&repos_url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.query(&[("limit", "50")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea list org repos: {e}")))?;
|
||||
|
||||
if !repos_resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Gitea GET repos for org {org} returned {}",
|
||||
repos_resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Repo {
|
||||
name: String,
|
||||
}
|
||||
let repos: Vec<Repo> = repos_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea repos parse: {e}")))?;
|
||||
|
||||
let mut all = Vec::new();
|
||||
for r in &repos {
|
||||
match self.list_issues(org, Some(&r.name), state).await {
|
||||
Ok(mut tickets) => all.append(&mut tickets),
|
||||
Err(_) => continue, // skip repos we cannot read
|
||||
}
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1/repos/{org}/{repo}/issues/{index}
|
||||
pub(super) async fn get_issue(&self, org: &str, repo: &str, index: u64) -> Result<Ticket> {
|
||||
let url = format!("{}/repos/{org}/{repo}/issues/{index}", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea get_issue: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Gitea GET issue {org}/{repo}#{index} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let issue: gitea_json::Issue = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea issue parse: {e}")))?;
|
||||
|
||||
Ok(issue.to_ticket(&self.base_url, org, repo))
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/{org}/{repo}/issues
|
||||
pub(super) async fn create_issue(
|
||||
&self,
|
||||
org: &str,
|
||||
repo: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) -> Result<Ticket> {
|
||||
let url = format!("{}/repos/{org}/{repo}/issues", self.base_url);
|
||||
let payload = serde_json::json!({
|
||||
"title": title,
|
||||
"body": body,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea create_issue: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Gitea POST issue to {org}/{repo} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let issue: gitea_json::Issue = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea issue create parse: {e}")))?;
|
||||
|
||||
Ok(issue.to_ticket(&self.base_url, org, repo))
|
||||
}
|
||||
|
||||
/// PATCH /api/v1/repos/{org}/{repo}/issues/{index}
|
||||
pub(super) async fn update_issue(
|
||||
&self,
|
||||
org: &str,
|
||||
repo: &str,
|
||||
index: u64,
|
||||
updates: &IssueUpdate,
|
||||
) -> Result<()> {
|
||||
let url = format!("{}/repos/{org}/{repo}/issues/{index}", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.json(updates)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea update_issue: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Gitea PATCH issue {org}/{repo}#{index} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close an issue.
|
||||
pub(super) async fn close_issue(&self, org: &str, repo: &str, index: u64) -> Result<()> {
|
||||
self.update_issue(
|
||||
org,
|
||||
repo,
|
||||
index,
|
||||
&IssueUpdate {
|
||||
state: Some("closed".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/{org}/{repo}/issues/{index}/comments
|
||||
pub(super) async fn comment_issue(
|
||||
&self,
|
||||
org: &str,
|
||||
repo: &str,
|
||||
index: u64,
|
||||
body: &str,
|
||||
) -> Result<()> {
|
||||
let url = format!(
|
||||
"{}/repos/{org}/{repo}/issues/{index}/comments",
|
||||
self.base_url
|
||||
);
|
||||
let payload = serde_json::json!({ "body": body });
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea comment_issue: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Gitea POST comment on {org}/{repo}#{index} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/v1/repos/{org}/{repo}/issues/{index}/assignees
|
||||
#[allow(dead_code)]
|
||||
pub(super) async fn assign_issue(
|
||||
&self,
|
||||
org: &str,
|
||||
repo: &str,
|
||||
index: u64,
|
||||
assignee: &str,
|
||||
) -> Result<()> {
|
||||
// Use PATCH on the issue itself -- the /assignees endpoint requires
|
||||
// the user to be an explicit collaborator, while PATCH works for
|
||||
// any org member with write access.
|
||||
let url = format!(
|
||||
"{}/repos/{org}/{repo}/issues/{index}",
|
||||
self.base_url
|
||||
);
|
||||
let payload = serde_json::json!({ "assignees": [assignee] });
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Gitea assign_issue: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Gitea assign on {org}/{repo}#{index} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
729
sunbeam-sdk/src/pm/mod.rs
Normal file
729
sunbeam-sdk/src/pm/mod.rs
Normal file
@@ -0,0 +1,729 @@
|
||||
//! Unified project management across Planka (kanban boards) and Gitea (issues).
|
||||
//!
|
||||
//! Ticket IDs use a prefix format:
|
||||
//! - `p:42` or `planka:42` -- Planka card
|
||||
//! - `g:studio/cli#7` or `gitea:studio/cli#7` -- Gitea issue
|
||||
|
||||
mod planka;
|
||||
mod gitea_issues;
|
||||
|
||||
use planka::PlankaClient;
|
||||
use gitea_issues::GiteaClient;
|
||||
|
||||
use crate::error::{Result, ResultExt, SunbeamError};
|
||||
use crate::output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Unified ticket representation across both systems.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ticket {
|
||||
pub id: String,
|
||||
pub source: Source,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: Status,
|
||||
pub assignees: Vec<String>,
|
||||
pub labels: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Which backend a ticket originates from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
Planka,
|
||||
Gitea,
|
||||
}
|
||||
|
||||
/// Normalised ticket status across both systems.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Open,
|
||||
InProgress,
|
||||
Done,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Source {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Source::Planka => write!(f, "planka"),
|
||||
Source::Gitea => write!(f, "gitea"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Status {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Status::Open => write!(f, "open"),
|
||||
Status::InProgress => write!(f, "in-progress"),
|
||||
Status::Done => write!(f, "done"),
|
||||
Status::Closed => write!(f, "closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ticket ID parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A parsed ticket reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TicketRef {
|
||||
/// Planka card by ID (snowflake string).
|
||||
Planka(String),
|
||||
/// Gitea issue: (org, repo, issue number).
|
||||
Gitea {
|
||||
org: String,
|
||||
repo: String,
|
||||
number: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse a prefixed ticket ID string.
|
||||
///
|
||||
/// Accepted formats:
|
||||
/// - `p:42`, `planka:42`
|
||||
/// - `g:studio/cli#7`, `gitea:studio/cli#7`
|
||||
pub fn parse_ticket_id(id: &str) -> Result<TicketRef> {
|
||||
let (prefix, rest) = id
|
||||
.split_once(':')
|
||||
.ctx("Invalid ticket ID: expected 'p:ID' or 'g:org/repo#num'")?;
|
||||
|
||||
match prefix {
|
||||
"p" | "planka" => {
|
||||
if rest.is_empty() {
|
||||
return Err(SunbeamError::config("Empty Planka card ID"));
|
||||
}
|
||||
Ok(TicketRef::Planka(rest.to_string()))
|
||||
}
|
||||
"g" | "gitea" => {
|
||||
// Expected: org/repo#number
|
||||
let (org_repo, num_str) = rest
|
||||
.rsplit_once('#')
|
||||
.ctx("Invalid Gitea ticket ID: expected org/repo#number")?;
|
||||
let (org, repo) = org_repo
|
||||
.split_once('/')
|
||||
.ctx("Invalid Gitea ticket ID: expected org/repo#number")?;
|
||||
let number: u64 = num_str
|
||||
.parse()
|
||||
.map_err(|_| SunbeamError::config(format!("Invalid issue number: {num_str}")))?;
|
||||
Ok(TicketRef::Gitea {
|
||||
org: org.to_string(),
|
||||
repo: repo.to_string(),
|
||||
number,
|
||||
})
|
||||
}
|
||||
_ => Err(SunbeamError::config(format!(
|
||||
"Unknown ticket prefix '{prefix}': use 'p'/'planka' or 'g'/'gitea'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Retrieve the user's Hydra OAuth2 access token via the auth module.
|
||||
async fn get_token() -> Result<String> {
|
||||
crate::auth::get_token().await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a list of tickets as a table.
|
||||
fn display_ticket_list(tickets: &[Ticket]) {
|
||||
if tickets.is_empty() {
|
||||
output::ok("No tickets found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let rows: Vec<Vec<String>> = tickets
|
||||
.iter()
|
||||
.map(|t| {
|
||||
vec![
|
||||
t.id.clone(),
|
||||
t.status.to_string(),
|
||||
t.title.clone(),
|
||||
t.assignees.join(", "),
|
||||
t.source.to_string(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]);
|
||||
println!("{tbl}");
|
||||
}
|
||||
|
||||
/// Print a single ticket in detail.
|
||||
fn display_ticket_detail(t: &Ticket) {
|
||||
println!("{} ({})", t.title, t.id);
|
||||
println!(" Status: {}", t.status);
|
||||
println!(" Source: {}", t.source);
|
||||
if !t.assignees.is_empty() {
|
||||
println!(" Assignees: {}", t.assignees.join(", "));
|
||||
}
|
||||
if !t.labels.is_empty() {
|
||||
println!(" Labels: {}", t.labels.join(", "));
|
||||
}
|
||||
if !t.created_at.is_empty() {
|
||||
println!(" Created: {}", t.created_at);
|
||||
}
|
||||
if !t.updated_at.is_empty() {
|
||||
println!(" Updated: {}", t.updated_at);
|
||||
}
|
||||
println!(" URL: {}", t.url);
|
||||
if !t.description.is_empty() {
|
||||
println!();
|
||||
println!("{}", t.description);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List tickets, optionally filtering by source and state.
|
||||
///
|
||||
/// When `source` is `None`, both Planka and Gitea are queried in parallel.
|
||||
#[allow(dead_code)]
|
||||
pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> {
|
||||
let domain = crate::config::domain();
|
||||
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||
|
||||
let fetch_planka = source.is_none() || matches!(source, Some("planka" | "p"));
|
||||
let fetch_gitea = source.is_none() || matches!(source, Some("gitea" | "g"));
|
||||
|
||||
let planka_fut = async {
|
||||
if fetch_planka {
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
client.list_all_cards().await
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
};
|
||||
|
||||
let gitea_fut = async {
|
||||
if fetch_gitea {
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
client.list_issues("studio", None, state).await
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
};
|
||||
|
||||
let (planka_result, gitea_result) = tokio::join!(planka_fut, gitea_fut);
|
||||
|
||||
let mut tickets = Vec::new();
|
||||
|
||||
match planka_result {
|
||||
Ok(mut t) => tickets.append(&mut t),
|
||||
Err(e) => output::warn(&format!("Planka: {e}")),
|
||||
}
|
||||
|
||||
match gitea_result {
|
||||
Ok(mut t) => tickets.append(&mut t),
|
||||
Err(e) => output::warn(&format!("Gitea: {e}")),
|
||||
}
|
||||
|
||||
// Filter by state if looking at Planka results too.
|
||||
if state == "closed" {
|
||||
tickets.retain(|t| matches!(t.status, Status::Closed | Status::Done));
|
||||
} else if state == "open" {
|
||||
tickets.retain(|t| matches!(t.status, Status::Open | Status::InProgress));
|
||||
}
|
||||
|
||||
display_ticket_list(&tickets);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show details for a single ticket by ID.
|
||||
#[allow(dead_code)]
|
||||
pub async fn cmd_pm_show(id: &str) -> Result<()> {
|
||||
let domain = crate::config::domain();
|
||||
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||
let ticket_ref = parse_ticket_id(id)?;
|
||||
|
||||
let ticket = match ticket_ref {
|
||||
TicketRef::Planka(card_id) => {
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
client.get_card(&card_id).await?
|
||||
}
|
||||
TicketRef::Gitea { org, repo, number } => {
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
client.get_issue(&org, &repo, number).await?
|
||||
}
|
||||
};
|
||||
|
||||
display_ticket_detail(&ticket);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new ticket.
|
||||
///
|
||||
/// `source` must be `"planka"` or `"gitea"`.
|
||||
/// `target` is source-specific: for Planka it is `"board_id/list_id"`,
|
||||
/// for Gitea it is `"org/repo"`.
|
||||
#[allow(dead_code)]
|
||||
pub async fn cmd_pm_create(title: &str, body: &str, source: &str, target: &str) -> Result<()> {
|
||||
let domain = crate::config::domain();
|
||||
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||
|
||||
let ticket = match source {
|
||||
"planka" | "p" => {
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
|
||||
// Fetch all boards
|
||||
let projects_url = format!("{}/projects", client.base_url);
|
||||
let resp = client.http.get(&projects_url).bearer_auth(&client.token).send().await?;
|
||||
let projects_body: serde_json::Value = resp.json().await?;
|
||||
let boards = projects_body.get("included").and_then(|i| i.get("boards"))
|
||||
.and_then(|b| b.as_array())
|
||||
.ok_or_else(|| SunbeamError::config("No Planka boards found"))?;
|
||||
|
||||
// Find the board: by name (--target "Board Name") or by ID, or use first
|
||||
let board = if target.is_empty() {
|
||||
boards.first()
|
||||
} else {
|
||||
boards.iter().find(|b| {
|
||||
let name = b.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
name.eq_ignore_ascii_case(target) || id == target
|
||||
}).or_else(|| boards.first())
|
||||
}.ok_or_else(|| SunbeamError::config("No Planka boards found"))?;
|
||||
|
||||
let board_id = board.get("id").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| SunbeamError::config("Board has no ID"))?;
|
||||
let board_name = board.get("name").and_then(|n| n.as_str()).unwrap_or("?");
|
||||
|
||||
// Fetch the board to get its lists, use the first list
|
||||
let board_url = format!("{}/boards/{board_id}", client.base_url);
|
||||
let board_resp = client.http.get(&board_url).bearer_auth(&client.token).send().await?;
|
||||
let board_body: serde_json::Value = board_resp.json().await?;
|
||||
let list_id = board_body.get("included").and_then(|i| i.get("lists"))
|
||||
.and_then(|l| l.as_array()).and_then(|a| a.first())
|
||||
.and_then(|l| l.get("id")).and_then(|v| v.as_str())
|
||||
.ok_or_else(|| SunbeamError::config(format!("No lists in board '{board_name}'")))?;
|
||||
|
||||
client.create_card(board_id, list_id, title, body).await?
|
||||
}
|
||||
"gitea" | "g" => {
|
||||
if target.is_empty() {
|
||||
return Err(SunbeamError::config(
|
||||
"Gitea target required: --target org/repo (e.g. studio/marathon)",
|
||||
));
|
||||
}
|
||||
let parts: Vec<&str> = target.splitn(2, '/').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(SunbeamError::config("Gitea target must be 'org/repo'"));
|
||||
}
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
client.create_issue(parts[0], parts[1], title, body).await?
|
||||
}
|
||||
_ => {
|
||||
return Err(SunbeamError::config(format!(
|
||||
"Unknown source '{source}': use 'planka' or 'gitea'"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
output::ok(&format!("Created: {} ({})", ticket.title, ticket.id));
|
||||
println!(" {}", ticket.url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a comment to a ticket.
|
||||
#[allow(dead_code)]
|
||||
pub async fn cmd_pm_comment(id: &str, text: &str) -> Result<()> {
|
||||
let domain = crate::config::domain();
|
||||
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||
let ticket_ref = parse_ticket_id(id)?;
|
||||
|
||||
match ticket_ref {
|
||||
TicketRef::Planka(card_id) => {
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
client.comment_card(&card_id, text).await?;
|
||||
}
|
||||
TicketRef::Gitea { org, repo, number } => {
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
client.comment_issue(&org, &repo, number, text).await?;
|
||||
}
|
||||
}
|
||||
|
||||
output::ok(&format!("Comment added to {id}."));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close a ticket.
|
||||
#[allow(dead_code)]
|
||||
pub async fn cmd_pm_close(id: &str) -> Result<()> {
|
||||
let domain = crate::config::domain();
|
||||
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||
let ticket_ref = parse_ticket_id(id)?;
|
||||
|
||||
match ticket_ref {
|
||||
TicketRef::Planka(card_id) => {
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
// Get the card to find its board, then find a "Done"/"Closed" list
|
||||
let ticket = client.get_card(&card_id).await?;
|
||||
// Try to find the board and its lists
|
||||
let url = format!("{}/cards/{card_id}", client.base_url);
|
||||
let resp = client.http.get(&url).bearer_auth(&client.token).send().await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka get card: {e}")))?;
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let board_id = body.get("item").and_then(|i| i.get("boardId"))
|
||||
.and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if !board_id.is_empty() {
|
||||
// Fetch the board to get its lists
|
||||
let board_url = format!("{}/boards/{board_id}", client.base_url);
|
||||
let board_resp = client.http.get(&board_url).bearer_auth(&client.token).send().await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka get board: {e}")))?;
|
||||
let board_body: serde_json::Value = board_resp.json().await?;
|
||||
let lists = board_body.get("included")
|
||||
.and_then(|i| i.get("lists"))
|
||||
.and_then(|l| l.as_array());
|
||||
|
||||
if let Some(lists) = lists {
|
||||
// Find a list named "Done", "Closed", "Complete", or similar
|
||||
let done_list = lists.iter().find(|l| {
|
||||
let name = l.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase();
|
||||
name.contains("done") || name.contains("closed") || name.contains("complete")
|
||||
});
|
||||
|
||||
if let Some(done_list) = done_list {
|
||||
let list_id = done_list.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !list_id.is_empty() {
|
||||
client.update_card(&card_id, &planka::CardUpdate {
|
||||
list_id: Some(serde_json::json!(list_id)),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
output::ok(&format!("Moved p:{card_id} to Done."));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
output::warn(&format!("Could not find a Done list for p:{card_id}. Move it manually."));
|
||||
}
|
||||
TicketRef::Gitea { org, repo, number } => {
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
client.close_issue(&org, &repo, number).await?;
|
||||
output::ok(&format!("Closed gitea:{org}/{repo}#{number}."));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assign a user to a ticket.
|
||||
#[allow(dead_code)]
|
||||
pub async fn cmd_pm_assign(id: &str, user: &str) -> Result<()> {
|
||||
let domain = crate::config::domain();
|
||||
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||
let ticket_ref = parse_ticket_id(id)?;
|
||||
|
||||
match ticket_ref {
|
||||
TicketRef::Planka(card_id) => {
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
client.assign_card(&card_id, user).await?;
|
||||
}
|
||||
TicketRef::Gitea { org, repo, number } => {
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
client.assign_issue(&org, &repo, number, user).await?;
|
||||
}
|
||||
}
|
||||
|
||||
output::ok(&format!("Assigned {user} to {id}."));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- Ticket ID parsing --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_parse_planka_short() {
|
||||
let r = parse_ticket_id("p:42").unwrap();
|
||||
assert_eq!(r, TicketRef::Planka("42".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_planka_long() {
|
||||
let r = parse_ticket_id("planka:100").unwrap();
|
||||
assert_eq!(r, TicketRef::Planka("100".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gitea_short() {
|
||||
let r = parse_ticket_id("g:studio/cli#7").unwrap();
|
||||
assert_eq!(
|
||||
r,
|
||||
TicketRef::Gitea {
|
||||
org: "studio".to_string(),
|
||||
repo: "cli".to_string(),
|
||||
number: 7,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gitea_long() {
|
||||
let r = parse_ticket_id("gitea:internal/infra#123").unwrap();
|
||||
assert_eq!(
|
||||
r,
|
||||
TicketRef::Gitea {
|
||||
org: "internal".to_string(),
|
||||
repo: "infra".to_string(),
|
||||
number: 123,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_colon() {
|
||||
assert!(parse_ticket_id("noprefix").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unknown_prefix() {
|
||||
assert!(parse_ticket_id("jira:FOO-1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_planka_id() {
|
||||
// Empty ID should fail
|
||||
assert!(parse_ticket_id("p:").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gitea_missing_hash() {
|
||||
assert!(parse_ticket_id("g:studio/cli").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gitea_missing_slash() {
|
||||
assert!(parse_ticket_id("g:repo#1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gitea_invalid_number() {
|
||||
assert!(parse_ticket_id("g:studio/cli#abc").is_err());
|
||||
}
|
||||
|
||||
// -- Status mapping -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_gitea_state_open() {
|
||||
assert_eq!(gitea_issues::gitea_json::state_to_status("open"), Status::Open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gitea_state_closed() {
|
||||
assert_eq!(gitea_issues::gitea_json::state_to_status("closed"), Status::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gitea_state_unknown_defaults_open() {
|
||||
assert_eq!(gitea_issues::gitea_json::state_to_status("weird"), Status::Open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_display() {
|
||||
assert_eq!(Status::Open.to_string(), "open");
|
||||
assert_eq!(Status::InProgress.to_string(), "in-progress");
|
||||
assert_eq!(Status::Done.to_string(), "done");
|
||||
assert_eq!(Status::Closed.to_string(), "closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_display() {
|
||||
assert_eq!(Source::Planka.to_string(), "planka");
|
||||
assert_eq!(Source::Gitea.to_string(), "gitea");
|
||||
}
|
||||
|
||||
// -- Display formatting -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_display_ticket_list_table() {
|
||||
let tickets = vec![
|
||||
Ticket {
|
||||
id: "p:1".to_string(),
|
||||
source: Source::Planka,
|
||||
title: "Fix login".to_string(),
|
||||
description: String::new(),
|
||||
status: Status::Open,
|
||||
assignees: vec!["alice".to_string()],
|
||||
labels: vec![],
|
||||
created_at: "2025-01-01".to_string(),
|
||||
updated_at: "2025-01-02".to_string(),
|
||||
url: "https://projects.example.com/cards/1".to_string(),
|
||||
},
|
||||
Ticket {
|
||||
id: "g:studio/cli#7".to_string(),
|
||||
source: Source::Gitea,
|
||||
title: "Add tests".to_string(),
|
||||
description: "We need more tests.".to_string(),
|
||||
status: Status::InProgress,
|
||||
assignees: vec!["bob".to_string(), "carol".to_string()],
|
||||
labels: vec!["enhancement".to_string()],
|
||||
created_at: "2025-02-01".to_string(),
|
||||
updated_at: "2025-02-05".to_string(),
|
||||
url: "https://src.example.com/studio/cli/issues/7".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let rows: Vec<Vec<String>> = tickets
|
||||
.iter()
|
||||
.map(|t| {
|
||||
vec![
|
||||
t.id.clone(),
|
||||
t.status.to_string(),
|
||||
t.title.clone(),
|
||||
t.assignees.join(", "),
|
||||
t.source.to_string(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]);
|
||||
assert!(tbl.contains("p:1"));
|
||||
assert!(tbl.contains("g:studio/cli#7"));
|
||||
assert!(tbl.contains("open"));
|
||||
assert!(tbl.contains("in-progress"));
|
||||
assert!(tbl.contains("Fix login"));
|
||||
assert!(tbl.contains("Add tests"));
|
||||
assert!(tbl.contains("alice"));
|
||||
assert!(tbl.contains("bob, carol"));
|
||||
assert!(tbl.contains("planka"));
|
||||
assert!(tbl.contains("gitea"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_ticket_list_empty() {
|
||||
let rows: Vec<Vec<String>> = vec![];
|
||||
let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]);
|
||||
// Should have header + separator but no data rows.
|
||||
assert!(tbl.contains("ID"));
|
||||
assert_eq!(tbl.lines().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_card_update_serialization() {
|
||||
let update = planka::CardUpdate {
|
||||
name: Some("New name".to_string()),
|
||||
description: None,
|
||||
list_id: Some(serde_json::json!(5)),
|
||||
};
|
||||
let json = serde_json::to_value(&update).unwrap();
|
||||
assert_eq!(json["name"], "New name");
|
||||
assert_eq!(json["listId"], 5);
|
||||
assert!(json.get("description").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_update_serialization() {
|
||||
let update = gitea_issues::IssueUpdate {
|
||||
title: None,
|
||||
body: Some("Updated body".to_string()),
|
||||
state: Some("closed".to_string()),
|
||||
};
|
||||
let json = serde_json::to_value(&update).unwrap();
|
||||
assert!(json.get("title").is_none());
|
||||
assert_eq!(json["body"], "Updated body");
|
||||
assert_eq!(json["state"], "closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_planka_list_name_to_status() {
|
||||
// Test via Card::to_ticket with synthetic included data.
|
||||
use planka::planka_json::*;
|
||||
|
||||
let inc = BoardIncluded {
|
||||
cards: vec![],
|
||||
card_memberships: vec![],
|
||||
card_labels: vec![],
|
||||
labels: vec![],
|
||||
lists: vec![
|
||||
List { id: serde_json::json!(1), name: "To Do".to_string() },
|
||||
List { id: serde_json::json!(2), name: "In Progress".to_string() },
|
||||
List { id: serde_json::json!(3), name: "Done".to_string() },
|
||||
List { id: serde_json::json!(4), name: "Archived / Closed".to_string() },
|
||||
],
|
||||
users: vec![],
|
||||
};
|
||||
|
||||
let make_card = |list_id: u64| Card {
|
||||
id: serde_json::json!(1),
|
||||
name: "test".to_string(),
|
||||
description: None,
|
||||
list_id: Some(serde_json::json!(list_id)),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
make_card(1).to_ticket("https://x/api", Some(&inc)).status,
|
||||
Status::Open
|
||||
);
|
||||
assert_eq!(
|
||||
make_card(2).to_ticket("https://x/api", Some(&inc)).status,
|
||||
Status::InProgress
|
||||
);
|
||||
assert_eq!(
|
||||
make_card(3).to_ticket("https://x/api", Some(&inc)).status,
|
||||
Status::Done
|
||||
);
|
||||
assert_eq!(
|
||||
make_card(4).to_ticket("https://x/api", Some(&inc)).status,
|
||||
Status::Closed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gitea_issue_to_ticket() {
|
||||
let issue = gitea_issues::gitea_json::Issue {
|
||||
number: 42,
|
||||
title: "Bug report".to_string(),
|
||||
body: Some("Something broke".to_string()),
|
||||
state: "open".to_string(),
|
||||
assignees: Some(vec![gitea_issues::gitea_json::GiteaUser {
|
||||
login: "dev1".to_string(),
|
||||
}]),
|
||||
labels: Some(vec![gitea_issues::gitea_json::GiteaLabel {
|
||||
name: "bug".to_string(),
|
||||
}]),
|
||||
created_at: Some("2025-03-01T00:00:00Z".to_string()),
|
||||
updated_at: Some("2025-03-02T00:00:00Z".to_string()),
|
||||
html_url: Some("https://src.example.com/studio/app/issues/42".to_string()),
|
||||
repository: None,
|
||||
};
|
||||
|
||||
let ticket = issue.to_ticket("https://src.example.com/api/v1", "studio", "app");
|
||||
assert_eq!(ticket.id, "g:studio/app#42");
|
||||
assert_eq!(ticket.source, Source::Gitea);
|
||||
assert_eq!(ticket.title, "Bug report");
|
||||
assert_eq!(ticket.description, "Something broke");
|
||||
assert_eq!(ticket.status, Status::Open);
|
||||
assert_eq!(ticket.assignees, vec!["dev1"]);
|
||||
assert_eq!(ticket.labels, vec!["bug"]);
|
||||
assert_eq!(
|
||||
ticket.url,
|
||||
"https://src.example.com/studio/app/issues/42"
|
||||
);
|
||||
}
|
||||
}
|
||||
546
sunbeam-sdk/src/pm/planka.rs
Normal file
546
sunbeam-sdk/src/pm/planka.rs
Normal file
@@ -0,0 +1,546 @@
|
||||
//! Planka (kanban board) client.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::{Result, SunbeamError};
|
||||
use super::{get_token, Ticket, Source, Status};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardUpdate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Update payload for a Planka card.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CardUpdate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_id: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PlankaClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(super) struct PlankaClient {
|
||||
pub(super) base_url: String,
|
||||
pub(super) token: String,
|
||||
pub(super) http: reqwest::Client,
|
||||
}
|
||||
|
||||
/// Serde helpers for Planka JSON responses.
|
||||
pub(super) mod planka_json {
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExchangeResponse {
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
// Planka may also return the token in `item`
|
||||
#[serde(default)]
|
||||
pub item: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Card {
|
||||
pub id: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub list_id: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoardResponse {
|
||||
#[serde(default)]
|
||||
pub included: Option<BoardIncluded>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoardIncluded {
|
||||
#[serde(default)]
|
||||
pub cards: Vec<Card>,
|
||||
#[serde(default)]
|
||||
pub card_memberships: Vec<CardMembership>,
|
||||
#[serde(default)]
|
||||
pub card_labels: Vec<CardLabel>,
|
||||
#[serde(default)]
|
||||
pub labels: Vec<Label>,
|
||||
#[serde(default)]
|
||||
pub lists: Vec<List>,
|
||||
#[serde(default)]
|
||||
pub users: Vec<User>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CardMembership {
|
||||
pub card_id: serde_json::Value,
|
||||
pub user_id: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CardLabel {
|
||||
pub card_id: serde_json::Value,
|
||||
pub label_id: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Label {
|
||||
pub id: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct List {
|
||||
pub id: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
pub id: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CardDetailResponse {
|
||||
pub item: Card,
|
||||
#[serde(default)]
|
||||
pub included: Option<BoardIncluded>,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
pub fn to_ticket(self, base_url: &str, included: Option<&BoardIncluded>) -> Ticket {
|
||||
let status = match included {
|
||||
Some(inc) => list_name_to_status(
|
||||
self.list_id
|
||||
.and_then(|lid| inc.lists.iter().find(|l| l.id == lid))
|
||||
.map(|l| l.name.as_str())
|
||||
.unwrap_or(""),
|
||||
),
|
||||
None => Status::Open,
|
||||
};
|
||||
|
||||
let assignees = match included {
|
||||
Some(inc) => inc
|
||||
.card_memberships
|
||||
.iter()
|
||||
.filter(|m| m.card_id == self.id)
|
||||
.filter_map(|m| {
|
||||
inc.users.iter().find(|u| u.id == m.user_id).map(|u| {
|
||||
u.username
|
||||
.clone()
|
||||
.or_else(|| u.name.clone())
|
||||
.unwrap_or_else(|| m.user_id.to_string())
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let labels = match included {
|
||||
Some(inc) => inc
|
||||
.card_labels
|
||||
.iter()
|
||||
.filter(|cl| cl.card_id == self.id)
|
||||
.filter_map(|cl| {
|
||||
inc.labels.iter().find(|l| l.id == cl.label_id).map(|l| {
|
||||
l.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| cl.label_id.to_string())
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
// Derive web URL from API base URL (strip `/api`).
|
||||
let web_base = base_url.trim_end_matches("/api");
|
||||
Ticket {
|
||||
id: format!("p:{}", self.id.as_str().unwrap_or(&self.id.to_string())),
|
||||
source: Source::Planka,
|
||||
title: self.name,
|
||||
description: self.description.unwrap_or_default(),
|
||||
status,
|
||||
assignees,
|
||||
labels,
|
||||
created_at: self.created_at.unwrap_or_default(),
|
||||
updated_at: self.updated_at.unwrap_or_default(),
|
||||
url: format!("{web_base}/cards/{}", self.id.as_str().unwrap_or(&self.id.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a Planka list name to a normalised status.
|
||||
fn list_name_to_status(name: &str) -> Status {
|
||||
let lower = name.to_lowercase();
|
||||
if lower.contains("done") || lower.contains("complete") {
|
||||
Status::Done
|
||||
} else if lower.contains("progress") || lower.contains("doing") || lower.contains("active")
|
||||
{
|
||||
Status::InProgress
|
||||
} else if lower.contains("closed") || lower.contains("archive") {
|
||||
Status::Closed
|
||||
} else {
|
||||
Status::Open
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlankaClient {
|
||||
/// Create a new Planka client, exchanging the Hydra token for a Planka JWT
|
||||
/// if the direct Bearer token is rejected.
|
||||
pub(super) async fn new(domain: &str) -> Result<Self> {
|
||||
let base_url = format!("https://projects.{domain}/api");
|
||||
let hydra_token = get_token().await?;
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| SunbeamError::network(format!("Failed to build HTTP client: {e}")))?;
|
||||
|
||||
// Exchange the Hydra access token for a Planka JWT via our custom endpoint.
|
||||
let exchange_url = format!("{base_url}/access-tokens/exchange-using-token");
|
||||
let exchange_resp = http
|
||||
.post(&exchange_url)
|
||||
.json(&serde_json::json!({ "token": hydra_token }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka token exchange failed: {e}")))?;
|
||||
|
||||
if !exchange_resp.status().is_success() {
|
||||
let status = exchange_resp.status();
|
||||
let body = exchange_resp.text().await.unwrap_or_default();
|
||||
return Err(SunbeamError::identity(format!(
|
||||
"Planka token exchange returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = exchange_resp.json().await?;
|
||||
let token = body
|
||||
.get("item")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| SunbeamError::identity("Planka exchange response missing 'item' field"))?
|
||||
.to_string();
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
token,
|
||||
http,
|
||||
})
|
||||
}
|
||||
|
||||
/// Discover all projects and boards, then fetch cards from each.
|
||||
pub(super) async fn list_all_cards(&self) -> Result<Vec<Ticket>> {
|
||||
// GET /api/projects returns all projects the user has access to,
|
||||
// with included boards.
|
||||
let url = format!("{}/projects", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka list projects: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Planka GET projects returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
// Extract board IDs -- Planka uses string IDs (snowflake-style)
|
||||
let board_ids: Vec<String> = body
|
||||
.get("included")
|
||||
.and_then(|inc| inc.get("boards"))
|
||||
.and_then(|b| b.as_array())
|
||||
.map(|boards| {
|
||||
boards
|
||||
.iter()
|
||||
.filter_map(|b| {
|
||||
b.get("id").and_then(|id| {
|
||||
id.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| id.as_u64().map(|n| n.to_string()))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if board_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Fetch cards from each board
|
||||
let mut all_tickets = Vec::new();
|
||||
for board_id in &board_ids {
|
||||
match self.list_cards(board_id).await {
|
||||
Ok(tickets) => all_tickets.extend(tickets),
|
||||
Err(e) => {
|
||||
crate::output::warn(&format!("Planka board {board_id}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_tickets)
|
||||
}
|
||||
|
||||
/// GET /api/boards/{id} and extract all cards.
|
||||
async fn list_cards(&self, board_id: &str) -> Result<Vec<Ticket>> {
|
||||
let url = format!("{}/boards/{board_id}", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka list_cards: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Planka GET board {board_id} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: planka_json::BoardResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka board parse error: {e}")))?;
|
||||
|
||||
let included = body.included;
|
||||
let tickets = included
|
||||
.as_ref()
|
||||
.map(|inc| {
|
||||
inc.cards
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|c: planka_json::Card| c.to_ticket(&self.base_url, Some(inc)))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(tickets)
|
||||
}
|
||||
|
||||
/// GET /api/cards/{id}
|
||||
pub(super) async fn get_card(&self, id: &str) -> Result<Ticket> {
|
||||
let url = format!("{}/cards/{id}", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka get_card: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Planka GET card {id} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body: planka_json::CardDetailResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka card parse error: {e}")))?;
|
||||
|
||||
Ok(body
|
||||
.item
|
||||
.to_ticket(&self.base_url, body.included.as_ref()))
|
||||
}
|
||||
|
||||
/// POST /api/lists/{list_id}/cards
|
||||
pub(super) async fn create_card(
|
||||
&self,
|
||||
_board_id: &str,
|
||||
list_id: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
) -> Result<Ticket> {
|
||||
let url = format!("{}/lists/{list_id}/cards", self.base_url);
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"position": 65535,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka create_card: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Planka POST card returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let card: planka_json::CardDetailResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka card create parse error: {e}")))?;
|
||||
|
||||
Ok(card.item.to_ticket(&self.base_url, card.included.as_ref()))
|
||||
}
|
||||
|
||||
/// PATCH /api/cards/{id}
|
||||
pub(super) async fn update_card(&self, id: &str, updates: &CardUpdate) -> Result<()> {
|
||||
let url = format!("{}/cards/{id}", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.patch(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.json(updates)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka update_card: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Planka PATCH card {id} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a card to a different list.
|
||||
#[allow(dead_code)]
|
||||
pub(super) async fn move_card(&self, id: &str, list_id: &str) -> Result<()> {
|
||||
self.update_card(
|
||||
id,
|
||||
&CardUpdate {
|
||||
list_id: Some(serde_json::json!(list_id)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// POST /api/cards/{id}/comment-actions
|
||||
pub(super) async fn comment_card(&self, id: &str, text: &str) -> Result<()> {
|
||||
let url = format!("{}/cards/{id}/comment-actions", self.base_url);
|
||||
let body = serde_json::json!({ "text": text });
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka comment_card: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Planka POST comment on card {id} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search for a Planka user by name/username, return their ID.
|
||||
async fn resolve_user_id(&self, query: &str) -> Result<String> {
|
||||
// "me" or "self" assigns to the current user
|
||||
if query == "me" || query == "self" {
|
||||
// Get current user via the token (decode JWT or call /api/users/me equivalent)
|
||||
// Planka doesn't have /api/users/me, but we can get user from any board membership
|
||||
let projects_url = format!("{}/projects", self.base_url);
|
||||
if let Ok(resp) = self.http.get(&projects_url).bearer_auth(&self.token).send().await {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(memberships) = body.get("included")
|
||||
.and_then(|i| i.get("boardMemberships"))
|
||||
.and_then(|b| b.as_array())
|
||||
{
|
||||
if let Some(user_id) = memberships.first()
|
||||
.and_then(|m| m.get("userId"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(user_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search other users (note: Planka excludes current user from search results)
|
||||
let url = format!("{}/users/search", self.base_url);
|
||||
let resp = self.http.get(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.query(&[("query", query)])
|
||||
.send().await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka user search: {e}")))?;
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let users = body.get("items").and_then(|i| i.as_array());
|
||||
if let Some(users) = users {
|
||||
if let Some(user) = users.first() {
|
||||
if let Some(id) = user.get("id").and_then(|v| v.as_str()) {
|
||||
return Ok(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(SunbeamError::identity(format!(
|
||||
"Planka user not found: {query} (use 'me' to assign to yourself)"
|
||||
)))
|
||||
}
|
||||
|
||||
/// POST /api/cards/{id}/memberships
|
||||
pub(super) async fn assign_card(&self, id: &str, user: &str) -> Result<()> {
|
||||
// Resolve username to user ID
|
||||
let user_id = self.resolve_user_id(user).await?;
|
||||
let url = format!("{}/cards/{id}/memberships", self.base_url);
|
||||
let body = serde_json::json!({ "userId": user_id });
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka assign_card: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Planka POST membership on card {id} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
656
sunbeam-sdk/src/users/mod.rs
Normal file
656
sunbeam-sdk/src/users/mod.rs
Normal file
@@ -0,0 +1,656 @@
|
||||
//! User management -- Kratos identity operations via port-forwarded admin API.
|
||||
|
||||
mod provisioning;
|
||||
pub use provisioning::{cmd_user_onboard, cmd_user_offboard};
|
||||
|
||||
use serde_json::Value;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::error::{Result, ResultExt, SunbeamError};
|
||||
use crate::output::{ok, step, table, warn};
|
||||
|
||||
const SMTP_LOCAL_PORT: u16 = 10025;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port-forward helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// RAII guard that terminates the port-forward on drop.
|
||||
struct PortForward {
|
||||
child: tokio::process::Child,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl PortForward {
|
||||
async fn new(ns: &str, svc: &str, local_port: u16, remote_port: u16) -> Result<Self> {
|
||||
let ctx = crate::kube::context();
|
||||
let child = tokio::process::Command::new("kubectl")
|
||||
.arg(format!("--context={ctx}"))
|
||||
.args([
|
||||
"-n",
|
||||
ns,
|
||||
"port-forward",
|
||||
&format!("svc/{svc}"),
|
||||
&format!("{local_port}:{remote_port}"),
|
||||
])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.with_ctx(|| format!("Failed to spawn port-forward to {ns}/svc/{svc}"))?;
|
||||
|
||||
// Give the port-forward time to bind
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
base_url: format!("http://localhost:{local_port}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience: Kratos admin (ory/kratos-admin 80 -> 4434).
|
||||
async fn kratos() -> Result<Self> {
|
||||
Self::new("ory", "kratos-admin", 4434, 80).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PortForward {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Make an HTTP request to an admin API endpoint.
|
||||
async fn api(
|
||||
base_url: &str,
|
||||
path: &str,
|
||||
method: &str,
|
||||
body: Option<&Value>,
|
||||
prefix: &str,
|
||||
ok_statuses: &[u16],
|
||||
) -> Result<Option<Value>> {
|
||||
let url = format!("{base_url}{prefix}{path}");
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut req = match method {
|
||||
"GET" => client.get(&url),
|
||||
"POST" => client.post(&url),
|
||||
"PUT" => client.put(&url),
|
||||
"PATCH" => client.patch(&url),
|
||||
"DELETE" => client.delete(&url),
|
||||
_ => bail!("Unsupported HTTP method: {method}"),
|
||||
};
|
||||
|
||||
req = req
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json");
|
||||
|
||||
if let Some(b) = body {
|
||||
req = req.json(b);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.with_ctx(|| format!("HTTP {method} {url} failed"))?;
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
if !resp.status().is_success() {
|
||||
if ok_statuses.contains(&status) {
|
||||
return Ok(None);
|
||||
}
|
||||
let err_text = resp.text().await.unwrap_or_default();
|
||||
bail!("API error {status}: {err_text}");
|
||||
}
|
||||
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if text.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let val: Value = serde_json::from_str(&text)
|
||||
.with_ctx(|| format!("Failed to parse API response as JSON: {text}"))?;
|
||||
Ok(Some(val))
|
||||
}
|
||||
|
||||
/// Shorthand: Kratos admin API call (prefix = "/admin").
|
||||
async fn kratos_api(
|
||||
base_url: &str,
|
||||
path: &str,
|
||||
method: &str,
|
||||
body: Option<&Value>,
|
||||
ok_statuses: &[u16],
|
||||
) -> Result<Option<Value>> {
|
||||
api(base_url, path, method, body, "/admin", ok_statuses).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Find identity by UUID or email search. Returns the identity JSON.
|
||||
async fn find_identity(base_url: &str, target: &str, required: bool) -> Result<Option<Value>> {
|
||||
// Looks like a UUID?
|
||||
if target.len() == 36 && target.chars().filter(|&c| c == '-').count() == 4 {
|
||||
let result = kratos_api(base_url, &format!("/identities/{target}"), "GET", None, &[]).await?;
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Search by email
|
||||
let result = kratos_api(
|
||||
base_url,
|
||||
&format!("/identities?credentials_identifier={target}&page_size=1"),
|
||||
"GET",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(Value::Array(arr)) = &result {
|
||||
if let Some(first) = arr.first() {
|
||||
return Ok(Some(first.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if required {
|
||||
return Err(SunbeamError::identity(format!("Identity not found: {target}")));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Build the PUT body for updating an identity, preserving all required fields.
|
||||
fn identity_put_body(identity: &Value, state: Option<&str>, extra: Option<Value>) -> Value {
|
||||
let mut body = serde_json::json!({
|
||||
"schema_id": identity["schema_id"],
|
||||
"traits": identity["traits"],
|
||||
"state": state.unwrap_or_else(|| identity.get("state").and_then(|v| v.as_str()).unwrap_or("active")),
|
||||
"metadata_public": identity.get("metadata_public").cloned().unwrap_or(Value::Null),
|
||||
"metadata_admin": identity.get("metadata_admin").cloned().unwrap_or(Value::Null),
|
||||
});
|
||||
|
||||
if let Some(extra_obj) = extra {
|
||||
if let (Some(base_map), Some(extra_map)) = (body.as_object_mut(), extra_obj.as_object()) {
|
||||
for (k, v) in extra_map {
|
||||
base_map.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
/// Generate a 24h recovery code. Returns (link, code).
|
||||
async fn generate_recovery(base_url: &str, identity_id: &str) -> Result<(String, String)> {
|
||||
let body = serde_json::json!({
|
||||
"identity_id": identity_id,
|
||||
"expires_in": "24h",
|
||||
});
|
||||
|
||||
let result = kratos_api(base_url, "/recovery/code", "POST", Some(&body), &[]).await?;
|
||||
|
||||
let recovery = result.unwrap_or_default();
|
||||
let link = recovery
|
||||
.get("recovery_link")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let code = recovery
|
||||
.get("recovery_code")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok((link, code))
|
||||
}
|
||||
|
||||
/// Find the next sequential employee ID by scanning all employee identities.
|
||||
///
|
||||
/// Paginates through all identities using `page` and `page_size` params to
|
||||
/// avoid missing employee IDs when there are more than 200 identities.
|
||||
async fn next_employee_id(base_url: &str) -> Result<String> {
|
||||
let mut max_num: u64 = 0;
|
||||
let mut page = 1;
|
||||
loop {
|
||||
let result = kratos_api(
|
||||
base_url,
|
||||
&format!("/identities?page_size=200&page={page}"),
|
||||
"GET",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let identities = match result {
|
||||
Some(Value::Array(arr)) if !arr.is_empty() => arr,
|
||||
_ => break,
|
||||
};
|
||||
|
||||
for ident in &identities {
|
||||
if let Some(eid) = ident
|
||||
.get("traits")
|
||||
.and_then(|t| t.get("employee_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if let Ok(n) = eid.parse::<u64>() {
|
||||
max_num = max_num.max(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if identities.len() < 200 {
|
||||
break; // last page
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
Ok((max_num + 1).to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract a display name from identity traits (supports both default and employee schemas).
|
||||
fn display_name(traits: &Value) -> String {
|
||||
let given = traits
|
||||
.get("given_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let family = traits
|
||||
.get("family_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if !given.is_empty() || !family.is_empty() {
|
||||
return format!("{given} {family}").trim().to_string();
|
||||
}
|
||||
|
||||
match traits.get("name") {
|
||||
Some(Value::Object(name_map)) => {
|
||||
let first = name_map
|
||||
.get("first")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let last = name_map
|
||||
.get("last")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
format!("{first} {last}").trim().to_string()
|
||||
}
|
||||
Some(name) => name.as_str().unwrap_or("").to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the short ID prefix (first 8 chars + "...").
|
||||
fn short_id(id: &str) -> String {
|
||||
if id.len() >= 8 {
|
||||
format!("{}...", &id[..8])
|
||||
} else {
|
||||
id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get identity ID as a string from a JSON value.
|
||||
fn identity_id(identity: &Value) -> Result<String> {
|
||||
identity
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| SunbeamError::identity("Identity missing 'id' field"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_user_list(search: &str) -> Result<()> {
|
||||
step("Listing identities...");
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
let mut path = "/identities?page_size=20".to_string();
|
||||
if !search.is_empty() {
|
||||
path.push_str(&format!("&credentials_identifier={search}"));
|
||||
}
|
||||
let result = kratos_api(&pf.base_url, &path, "GET", None, &[]).await?;
|
||||
drop(pf);
|
||||
|
||||
let identities = match result {
|
||||
Some(Value::Array(arr)) => arr,
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
let rows: Vec<Vec<String>> = identities
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let traits = i.get("traits").cloned().unwrap_or(Value::Object(Default::default()));
|
||||
let email = traits
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let name = display_name(&traits);
|
||||
let state = i
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("active")
|
||||
.to_string();
|
||||
let id = i
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
vec![short_id(id), email, name, state]
|
||||
})
|
||||
.collect();
|
||||
|
||||
println!("{}", table(&rows, &["ID", "Email", "Name", "State"]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_user_get(target: &str) -> Result<()> {
|
||||
step(&format!("Getting identity: {target}"));
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
let identity = find_identity(&pf.base_url, target, true)
|
||||
.await?
|
||||
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
|
||||
drop(pf);
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&identity)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_user_create(email: &str, name: &str, schema_id: &str) -> Result<()> {
|
||||
step(&format!("Creating identity: {email}"));
|
||||
|
||||
let mut traits = serde_json::json!({ "email": email });
|
||||
if !name.is_empty() {
|
||||
let parts: Vec<&str> = name.splitn(2, ' ').collect();
|
||||
traits["name"] = serde_json::json!({
|
||||
"first": parts[0],
|
||||
"last": if parts.len() > 1 { parts[1] } else { "" },
|
||||
});
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"schema_id": schema_id,
|
||||
"traits": traits,
|
||||
"state": "active",
|
||||
});
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
let identity = kratos_api(&pf.base_url, "/identities", "POST", Some(&body), &[])
|
||||
.await?
|
||||
.ok_or_else(|| SunbeamError::identity("Failed to create identity"))?;
|
||||
|
||||
let iid = identity_id(&identity)?;
|
||||
ok(&format!("Created identity: {iid}"));
|
||||
|
||||
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
|
||||
drop(pf);
|
||||
|
||||
ok("Recovery link (valid 24h):");
|
||||
println!("{link}");
|
||||
ok("Recovery code (enter on the page above):");
|
||||
println!("{code}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_user_delete(target: &str) -> Result<()> {
|
||||
step(&format!("Deleting identity: {target}"));
|
||||
|
||||
eprint!("Delete identity '{target}'? This cannot be undone. [y/N] ");
|
||||
std::io::stderr().flush()?;
|
||||
let mut answer = String::new();
|
||||
std::io::stdin().read_line(&mut answer)?;
|
||||
if answer.trim().to_lowercase() != "y" {
|
||||
ok("Cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
let identity = find_identity(&pf.base_url, target, true)
|
||||
.await?
|
||||
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
|
||||
let iid = identity_id(&identity)?;
|
||||
kratos_api(
|
||||
&pf.base_url,
|
||||
&format!("/identities/{iid}"),
|
||||
"DELETE",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
drop(pf);
|
||||
|
||||
ok("Deleted.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_user_recover(target: &str) -> Result<()> {
|
||||
step(&format!("Generating recovery link for: {target}"));
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
let identity = find_identity(&pf.base_url, target, true)
|
||||
.await?
|
||||
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
|
||||
let iid = identity_id(&identity)?;
|
||||
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
|
||||
drop(pf);
|
||||
|
||||
ok("Recovery link (valid 24h):");
|
||||
println!("{link}");
|
||||
ok("Recovery code (enter on the page above):");
|
||||
println!("{code}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_user_disable(target: &str) -> Result<()> {
|
||||
step(&format!("Disabling identity: {target}"));
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
let identity = find_identity(&pf.base_url, target, true)
|
||||
.await?
|
||||
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
|
||||
let iid = identity_id(&identity)?;
|
||||
|
||||
let put_body = identity_put_body(&identity, Some("inactive"), None);
|
||||
kratos_api(
|
||||
&pf.base_url,
|
||||
&format!("/identities/{iid}"),
|
||||
"PUT",
|
||||
Some(&put_body),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
kratos_api(
|
||||
&pf.base_url,
|
||||
&format!("/identities/{iid}/sessions"),
|
||||
"DELETE",
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
drop(pf);
|
||||
|
||||
ok(&format!(
|
||||
"Identity {}... disabled and all Kratos sessions revoked.",
|
||||
&iid[..8.min(iid.len())]
|
||||
));
|
||||
warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE -- currently 1h.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_user_enable(target: &str) -> Result<()> {
|
||||
step(&format!("Enabling identity: {target}"));
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
let identity = find_identity(&pf.base_url, target, true)
|
||||
.await?
|
||||
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
|
||||
let iid = identity_id(&identity)?;
|
||||
|
||||
let put_body = identity_put_body(&identity, Some("active"), None);
|
||||
kratos_api(
|
||||
&pf.base_url,
|
||||
&format!("/identities/{iid}"),
|
||||
"PUT",
|
||||
Some(&put_body),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
drop(pf);
|
||||
|
||||
ok(&format!("Identity {}... re-enabled.", short_id(&iid)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_user_set_password(target: &str, password: &str) -> Result<()> {
|
||||
step(&format!("Setting password for: {target}"));
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
let identity = find_identity(&pf.base_url, target, true)
|
||||
.await?
|
||||
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
|
||||
let iid = identity_id(&identity)?;
|
||||
|
||||
let extra = serde_json::json!({
|
||||
"credentials": {
|
||||
"password": {
|
||||
"config": {
|
||||
"password": password,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let put_body = identity_put_body(&identity, None, Some(extra));
|
||||
kratos_api(
|
||||
&pf.base_url,
|
||||
&format!("/identities/{iid}"),
|
||||
"PUT",
|
||||
Some(&put_body),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
drop(pf);
|
||||
|
||||
ok(&format!("Password set for {}...", short_id(&iid)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_display_name_employee_schema() {
|
||||
let traits = serde_json::json!({
|
||||
"email": "test@example.com",
|
||||
"given_name": "Alice",
|
||||
"family_name": "Smith",
|
||||
});
|
||||
assert_eq!(display_name(&traits), "Alice Smith");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_name_default_schema() {
|
||||
let traits = serde_json::json!({
|
||||
"email": "test@example.com",
|
||||
"name": { "first": "Bob", "last": "Jones" },
|
||||
});
|
||||
assert_eq!(display_name(&traits), "Bob Jones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_name_empty() {
|
||||
let traits = serde_json::json!({ "email": "test@example.com" });
|
||||
assert_eq!(display_name(&traits), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_name_given_only() {
|
||||
let traits = serde_json::json!({
|
||||
"given_name": "Alice",
|
||||
});
|
||||
assert_eq!(display_name(&traits), "Alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_id() {
|
||||
assert_eq!(
|
||||
short_id("12345678-abcd-1234-abcd-123456789012"),
|
||||
"12345678..."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_id_short() {
|
||||
assert_eq!(short_id("abc"), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_put_body_preserves_fields() {
|
||||
let identity = serde_json::json!({
|
||||
"schema_id": "employee",
|
||||
"traits": { "email": "a@b.com" },
|
||||
"state": "active",
|
||||
"metadata_public": null,
|
||||
"metadata_admin": null,
|
||||
});
|
||||
|
||||
let body = identity_put_body(&identity, Some("inactive"), None);
|
||||
assert_eq!(body["state"], "inactive");
|
||||
assert_eq!(body["schema_id"], "employee");
|
||||
assert_eq!(body["traits"]["email"], "a@b.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_put_body_with_extra() {
|
||||
let identity = serde_json::json!({
|
||||
"schema_id": "default",
|
||||
"traits": { "email": "a@b.com" },
|
||||
"state": "active",
|
||||
});
|
||||
|
||||
let extra = serde_json::json!({
|
||||
"credentials": {
|
||||
"password": { "config": { "password": "s3cret" } }
|
||||
}
|
||||
});
|
||||
let body = identity_put_body(&identity, None, Some(extra));
|
||||
assert_eq!(body["state"], "active");
|
||||
assert!(body["credentials"]["password"]["config"]["password"] == "s3cret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_put_body_default_state() {
|
||||
let identity = serde_json::json!({
|
||||
"schema_id": "default",
|
||||
"traits": {},
|
||||
"state": "inactive",
|
||||
});
|
||||
let body = identity_put_body(&identity, None, None);
|
||||
assert_eq!(body["state"], "inactive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_id_extraction() {
|
||||
let identity = serde_json::json!({ "id": "12345678-abcd-1234-abcd-123456789012" });
|
||||
assert_eq!(
|
||||
identity_id(&identity).unwrap(),
|
||||
"12345678-abcd-1234-abcd-123456789012"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_id_missing() {
|
||||
let identity = serde_json::json!({});
|
||||
assert!(identity_id(&identity).is_err());
|
||||
}
|
||||
}
|
||||
516
sunbeam-sdk/src/users/provisioning.rs
Normal file
516
sunbeam-sdk/src/users/provisioning.rs
Normal file
@@ -0,0 +1,516 @@
|
||||
//! User provisioning -- onboarding and offboarding workflows.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::error::{Result, ResultExt, SunbeamError};
|
||||
use crate::output::{ok, step, warn};
|
||||
|
||||
use super::{
|
||||
api, find_identity, generate_recovery, identity_id, identity_put_body, kratos_api,
|
||||
next_employee_id, short_id, PortForward, SMTP_LOCAL_PORT,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App-level provisioning (best-effort)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a deployment to the name of a running pod.
|
||||
async fn pod_for_deployment(ns: &str, deployment: &str) -> Result<String> {
|
||||
let client = crate::kube::get_client().await?;
|
||||
let pods: kube::Api<k8s_openapi::api::core::v1::Pod> =
|
||||
kube::Api::namespaced(client.clone(), ns);
|
||||
|
||||
let label = format!("app.kubernetes.io/name={deployment}");
|
||||
let lp = kube::api::ListParams::default().labels(&label);
|
||||
let pod_list = pods
|
||||
.list(&lp)
|
||||
.await
|
||||
.with_ctx(|| format!("Failed to list pods for deployment {deployment} in {ns}"))?;
|
||||
|
||||
for pod in &pod_list.items {
|
||||
if let Some(status) = &pod.status {
|
||||
let phase = status.phase.as_deref().unwrap_or("");
|
||||
if phase == "Running" {
|
||||
if let Some(name) = &pod.metadata.name {
|
||||
return Ok(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try with app= label
|
||||
let label2 = format!("app={deployment}");
|
||||
let lp2 = kube::api::ListParams::default().labels(&label2);
|
||||
let pod_list2 = match pods.list(&lp2).await {
|
||||
Ok(list) => list,
|
||||
Err(_) => {
|
||||
return Err(SunbeamError::kube(format!(
|
||||
"No running pod found for deployment {deployment} in {ns}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
for pod in &pod_list2.items {
|
||||
if let Some(status) = &pod.status {
|
||||
let phase = status.phase.as_deref().unwrap_or("");
|
||||
if phase == "Running" {
|
||||
if let Some(name) = &pod.metadata.name {
|
||||
return Ok(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(SunbeamError::kube(format!(
|
||||
"No running pod found for deployment {deployment} in {ns}"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Create a mailbox in Messages via kubectl exec into the backend.
|
||||
async fn create_mailbox(email: &str, name: &str) {
|
||||
let parts: Vec<&str> = email.splitn(2, '@').collect();
|
||||
if parts.len() != 2 {
|
||||
warn(&format!("Invalid email for mailbox creation: {email}"));
|
||||
return;
|
||||
}
|
||||
let local_part = parts[0];
|
||||
let domain_part = parts[1];
|
||||
let display_name = if name.is_empty() { local_part } else { name };
|
||||
let _ = display_name; // used in Python for future features; kept for parity
|
||||
|
||||
step(&format!("Creating mailbox: {email}"));
|
||||
|
||||
let pod = match pod_for_deployment("lasuite", "messages-backend").await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn(&format!("Could not find messages-backend pod: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let script = format!(
|
||||
"mb, created = Mailbox.objects.get_or_create(\n local_part=\"{}\",\n domain=MailDomain.objects.get(name=\"{}\"),\n)\nprint(\"created\" if created else \"exists\")\n",
|
||||
local_part, domain_part,
|
||||
);
|
||||
|
||||
let cmd: Vec<&str> = vec!["python", "manage.py", "shell", "-c", &script];
|
||||
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("messages-backend")).await {
|
||||
Ok((0, output)) if output.contains("created") => {
|
||||
ok(&format!("Mailbox {email} created."));
|
||||
}
|
||||
Ok((0, output)) if output.contains("exists") => {
|
||||
ok(&format!("Mailbox {email} already exists."));
|
||||
}
|
||||
Ok((_, output)) => {
|
||||
warn(&format!(
|
||||
"Could not create mailbox (Messages backend may not be running): {output}"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
warn(&format!("Could not create mailbox: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a mailbox and associated Django user in Messages.
|
||||
async fn delete_mailbox(email: &str) {
|
||||
let parts: Vec<&str> = email.splitn(2, '@').collect();
|
||||
if parts.len() != 2 {
|
||||
warn(&format!("Invalid email for mailbox deletion: {email}"));
|
||||
return;
|
||||
}
|
||||
let local_part = parts[0];
|
||||
let domain_part = parts[1];
|
||||
|
||||
step(&format!("Cleaning up mailbox: {email}"));
|
||||
|
||||
let pod = match pod_for_deployment("lasuite", "messages-backend").await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn(&format!("Could not find messages-backend pod: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let script = format!(
|
||||
"from django.contrib.auth import get_user_model\nUser = get_user_model()\ndeleted = 0\nfor mb in Mailbox.objects.filter(local_part=\"{local_part}\", domain__name=\"{domain_part}\"):\n mb.delete()\n deleted += 1\ntry:\n u = User.objects.get(email=\"{email}\")\n u.delete()\n deleted += 1\nexcept User.DoesNotExist:\n pass\nprint(f\"deleted {{deleted}}\")\n",
|
||||
);
|
||||
|
||||
let cmd: Vec<&str> = vec!["python", "manage.py", "shell", "-c", &script];
|
||||
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("messages-backend")).await {
|
||||
Ok((0, output)) if output.contains("deleted") => {
|
||||
ok("Mailbox and user cleaned up.");
|
||||
}
|
||||
Ok((_, output)) => {
|
||||
warn(&format!("Could not clean up mailbox: {output}"));
|
||||
}
|
||||
Err(e) => {
|
||||
warn(&format!("Could not clean up mailbox: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Projects (Planka) user and add them as manager of the Default project.
|
||||
async fn setup_projects_user(email: &str, name: &str) {
|
||||
step(&format!("Setting up Projects user: {email}"));
|
||||
|
||||
let pod = match pod_for_deployment("lasuite", "projects").await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn(&format!("Could not find projects pod: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let js = format!(
|
||||
"const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});\nasync function go() {{\n let user = await knex('user_account').where({{email: '{email}'}}).first();\n if (!user) {{\n const id = Date.now().toString();\n await knex('user_account').insert({{\n id, email: '{email}', name: '{name}', password: '',\n is_admin: true, is_sso: true, language: 'en-US',\n created_at: new Date(), updated_at: new Date()\n }});\n user = {{id}};\n console.log('user_created');\n }} else {{\n console.log('user_exists');\n }}\n const project = await knex('project').where({{name: 'Default'}}).first();\n if (project) {{\n const exists = await knex('project_manager').where({{project_id: project.id, user_id: user.id}}).first();\n if (!exists) {{\n await knex('project_manager').insert({{\n id: (Date.now()+1).toString(), project_id: project.id,\n user_id: user.id, created_at: new Date()\n }});\n console.log('manager_added');\n }} else {{\n console.log('manager_exists');\n }}\n }} else {{\n console.log('no_default_project');\n }}\n}}\ngo().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});\n",
|
||||
);
|
||||
|
||||
let cmd: Vec<&str> = vec!["node", "-e", &js];
|
||||
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("projects")).await {
|
||||
Ok((0, output))
|
||||
if output.contains("manager_added") || output.contains("manager_exists") =>
|
||||
{
|
||||
ok("Projects user ready.");
|
||||
}
|
||||
Ok((0, output)) if output.contains("no_default_project") => {
|
||||
warn("No Default project found in Projects -- skip.");
|
||||
}
|
||||
Ok((_, output)) => {
|
||||
warn(&format!("Could not set up Projects user: {output}"));
|
||||
}
|
||||
Err(e) => {
|
||||
warn(&format!("Could not set up Projects user: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a user from Projects (Planka) -- delete memberships and soft-delete user.
|
||||
async fn cleanup_projects_user(email: &str) {
|
||||
step(&format!("Cleaning up Projects user: {email}"));
|
||||
|
||||
let pod = match pod_for_deployment("lasuite", "projects").await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn(&format!("Could not find projects pod: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let js = format!(
|
||||
"const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}});\nasync function go() {{\n const user = await knex('user_account').where({{email: '{email}'}}).first();\n if (!user) {{ console.log('not_found'); return; }}\n await knex('board_membership').where({{user_id: user.id}}).del();\n await knex('project_manager').where({{user_id: user.id}}).del();\n await knex('user_account').where({{id: user.id}}).update({{deleted_at: new Date()}});\n console.log('cleaned');\n}}\ngo().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }});\n",
|
||||
);
|
||||
|
||||
let cmd: Vec<&str> = vec!["node", "-e", &js];
|
||||
match crate::kube::kube_exec("lasuite", &pod, &cmd, Some("projects")).await {
|
||||
Ok((0, output)) if output.contains("cleaned") => {
|
||||
ok("Projects user cleaned up.");
|
||||
}
|
||||
Ok((_, output)) => {
|
||||
warn(&format!("Could not clean up Projects user: {output}"));
|
||||
}
|
||||
Err(e) => {
|
||||
warn(&format!("Could not clean up Projects user: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Send a welcome email via cluster Postfix (port-forward to svc/postfix in lasuite).
|
||||
async fn send_welcome_email(
|
||||
domain: &str,
|
||||
email: &str,
|
||||
name: &str,
|
||||
recovery_link: &str,
|
||||
recovery_code: &str,
|
||||
job_title: &str,
|
||||
department: &str,
|
||||
) -> Result<()> {
|
||||
let greeting = if name.is_empty() {
|
||||
"Hi".to_string()
|
||||
} else {
|
||||
format!("Hi {name}")
|
||||
};
|
||||
|
||||
let joining_line = if !job_title.is_empty() && !department.is_empty() {
|
||||
format!(
|
||||
" You're joining as {job_title} in the {department} department."
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let body_text = format!(
|
||||
"{greeting},
|
||||
|
||||
Welcome to Sunbeam Studios!{joining_line} Your account has been created.
|
||||
|
||||
To set your password, open this link and enter the recovery code below:
|
||||
|
||||
Link: {recovery_link}
|
||||
Code: {recovery_code}
|
||||
|
||||
This link expires in 24 hours.
|
||||
|
||||
Once signed in you will be prompted to set up 2FA (mandatory).
|
||||
|
||||
After that, head to https://auth.{domain}/settings to set up your
|
||||
profile -- add your name, profile picture, and any other details.
|
||||
|
||||
Your services:
|
||||
Calendar: https://cal.{domain}
|
||||
Drive: https://drive.{domain}
|
||||
Mail: https://mail.{domain}
|
||||
Meet: https://meet.{domain}
|
||||
Projects: https://projects.{domain}
|
||||
Source Code: https://src.{domain}
|
||||
|
||||
Messages (Matrix):
|
||||
Download Element from https://element.io/download
|
||||
Open Element and sign in with a custom homeserver:
|
||||
Homeserver: https://messages.{domain}
|
||||
Use \"Sign in with Sunbeam Studios\" (SSO) to log in.
|
||||
|
||||
-- With Love & Warmth, Sunbeam Studios
|
||||
"
|
||||
);
|
||||
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
|
||||
let from: Mailbox = format!("Sunbeam Studios <noreply@{domain}>")
|
||||
.parse()
|
||||
.map_err(|e| SunbeamError::Other(format!("Invalid from address: {e}")))?;
|
||||
let to: Mailbox = email
|
||||
.parse()
|
||||
.map_err(|e| SunbeamError::Other(format!("Invalid recipient address: {e}")))?;
|
||||
|
||||
let message = Message::builder()
|
||||
.from(from)
|
||||
.to(to)
|
||||
.subject("Welcome to Sunbeam Studios -- Set Your Password")
|
||||
.body(body_text)
|
||||
.ctx("Failed to build email message")?;
|
||||
|
||||
let _pf = PortForward::new("lasuite", "postfix", SMTP_LOCAL_PORT, 25).await?;
|
||||
|
||||
let mailer = SmtpTransport::builder_dangerous("localhost")
|
||||
.port(SMTP_LOCAL_PORT)
|
||||
.build();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
mailer
|
||||
.send(&message)
|
||||
.map_err(|e| SunbeamError::Other(format!("Failed to send welcome email via SMTP: {e}")))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| SunbeamError::Other(format!("Email send task panicked: {e}")))??;
|
||||
|
||||
ok(&format!("Welcome email sent to {email}"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_user_onboard(
|
||||
email: &str,
|
||||
name: &str,
|
||||
schema_id: &str,
|
||||
send_email: bool,
|
||||
notify: &str,
|
||||
job_title: &str,
|
||||
department: &str,
|
||||
office_location: &str,
|
||||
hire_date: &str,
|
||||
manager: &str,
|
||||
) -> Result<()> {
|
||||
step(&format!("Onboarding: {email}"));
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
|
||||
let (iid, recovery_link, recovery_code, is_new) = {
|
||||
let existing = find_identity(&pf.base_url, email, false).await?;
|
||||
|
||||
if let Some(existing) = existing {
|
||||
let iid = identity_id(&existing)?;
|
||||
warn(&format!("Identity already exists: {}...", short_id(&iid)));
|
||||
step("Generating fresh recovery link...");
|
||||
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
|
||||
(iid, link, code, false)
|
||||
} else {
|
||||
let mut traits = serde_json::json!({ "email": email });
|
||||
if !name.is_empty() {
|
||||
let parts: Vec<&str> = name.splitn(2, ' ').collect();
|
||||
traits["given_name"] = Value::String(parts[0].to_string());
|
||||
traits["family_name"] =
|
||||
Value::String(if parts.len() > 1 { parts[1] } else { "" }.to_string());
|
||||
}
|
||||
|
||||
let mut employee_id = String::new();
|
||||
if schema_id == "employee" {
|
||||
employee_id = next_employee_id(&pf.base_url).await?;
|
||||
traits["employee_id"] = Value::String(employee_id.clone());
|
||||
if !job_title.is_empty() {
|
||||
traits["job_title"] = Value::String(job_title.to_string());
|
||||
}
|
||||
if !department.is_empty() {
|
||||
traits["department"] = Value::String(department.to_string());
|
||||
}
|
||||
if !office_location.is_empty() {
|
||||
traits["office_location"] = Value::String(office_location.to_string());
|
||||
}
|
||||
if !hire_date.is_empty() {
|
||||
traits["hire_date"] = Value::String(hire_date.to_string());
|
||||
}
|
||||
if !manager.is_empty() {
|
||||
traits["manager"] = Value::String(manager.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"schema_id": schema_id,
|
||||
"traits": traits,
|
||||
"state": "active",
|
||||
"verifiable_addresses": [{
|
||||
"value": email,
|
||||
"verified": true,
|
||||
"via": "email",
|
||||
}],
|
||||
});
|
||||
|
||||
let identity = kratos_api(&pf.base_url, "/identities", "POST", Some(&body), &[])
|
||||
.await?
|
||||
.ok_or_else(|| SunbeamError::identity("Failed to create identity"))?;
|
||||
|
||||
let iid = identity_id(&identity)?;
|
||||
ok(&format!("Created identity: {iid}"));
|
||||
if !employee_id.is_empty() {
|
||||
ok(&format!("Employee #{employee_id}"));
|
||||
}
|
||||
|
||||
// Kratos ignores verifiable_addresses on POST -- PATCH to mark verified
|
||||
let patch_body = serde_json::json!([
|
||||
{"op": "replace", "path": "/verifiable_addresses/0/verified", "value": true},
|
||||
{"op": "replace", "path": "/verifiable_addresses/0/status", "value": "completed"},
|
||||
]);
|
||||
kratos_api(
|
||||
&pf.base_url,
|
||||
&format!("/identities/{iid}"),
|
||||
"PATCH",
|
||||
Some(&patch_body),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (link, code) = generate_recovery(&pf.base_url, &iid).await?;
|
||||
(iid, link, code, true)
|
||||
}
|
||||
};
|
||||
|
||||
drop(pf);
|
||||
|
||||
// Provision app-level accounts for new users
|
||||
if is_new {
|
||||
create_mailbox(email, name).await;
|
||||
setup_projects_user(email, name).await;
|
||||
}
|
||||
|
||||
if send_email {
|
||||
let domain = crate::kube::get_domain().await?;
|
||||
let recipient = if notify.is_empty() { email } else { notify };
|
||||
send_welcome_email(
|
||||
&domain, recipient, name, &recovery_link, &recovery_code,
|
||||
job_title, department,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
ok(&format!("Identity ID: {iid}"));
|
||||
ok("Recovery link (valid 24h):");
|
||||
println!("{recovery_link}");
|
||||
ok("Recovery code:");
|
||||
println!("{recovery_code}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_user_offboard(target: &str) -> Result<()> {
|
||||
step(&format!("Offboarding: {target}"));
|
||||
|
||||
eprint!("Offboard '{target}'? This will disable the account and revoke all sessions. [y/N] ");
|
||||
std::io::stderr().flush()?;
|
||||
let mut answer = String::new();
|
||||
std::io::stdin().read_line(&mut answer)?;
|
||||
if answer.trim().to_lowercase() != "y" {
|
||||
ok("Cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pf = PortForward::kratos().await?;
|
||||
let identity = find_identity(&pf.base_url, target, true)
|
||||
.await?
|
||||
.ok_or_else(|| SunbeamError::identity("Identity not found"))?;
|
||||
let iid = identity_id(&identity)?;
|
||||
|
||||
step("Disabling identity...");
|
||||
let put_body = identity_put_body(&identity, Some("inactive"), None);
|
||||
kratos_api(
|
||||
&pf.base_url,
|
||||
&format!("/identities/{iid}"),
|
||||
"PUT",
|
||||
Some(&put_body),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
ok(&format!("Identity {}... disabled.", short_id(&iid)));
|
||||
|
||||
step("Revoking Kratos sessions...");
|
||||
kratos_api(
|
||||
&pf.base_url,
|
||||
&format!("/identities/{iid}/sessions"),
|
||||
"DELETE",
|
||||
None,
|
||||
&[404],
|
||||
)
|
||||
.await?;
|
||||
ok("Kratos sessions revoked.");
|
||||
|
||||
step("Revoking Hydra consent sessions...");
|
||||
{
|
||||
let hydra_pf = PortForward::new("ory", "hydra-admin", 14445, 4445).await?;
|
||||
api(
|
||||
&hydra_pf.base_url,
|
||||
&format!("/oauth2/auth/sessions/consent?subject={iid}&all=true"),
|
||||
"DELETE",
|
||||
None,
|
||||
"/admin",
|
||||
&[404],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ok("Hydra consent sessions revoked.");
|
||||
|
||||
drop(pf);
|
||||
|
||||
// Clean up Messages mailbox and Projects user
|
||||
let email = identity
|
||||
.get("traits")
|
||||
.and_then(|t| t.get("email"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !email.is_empty() {
|
||||
delete_mailbox(email).await;
|
||||
cleanup_projects_user(email).await;
|
||||
}
|
||||
|
||||
ok(&format!("Offboarding complete for {}...", short_id(&iid)));
|
||||
warn("Existing access tokens expire within ~1h (Hydra TTL).");
|
||||
warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE (~1h).");
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user