Files
cli/src/checks.rs
Sienna Meridian Satterwhite 019c73e300 fix: S3 auth signature tested against AWS reference vector
Refactor s3_auth_headers into deterministic s3_auth_headers_at that
accepts a timestamp. Add test with AWS example credentials and fixed
date verifying canonical request, string-to-sign, and final signature.
2026-03-20 13:37:17 +00:00

1215 lines
42 KiB
Rust

//! Service-level health checks — functional probes beyond pod readiness.
use crate::error::Result;
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 std::time::Duration;
use crate::kube::{get_client, kube_exec, parse_target};
use crate::output::{ok, step, warn};
type HmacSha256 = Hmac<Sha256>;
// ---------------------------------------------------------------------------
// 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(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()
}
// ---------------------------------------------------------------------------
// Individual checks
// ---------------------------------------------------------------------------
/// GET /api/v1/version -> JSON with version field.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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}")),
}
}
// ---------------------------------------------------------------------------
// 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> {
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(())
}
// ---------------------------------------------------------------------------
// hex encoding helper (avoids adding the `hex` 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
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// ── 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);
}
}