Files
cli/src/cluster.rs

457 lines
14 KiB
Rust
Raw Normal View History

feat: Phase 2 feature modules + comprehensive test suite (142 tests) services.rs: - Pod status with unicode icons, grouped by namespace - VSO sync status (VaultStaticSecret/VaultDynamicSecret via kube-rs DynamicObject) - Log streaming via kube-rs log_stream + futures::AsyncBufReadExt - Pod get in YAML/JSON format - Rollout restart with namespace/service filtering checks.rs: - 11 health check functions (gitea, postgres, valkey, openbao, seaweedfs, kratos, hydra, people, livekit) - AWS4-HMAC-SHA256 S3 auth header generation using sha2 + hmac - Concurrent execution via tokio JoinSet - mkcert root CA trust for local TLS secrets.rs: - Stub with cmd_seed/cmd_verify (requires live cluster for full impl) users.rs: - All 10 Kratos identity operations via reqwest + kubectl port-forward - Welcome email via lettre SMTP through port-forwarded postfix - Employee onboarding with auto-assigned ID, HR metadata - Offboarding with Kratos + Hydra session revocation gitea.rs: - Bootstrap without Lima VM: admin password, org creation, OIDC auth source - Gitea API via kubectl exec curl images.rs: - BuildEnv detection, buildctl build + push via port-forward - Per-service builders for all 17 build targets - Deploy rollout, node image pull, uv Dockerfile patching - Mirror scaffolding (containerd operations marked TODO) cluster.rs: - Pure K8s cmd_up: cert-manager, linkerd, rcgen TLS certs, core service wait - No Lima VM operations manifests.rs: - Full cmd_apply: kustomize build, two-pass convergence, ConfigMap restart detection - Pre-apply cleanup, webhook wait, mkcert CA, tuwunel OAuth2 redirect patch Test coverage: 142 tests across 14 modules (44 in checks, 27 in cli, 13 in images, 12 in tools, 12 in services, 11 in users, 10 in manifests, 9 in kube, 9 in cluster, 7 in update, 6 in gitea, 4 in openbao, 3 in output, 2 in config).
2026-03-20 12:45:07 +00:00
//! Cluster lifecycle — cert-manager, Linkerd, TLS, core service readiness.
//!
//! Pure K8s implementation: no Lima VM operations.
feat: Phase 2 feature modules + comprehensive test suite (142 tests) services.rs: - Pod status with unicode icons, grouped by namespace - VSO sync status (VaultStaticSecret/VaultDynamicSecret via kube-rs DynamicObject) - Log streaming via kube-rs log_stream + futures::AsyncBufReadExt - Pod get in YAML/JSON format - Rollout restart with namespace/service filtering checks.rs: - 11 health check functions (gitea, postgres, valkey, openbao, seaweedfs, kratos, hydra, people, livekit) - AWS4-HMAC-SHA256 S3 auth header generation using sha2 + hmac - Concurrent execution via tokio JoinSet - mkcert root CA trust for local TLS secrets.rs: - Stub with cmd_seed/cmd_verify (requires live cluster for full impl) users.rs: - All 10 Kratos identity operations via reqwest + kubectl port-forward - Welcome email via lettre SMTP through port-forwarded postfix - Employee onboarding with auto-assigned ID, HR metadata - Offboarding with Kratos + Hydra session revocation gitea.rs: - Bootstrap without Lima VM: admin password, org creation, OIDC auth source - Gitea API via kubectl exec curl images.rs: - BuildEnv detection, buildctl build + push via port-forward - Per-service builders for all 17 build targets - Deploy rollout, node image pull, uv Dockerfile patching - Mirror scaffolding (containerd operations marked TODO) cluster.rs: - Pure K8s cmd_up: cert-manager, linkerd, rcgen TLS certs, core service wait - No Lima VM operations manifests.rs: - Full cmd_apply: kustomize build, two-pass convergence, ConfigMap restart detection - Pre-apply cleanup, webhook wait, mkcert CA, tuwunel OAuth2 redirect patch Test coverage: 142 tests across 14 modules (44 in checks, 27 in cli, 13 in images, 12 in tools, 12 in services, 11 in users, 10 in manifests, 9 in kube, 9 in cluster, 7 in update, 6 in gitea, 4 in openbao, 3 in output, 2 in config).
2026-03-20 12:45:07 +00:00
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
const GITEA_ADMIN_USER: &str = "gitea_admin";
const CERT_MANAGER_URL: &str =
"https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml";
const GATEWAY_API_CRDS_URL: &str =
"https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml";
fn secrets_dir() -> PathBuf {
crate::config::get_infra_dir()
.join("secrets")
.join("local")
}
// ---------------------------------------------------------------------------
// cert-manager
// ---------------------------------------------------------------------------
async fn ensure_cert_manager() -> Result<()> {
crate::output::step("cert-manager...");
if crate::kube::ns_exists("cert-manager").await? {
crate::output::ok("Already installed.");
return Ok(());
}
crate::output::ok("Installing...");
// Download and apply cert-manager YAML
let body = reqwest::get(CERT_MANAGER_URL)
.await
.context("Failed to download cert-manager manifest")?
.text()
.await
.context("Failed to read cert-manager manifest body")?;
crate::kube::kube_apply(&body).await?;
// Wait for rollout
for dep in &[
"cert-manager",
"cert-manager-webhook",
"cert-manager-cainjector",
] {
crate::output::ok(&format!("Waiting for {dep}..."));
wait_rollout("cert-manager", dep, 120).await?;
}
crate::output::ok("Installed.");
Ok(())
}
// ---------------------------------------------------------------------------
// Linkerd
// ---------------------------------------------------------------------------
async fn ensure_linkerd() -> Result<()> {
crate::output::step("Linkerd...");
if crate::kube::ns_exists("linkerd").await? {
crate::output::ok("Already installed.");
return Ok(());
}
// Gateway API CRDs
crate::output::ok("Installing Gateway API CRDs...");
let gateway_body = reqwest::get(GATEWAY_API_CRDS_URL)
.await
.context("Failed to download Gateway API CRDs")?
.text()
.await?;
// Gateway API CRDs require server-side apply; kube_apply already does SSA
crate::kube::kube_apply(&gateway_body).await?;
// Linkerd CRDs via subprocess (no pure HTTP source for linkerd manifests)
crate::output::ok("Installing Linkerd CRDs...");
let crds_output = tokio::process::Command::new("linkerd")
.args(["install", "--crds"])
.output()
.await
.context("Failed to run `linkerd install --crds`")?;
if !crds_output.status.success() {
let stderr = String::from_utf8_lossy(&crds_output.stderr);
bail!("linkerd install --crds failed: {stderr}");
}
let crds = String::from_utf8_lossy(&crds_output.stdout);
crate::kube::kube_apply(&crds).await?;
// Linkerd control plane
crate::output::ok("Installing Linkerd control plane...");
let cp_output = tokio::process::Command::new("linkerd")
.args(["install"])
.output()
.await
.context("Failed to run `linkerd install`")?;
if !cp_output.status.success() {
let stderr = String::from_utf8_lossy(&cp_output.stderr);
bail!("linkerd install failed: {stderr}");
}
let cp = String::from_utf8_lossy(&cp_output.stdout);
crate::kube::kube_apply(&cp).await?;
for dep in &[
"linkerd-identity",
"linkerd-destination",
"linkerd-proxy-injector",
] {
crate::output::ok(&format!("Waiting for {dep}..."));
wait_rollout("linkerd", dep, 120).await?;
}
crate::output::ok("Installed.");
Ok(())
}
// ---------------------------------------------------------------------------
// TLS certificate (rcgen)
// ---------------------------------------------------------------------------
async fn ensure_tls_cert(domain: &str) -> Result<()> {
crate::output::step("TLS certificate...");
let dir = secrets_dir();
let cert_path = dir.join("tls.crt");
let key_path = dir.join("tls.key");
if cert_path.exists() {
crate::output::ok(&format!("Cert exists. Domain: {domain}"));
return Ok(());
}
crate::output::ok(&format!("Generating wildcard cert for *.{domain}..."));
std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create secrets dir: {}", dir.display()))?;
let subject_alt_names = vec![format!("*.{domain}")];
let mut params = rcgen::CertificateParams::new(subject_alt_names)
.context("Failed to create certificate params")?;
params
.distinguished_name
.push(rcgen::DnType::CommonName, format!("*.{domain}"));
let key_pair = rcgen::KeyPair::generate().context("Failed to generate key pair")?;
let cert = params
.self_signed(&key_pair)
.context("Failed to generate self-signed certificate")?;
std::fs::write(&cert_path, cert.pem())
.with_context(|| format!("Failed to write {}", cert_path.display()))?;
std::fs::write(&key_path, key_pair.serialize_pem())
.with_context(|| format!("Failed to write {}", key_path.display()))?;
crate::output::ok(&format!("Cert generated. Domain: {domain}"));
Ok(())
}
// ---------------------------------------------------------------------------
// TLS secret
// ---------------------------------------------------------------------------
async fn ensure_tls_secret(domain: &str) -> Result<()> {
crate::output::step("TLS secret...");
let _ = domain; // domain used contextually above; secret uses files
crate::kube::ensure_ns("ingress").await?;
let dir = secrets_dir();
let cert_pem =
std::fs::read_to_string(dir.join("tls.crt")).context("Failed to read tls.crt")?;
let key_pem =
std::fs::read_to_string(dir.join("tls.key")).context("Failed to read tls.key")?;
// Create TLS secret via kube-rs
let client = crate::kube::get_client().await?;
let api: kube::api::Api<k8s_openapi::api::core::v1::Secret> =
kube::api::Api::namespaced(client.clone(), "ingress");
let b64_cert = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
cert_pem.as_bytes(),
);
let b64_key = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
key_pem.as_bytes(),
);
let secret_obj = serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "pingora-tls",
"namespace": "ingress",
},
"type": "kubernetes.io/tls",
"data": {
"tls.crt": b64_cert,
"tls.key": b64_key,
},
});
let pp = kube::api::PatchParams::apply("sunbeam").force();
api.patch("pingora-tls", &pp, &kube::api::Patch::Apply(secret_obj))
.await
.context("Failed to create TLS secret")?;
crate::output::ok("Done.");
Ok(())
}
// ---------------------------------------------------------------------------
// Wait for core
// ---------------------------------------------------------------------------
async fn wait_for_core() -> Result<()> {
crate::output::step("Waiting for core services...");
for (ns, dep) in &[("data", "valkey"), ("ory", "kratos"), ("ory", "hydra")] {
let _ = wait_rollout(ns, dep, 120).await;
}
crate::output::ok("Core services ready.");
Ok(())
}
// ---------------------------------------------------------------------------
// Print URLs
// ---------------------------------------------------------------------------
fn print_urls(domain: &str, gitea_admin_pass: &str) {
let sep = "\u{2500}".repeat(60);
println!("\n{sep}");
println!(" Stack is up. Domain: {domain}");
println!("{sep}");
let urls: &[(&str, String)] = &[
("Auth", format!("https://auth.{domain}/")),
("Docs", format!("https://docs.{domain}/")),
("Meet", format!("https://meet.{domain}/")),
("Drive", format!("https://drive.{domain}/")),
("Chat", format!("https://chat.{domain}/")),
("Mail", format!("https://mail.{domain}/")),
("People", format!("https://people.{domain}/")),
(
"Gitea",
format!(
"https://src.{domain}/ ({GITEA_ADMIN_USER} / {gitea_admin_pass})"
),
),
];
for (name, url) in urls {
println!(" {name:<10} {url}");
}
println!();
println!(" OpenBao UI:");
println!(" kubectl --context=sunbeam -n data port-forward svc/openbao 8200:8200");
println!(" http://localhost:8200");
println!(
" token: kubectl --context=sunbeam -n data get secret openbao-keys \
-o jsonpath='{{.data.root-token}}' | base64 -d"
);
println!("{sep}\n");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Poll deployment rollout status (approximate: check Available condition).
async fn wait_rollout(ns: &str, deployment: &str, timeout_secs: u64) -> Result<()> {
use k8s_openapi::api::apps::v1::Deployment;
use std::time::{Duration, Instant};
let client = crate::kube::get_client().await?;
let api: kube::api::Api<Deployment> = kube::api::Api::namespaced(client.clone(), ns);
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop {
if Instant::now() > deadline {
bail!("Timed out waiting for deployment {ns}/{deployment}");
}
match api.get_opt(deployment).await? {
Some(dep) => {
if let Some(status) = &dep.status {
if let Some(conditions) = &status.conditions {
let available = conditions.iter().any(|c| {
c.type_ == "Available" && c.status == "True"
});
if available {
return Ok(());
}
}
}
}
None => {
// Deployment doesn't exist yet — keep waiting
}
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/// Full cluster bring-up (pure K8s — no Lima VM operations).
pub async fn cmd_up() -> Result<()> {
feat: Phase 2 feature modules + comprehensive test suite (142 tests) services.rs: - Pod status with unicode icons, grouped by namespace - VSO sync status (VaultStaticSecret/VaultDynamicSecret via kube-rs DynamicObject) - Log streaming via kube-rs log_stream + futures::AsyncBufReadExt - Pod get in YAML/JSON format - Rollout restart with namespace/service filtering checks.rs: - 11 health check functions (gitea, postgres, valkey, openbao, seaweedfs, kratos, hydra, people, livekit) - AWS4-HMAC-SHA256 S3 auth header generation using sha2 + hmac - Concurrent execution via tokio JoinSet - mkcert root CA trust for local TLS secrets.rs: - Stub with cmd_seed/cmd_verify (requires live cluster for full impl) users.rs: - All 10 Kratos identity operations via reqwest + kubectl port-forward - Welcome email via lettre SMTP through port-forwarded postfix - Employee onboarding with auto-assigned ID, HR metadata - Offboarding with Kratos + Hydra session revocation gitea.rs: - Bootstrap without Lima VM: admin password, org creation, OIDC auth source - Gitea API via kubectl exec curl images.rs: - BuildEnv detection, buildctl build + push via port-forward - Per-service builders for all 17 build targets - Deploy rollout, node image pull, uv Dockerfile patching - Mirror scaffolding (containerd operations marked TODO) cluster.rs: - Pure K8s cmd_up: cert-manager, linkerd, rcgen TLS certs, core service wait - No Lima VM operations manifests.rs: - Full cmd_apply: kustomize build, two-pass convergence, ConfigMap restart detection - Pre-apply cleanup, webhook wait, mkcert CA, tuwunel OAuth2 redirect patch Test coverage: 142 tests across 14 modules (44 in checks, 27 in cli, 13 in images, 12 in tools, 12 in services, 11 in users, 10 in manifests, 9 in kube, 9 in cluster, 7 in update, 6 in gitea, 4 in openbao, 3 in output, 2 in config).
2026-03-20 12:45:07 +00:00
// Resolve domain from cluster state
let domain = crate::kube::get_domain().await?;
ensure_cert_manager().await?;
ensure_linkerd().await?;
ensure_tls_cert(&domain).await?;
ensure_tls_secret(&domain).await?;
// Apply manifests
crate::manifests::cmd_apply("local", &domain, "", "").await?;
// Seed secrets
crate::secrets::cmd_seed().await?;
// Gitea bootstrap
crate::gitea::cmd_bootstrap().await?;
// Mirror amd64-only images
crate::images::cmd_mirror().await?;
// Wait for core services
wait_for_core().await?;
// Get gitea admin password for URL display
let admin_pass = crate::kube::kube_get_secret_field(
"devtools",
"gitea-admin-credentials",
"password",
)
.await
.unwrap_or_default();
print_urls(&domain, &admin_pass);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cert_manager_url_points_to_github_release() {
assert!(CERT_MANAGER_URL.starts_with("https://github.com/cert-manager/cert-manager/"));
assert!(CERT_MANAGER_URL.contains("/releases/download/"));
assert!(CERT_MANAGER_URL.ends_with(".yaml"));
}
#[test]
fn cert_manager_url_has_version() {
// Verify the URL contains a version tag like v1.x.x
assert!(
CERT_MANAGER_URL.contains("/v1."),
"CERT_MANAGER_URL should reference a v1.x release"
);
}
#[test]
fn gateway_api_crds_url_points_to_github_release() {
assert!(GATEWAY_API_CRDS_URL
.starts_with("https://github.com/kubernetes-sigs/gateway-api/"));
assert!(GATEWAY_API_CRDS_URL.contains("/releases/download/"));
assert!(GATEWAY_API_CRDS_URL.ends_with(".yaml"));
}
#[test]
fn gateway_api_crds_url_has_version() {
assert!(
GATEWAY_API_CRDS_URL.contains("/v1."),
"GATEWAY_API_CRDS_URL should reference a v1.x release"
);
}
#[test]
fn secrets_dir_ends_with_secrets_local() {
let dir = secrets_dir();
assert!(
dir.ends_with("secrets/local"),
"secrets_dir() should end with secrets/local, got: {}",
dir.display()
);
}
#[test]
fn secrets_dir_has_at_least_three_components() {
let dir = secrets_dir();
let components: Vec<_> = dir.components().collect();
assert!(
components.len() >= 3,
"secrets_dir() should have at least 3 path components (base/secrets/local), got: {}",
dir.display()
);
}
#[test]
fn gitea_admin_user_constant() {
assert_eq!(GITEA_ADMIN_USER, "gitea_admin");
}
#[test]
fn print_urls_contains_expected_services() {
// Capture print_urls output by checking the URL construction logic.
// We can't easily capture stdout in unit tests, but we can verify
// the URL format matches expectations.
let domain = "test.local";
let expected_urls = [
format!("https://auth.{domain}/"),
format!("https://docs.{domain}/"),
format!("https://meet.{domain}/"),
format!("https://drive.{domain}/"),
format!("https://chat.{domain}/"),
format!("https://mail.{domain}/"),
format!("https://people.{domain}/"),
format!("https://src.{domain}/"),
];
// Verify URL patterns are valid
for url in &expected_urls {
assert!(url.starts_with("https://"));
assert!(url.contains(domain));
}
}
#[test]
fn print_urls_gitea_includes_credentials() {
let domain = "example.local";
let pass = "s3cret";
let gitea_url = format!(
"https://src.{domain}/ ({GITEA_ADMIN_USER} / {pass})"
);
assert!(gitea_url.contains(GITEA_ADMIN_USER));
assert!(gitea_url.contains(pass));
assert!(gitea_url.contains(&format!("src.{domain}")));
}
}