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).
This commit is contained in:
428
src/gitea.rs
428
src/gitea.rs
@@ -1,5 +1,429 @@
|
||||
use anyhow::Result;
|
||||
//! Gitea bootstrap -- admin setup, org creation, OIDC auth source configuration.
|
||||
|
||||
use anyhow::Result;
|
||||
use k8s_openapi::api::core::v1::Pod;
|
||||
use kube::api::{Api, ListParams};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::kube::{get_client, get_domain, kube_exec, kube_get_secret_field};
|
||||
use crate::output::{ok, step, warn};
|
||||
|
||||
const GITEA_ADMIN_USER: &str = "gitea_admin";
|
||||
const GITEA_ADMIN_EMAIL: &str = "gitea@local.domain";
|
||||
|
||||
/// Bootstrap Gitea: set admin password, create orgs, configure OIDC.
|
||||
pub async fn cmd_bootstrap() -> Result<()> {
|
||||
todo!("cmd_bootstrap: Gitea admin + org setup via kube-rs exec + reqwest")
|
||||
let domain = get_domain().await?;
|
||||
|
||||
// Retrieve gitea admin password from cluster secret
|
||||
let gitea_admin_pass = kube_get_secret_field("devtools", "gitea-admin-credentials", "password")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if gitea_admin_pass.is_empty() {
|
||||
warn("gitea-admin-credentials password not found -- cannot bootstrap.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
step("Bootstrapping Gitea...");
|
||||
|
||||
// Wait for a Running + Ready Gitea pod
|
||||
let pod_name = wait_for_gitea_pod().await?;
|
||||
let Some(pod) = pod_name else {
|
||||
warn("Gitea pod not ready after 3 min -- skipping bootstrap.");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Set admin password
|
||||
set_admin_password(&pod, &gitea_admin_pass).await?;
|
||||
|
||||
// Mark admin as private
|
||||
mark_admin_private(&pod, &gitea_admin_pass).await?;
|
||||
|
||||
// Create orgs
|
||||
create_orgs(&pod, &gitea_admin_pass).await?;
|
||||
|
||||
// Configure OIDC auth source
|
||||
configure_oidc(&pod, &gitea_admin_pass).await?;
|
||||
|
||||
ok(&format!(
|
||||
"Gitea ready -- https://src.{domain} ({GITEA_ADMIN_USER} / <from openbao>)"
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for a Running + Ready Gitea pod (up to 3 minutes).
|
||||
async fn wait_for_gitea_pod() -> Result<Option<String>> {
|
||||
let client = get_client().await?;
|
||||
let pods: Api<Pod> = Api::namespaced(client.clone(), "devtools");
|
||||
|
||||
for _ in 0..60 {
|
||||
let lp = ListParams::default().labels("app.kubernetes.io/name=gitea");
|
||||
if let Ok(pod_list) = pods.list(&lp).await {
|
||||
for pod in &pod_list.items {
|
||||
let phase = pod
|
||||
.status
|
||||
.as_ref()
|
||||
.and_then(|s| s.phase.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
if phase != "Running" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ready = pod
|
||||
.status
|
||||
.as_ref()
|
||||
.and_then(|s| s.container_statuses.as_ref())
|
||||
.and_then(|cs| cs.first())
|
||||
.map(|c| c.ready)
|
||||
.unwrap_or(false);
|
||||
|
||||
if ready {
|
||||
let name = pod
|
||||
.metadata
|
||||
.name
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !name.is_empty() {
|
||||
return Ok(Some(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Set the admin password via gitea CLI exec.
|
||||
async fn set_admin_password(pod: &str, password: &str) -> Result<()> {
|
||||
let (code, output) = kube_exec(
|
||||
"devtools",
|
||||
pod,
|
||||
&[
|
||||
"gitea",
|
||||
"admin",
|
||||
"user",
|
||||
"change-password",
|
||||
"--username",
|
||||
GITEA_ADMIN_USER,
|
||||
"--password",
|
||||
password,
|
||||
"--must-change-password=false",
|
||||
],
|
||||
Some("gitea"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if code == 0 || output.to_lowercase().contains("password") {
|
||||
ok(&format!("Admin '{GITEA_ADMIN_USER}' password set."));
|
||||
} else {
|
||||
warn(&format!("change-password: {output}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call Gitea API via kubectl exec + curl inside the pod.
|
||||
async fn gitea_api(
|
||||
pod: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
password: &str,
|
||||
data: Option<&Value>,
|
||||
) -> Result<Value> {
|
||||
let url = format!("http://localhost:3000/api/v1{path}");
|
||||
let auth = format!("{GITEA_ADMIN_USER}:{password}");
|
||||
|
||||
let mut args = vec![
|
||||
"curl", "-s", "-X", method, &url, "-H", "Content-Type: application/json", "-u", &auth,
|
||||
];
|
||||
|
||||
let data_str;
|
||||
if let Some(d) = data {
|
||||
data_str = serde_json::to_string(d)?;
|
||||
args.push("-d");
|
||||
args.push(&data_str);
|
||||
}
|
||||
|
||||
let (_, stdout) = kube_exec("devtools", pod, &args, Some("gitea")).await?;
|
||||
|
||||
Ok(serde_json::from_str(&stdout).unwrap_or(Value::Object(Default::default())))
|
||||
}
|
||||
|
||||
/// Mark the admin account as private.
|
||||
async fn mark_admin_private(pod: &str, password: &str) -> Result<()> {
|
||||
let data = serde_json::json!({
|
||||
"source_id": 0,
|
||||
"login_name": GITEA_ADMIN_USER,
|
||||
"email": GITEA_ADMIN_EMAIL,
|
||||
"visibility": "private",
|
||||
});
|
||||
|
||||
let result = gitea_api(
|
||||
pod,
|
||||
"PATCH",
|
||||
&format!("/admin/users/{GITEA_ADMIN_USER}"),
|
||||
password,
|
||||
Some(&data),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if result.get("login").and_then(|v| v.as_str()) == Some(GITEA_ADMIN_USER) {
|
||||
ok(&format!("Admin '{GITEA_ADMIN_USER}' marked as private."));
|
||||
} else {
|
||||
warn(&format!("Could not set admin visibility: {result}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the studio and internal organizations.
|
||||
async fn create_orgs(pod: &str, password: &str) -> Result<()> {
|
||||
let orgs = [
|
||||
("studio", "public", "Public source code"),
|
||||
("internal", "private", "Internal tools and services"),
|
||||
];
|
||||
|
||||
for (org_name, visibility, desc) in &orgs {
|
||||
let data = serde_json::json!({
|
||||
"username": org_name,
|
||||
"visibility": visibility,
|
||||
"description": desc,
|
||||
});
|
||||
|
||||
let result = gitea_api(pod, "POST", "/orgs", password, Some(&data)).await?;
|
||||
|
||||
if result.get("id").is_some() {
|
||||
ok(&format!("Created org '{org_name}'."));
|
||||
} else if result
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
.contains("already")
|
||||
{
|
||||
ok(&format!("Org '{org_name}' already exists."));
|
||||
} else {
|
||||
let msg = result
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("{result}"));
|
||||
warn(&format!("Org '{org_name}': {msg}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure Hydra as the OIDC authentication source.
|
||||
async fn configure_oidc(pod: &str, _password: &str) -> Result<()> {
|
||||
// List existing auth sources
|
||||
let (_, auth_list_output) =
|
||||
kube_exec("devtools", pod, &["gitea", "admin", "auth", "list"], Some("gitea")).await?;
|
||||
|
||||
let mut existing_id: Option<String> = None;
|
||||
let mut exact_ok = false;
|
||||
|
||||
for line in auth_list_output.lines().skip(1) {
|
||||
// Tab-separated: ID\tName\tType\tEnabled
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let src_id = parts[0].trim();
|
||||
let src_name = parts[1].trim();
|
||||
|
||||
if src_name == "Sunbeam" {
|
||||
exact_ok = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let src_type = if parts.len() > 2 {
|
||||
parts[2].trim()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
if src_name == "Sunbeam Auth"
|
||||
|| (src_name.starts_with("Sunbeam") && src_type == "OAuth2")
|
||||
{
|
||||
existing_id = Some(src_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if exact_ok {
|
||||
ok("OIDC auth source 'Sunbeam' already present.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(eid) = existing_id {
|
||||
// Wrong name -- rename in-place
|
||||
let (code, stderr) = kube_exec(
|
||||
"devtools",
|
||||
pod,
|
||||
&[
|
||||
"gitea",
|
||||
"admin",
|
||||
"auth",
|
||||
"update-oauth",
|
||||
"--id",
|
||||
&eid,
|
||||
"--name",
|
||||
"Sunbeam",
|
||||
],
|
||||
Some("gitea"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if code == 0 {
|
||||
ok(&format!(
|
||||
"Renamed OIDC auth source (id={eid}) to 'Sunbeam'."
|
||||
));
|
||||
} else {
|
||||
warn(&format!("Rename failed: {stderr}"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create new OIDC auth source
|
||||
let oidc_id = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_ID").await;
|
||||
let oidc_secret = kube_get_secret_field("lasuite", "oidc-gitea", "CLIENT_SECRET").await;
|
||||
|
||||
match (oidc_id, oidc_secret) {
|
||||
(Ok(oidc_id), Ok(oidc_sec)) => {
|
||||
let discover_url =
|
||||
"http://hydra-public.ory.svc.cluster.local:4444/.well-known/openid-configuration";
|
||||
|
||||
let (code, stderr) = kube_exec(
|
||||
"devtools",
|
||||
pod,
|
||||
&[
|
||||
"gitea",
|
||||
"admin",
|
||||
"auth",
|
||||
"add-oauth",
|
||||
"--name",
|
||||
"Sunbeam",
|
||||
"--provider",
|
||||
"openidConnect",
|
||||
"--key",
|
||||
&oidc_id,
|
||||
"--secret",
|
||||
&oidc_sec,
|
||||
"--auto-discover-url",
|
||||
discover_url,
|
||||
"--scopes",
|
||||
"openid",
|
||||
"--scopes",
|
||||
"email",
|
||||
"--scopes",
|
||||
"profile",
|
||||
],
|
||||
Some("gitea"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if code == 0 {
|
||||
ok("OIDC auth source 'Sunbeam' configured.");
|
||||
} else {
|
||||
warn(&format!("OIDC auth source config failed: {stderr}"));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn("oidc-gitea secret not found -- OIDC auth source not configured.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_constants() {
|
||||
assert_eq!(GITEA_ADMIN_USER, "gitea_admin");
|
||||
assert_eq!(GITEA_ADMIN_EMAIL, "gitea@local.domain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_org_definitions() {
|
||||
// Verify the org configs match the Python version
|
||||
let orgs = [
|
||||
("studio", "public", "Public source code"),
|
||||
("internal", "private", "Internal tools and services"),
|
||||
];
|
||||
assert_eq!(orgs[0].0, "studio");
|
||||
assert_eq!(orgs[0].1, "public");
|
||||
assert_eq!(orgs[1].0, "internal");
|
||||
assert_eq!(orgs[1].1, "private");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_auth_list_output() {
|
||||
let output = "ID\tName\tType\tEnabled\n1\tSunbeam\tOAuth2\ttrue\n";
|
||||
let mut found = false;
|
||||
for line in output.lines().skip(1) {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 2 && parts[1].trim() == "Sunbeam" {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
assert!(found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_auth_list_rename_needed() {
|
||||
let output = "ID\tName\tType\tEnabled\n5\tSunbeam Auth\tOAuth2\ttrue\n";
|
||||
let mut rename_id: Option<String> = None;
|
||||
for line in output.lines().skip(1) {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 3 {
|
||||
let name = parts[1].trim();
|
||||
let typ = parts[2].trim();
|
||||
if name == "Sunbeam Auth" || (name.starts_with("Sunbeam") && typ == "OAuth2") {
|
||||
rename_id = Some(parts[0].trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(rename_id, Some("5".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gitea_api_response_parsing() {
|
||||
// Simulate a successful org creation response
|
||||
let json_str = r#"{"id": 1, "username": "studio"}"#;
|
||||
let val: Value = serde_json::from_str(json_str).unwrap();
|
||||
assert!(val.get("id").is_some());
|
||||
|
||||
// Simulate an "already exists" response
|
||||
let json_str = r#"{"message": "organization already exists"}"#;
|
||||
let val: Value = serde_json::from_str(json_str).unwrap();
|
||||
assert!(val
|
||||
.get("message")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_lowercase()
|
||||
.contains("already"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_admin_visibility_patch_body() {
|
||||
let data = serde_json::json!({
|
||||
"source_id": 0,
|
||||
"login_name": GITEA_ADMIN_USER,
|
||||
"email": GITEA_ADMIN_EMAIL,
|
||||
"visibility": "private",
|
||||
});
|
||||
assert_eq!(data["login_name"], "gitea_admin");
|
||||
assert_eq!(data["visibility"], "private");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user