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).
430 lines
12 KiB
Rust
430 lines
12 KiB
Rust
//! 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<()> {
|
|
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");
|
|
}
|
|
}
|