From 20740af16cf3da5eebd704d83fae9f6817180503 Mon Sep 17 00:00:00 2001 From: Mathieu JESER <6737167+bobdivx@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:18:54 +0100 Subject: [PATCH 1/4] feat(kube): backend Kubernetes phase 1 + chart Helm + wiki MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crate devforge-kube : déploiement d'images (Deployment/Service/Ingress/Secret), probes, statut, logs, stop/start/restart, rolling update, prune (server-side apply) - DEVFORGE_RUNTIME=kubernetes (Docker reste le défaut) ; routes /api/v1/kube/* (instance_admin) ; Traefik/GC/auto-update Docker désactivés en mode k8s - chart Helm deploy/helm/devforge (PVC /data, Postgres intégré ou externe, RBAC limité au namespace des apps, PodSecurity restricted) - exemple GitHub Actions build → GHCR → déploiement ; wiki Kubernetes (Talos) - tests : snapshots de manifests, test live (DEVFORGE_KUBE_IT=1) --- Cargo.lock | 16 + Cargo.toml | 2 + apps/server/Cargo.toml | 1 + apps/server/src/kube_routes.rs | 216 +++++++++ apps/server/src/main.rs | 35 +- crates/kube/Cargo.toml | 18 + crates/kube/src/client.rs | 321 +++++++++++++ crates/kube/src/config.rs | 173 +++++++ crates/kube/src/lib.rs | 31 ++ crates/kube/src/manifests.rs | 415 ++++++++++++++++ crates/kube/src/runtime.rs | 382 +++++++++++++++ crates/kube/src/spec.rs | 441 ++++++++++++++++++ crates/kube/src/status.rs | 293 ++++++++++++ crates/kube/tests/live_cluster.rs | 125 +++++ crates/kube/tests/manifests_snapshot.rs | 71 +++ crates/kube/tests/snapshots/full.json | 237 ++++++++++ crates/kube/tests/snapshots/minimal.json | 131 ++++++ crates/shared/src/lib.rs | 1 + crates/shared/src/runtime_backend.rs | 61 +++ crates/update/src/lib.rs | 10 + deploy/helm/devforge/.helmignore | 3 + deploy/helm/devforge/Chart.yaml | 13 + deploy/helm/devforge/templates/NOTES.txt | 14 + deploy/helm/devforge/templates/_helpers.tpl | 46 ++ .../devforge/templates/apps-namespace.yaml | 16 + .../helm/devforge/templates/deployment.yaml | 145 ++++++ deploy/helm/devforge/templates/ingress.yaml | 34 ++ .../helm/devforge/templates/postgresql.yaml | 130 ++++++ deploy/helm/devforge/templates/pvc.yaml | 20 + deploy/helm/devforge/templates/rbac.yaml | 44 ++ deploy/helm/devforge/templates/service.yaml | 16 + .../devforge/templates/serviceaccount.yaml | 14 + deploy/helm/devforge/values.yaml | 120 +++++ deploy/kubernetes/README.md | 22 + .../examples/github-actions-build-push.yml | 74 +++ wiki/FAQ.md | 4 +- wiki/Home.md | 1 + wiki/Installation.md | 4 + wiki/Kubernetes.md | 287 ++++++++++++ wiki/_Sidebar.md | 1 + 40 files changed, 3976 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/kube_routes.rs create mode 100644 crates/kube/Cargo.toml create mode 100644 crates/kube/src/client.rs create mode 100644 crates/kube/src/config.rs create mode 100644 crates/kube/src/lib.rs create mode 100644 crates/kube/src/manifests.rs create mode 100644 crates/kube/src/runtime.rs create mode 100644 crates/kube/src/spec.rs create mode 100644 crates/kube/src/status.rs create mode 100644 crates/kube/tests/live_cluster.rs create mode 100644 crates/kube/tests/manifests_snapshot.rs create mode 100644 crates/kube/tests/snapshots/full.json create mode 100644 crates/kube/tests/snapshots/minimal.json create mode 100644 crates/shared/src/runtime_backend.rs create mode 100644 deploy/helm/devforge/.helmignore create mode 100644 deploy/helm/devforge/Chart.yaml create mode 100644 deploy/helm/devforge/templates/NOTES.txt create mode 100644 deploy/helm/devforge/templates/_helpers.tpl create mode 100644 deploy/helm/devforge/templates/apps-namespace.yaml create mode 100644 deploy/helm/devforge/templates/deployment.yaml create mode 100644 deploy/helm/devforge/templates/ingress.yaml create mode 100644 deploy/helm/devforge/templates/postgresql.yaml create mode 100644 deploy/helm/devforge/templates/pvc.yaml create mode 100644 deploy/helm/devforge/templates/rbac.yaml create mode 100644 deploy/helm/devforge/templates/service.yaml create mode 100644 deploy/helm/devforge/templates/serviceaccount.yaml create mode 100644 deploy/helm/devforge/values.yaml create mode 100644 deploy/kubernetes/README.md create mode 100644 deploy/kubernetes/examples/github-actions-build-push.yml create mode 100644 wiki/Kubernetes.md diff --git a/Cargo.lock b/Cargo.lock index 9cf27d5c06..ab96269a46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,21 @@ dependencies = [ "wiremock", ] +[[package]] +name = "devforge-kube" +version = "2.0.278" +dependencies = [ + "async-trait", + "chrono", + "devforge-shared", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tracing", +] + [[package]] name = "devforge-llm" version = "2.0.278" @@ -890,6 +905,7 @@ dependencies = [ "devforge-domain", "devforge-env", "devforge-github", + "devforge-kube", "devforge-llm", "devforge-mcp", "devforge-memory", diff --git a/Cargo.toml b/Cargo.toml index 3189595bb5..300996bc6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ members = [ "crates/cron", "crates/cluster", "crates/memory", + "crates/kube", ] [workspace.package] @@ -67,6 +68,7 @@ devforge-update = { path = "crates/update" } devforge-runner = { path = "crates/runner" } devforge-cron = { path = "crates/cron" } devforge-cluster = { path = "crates/cluster" } +devforge-kube = { path = "crates/kube" } [workspace.metadata.cargo-machete] ignored = ["idna"] diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 39eccc4396..31e1dcdffa 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -30,6 +30,7 @@ devforge-update = { workspace = true } devforge-runner = { workspace = true } devforge-cron = { workspace = true } devforge-cluster = { workspace = true } +devforge-kube = { workspace = true } axum = { workspace = true } tower-http = { workspace = true } tokio = { workspace = true } diff --git a/apps/server/src/kube_routes.rs b/apps/server/src/kube_routes.rs new file mode 100644 index 0000000000..ef8012a85b --- /dev/null +++ b/apps/server/src/kube_routes.rs @@ -0,0 +1,216 @@ +//! API du backend Kubernetes (phase 1) : déployer une image déjà construite. +//! +//! Actif seulement avec `DEVFORGE_RUNTIME=kubernetes` (sauf `render`, qui ne touche pas +//! au cluster). Réservé à l'administrateur d'instance. Le backend Docker reste le défaut. + +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::Json, + routing::{get, post}, + Router, +}; +use devforge_kube::{runtime::ImageRuntime, AppSpec, KubeDefaults, KubeError, KubeRuntime}; +use devforge_shared::runtime_backend::RuntimeBackend; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::state::AppState; + +type ApiError = (StatusCode, Json); + +pub fn router() -> Router { + Router::new() + .route("/api/v1/kube/info", get(info)) + .route("/api/v1/kube/render", post(render)) + .route("/api/v1/kube/apps", get(list).post(deploy)) + .route("/api/v1/kube/apps/{name}", get(status).delete(remove)) + .route("/api/v1/kube/apps/{name}/logs", get(logs)) + .route("/api/v1/kube/apps/{name}/stop", post(stop)) + .route("/api/v1/kube/apps/{name}/start", post(start)) + .route("/api/v1/kube/apps/{name}/restart", post(restart)) +} + +static RUNTIME: OnceLock, String>> = OnceLock::new(); + +fn runtime() -> Result, ApiError> { + if !RuntimeBackend::from_env().is_kubernetes() { + return Err(( + StatusCode::CONFLICT, + Json(json!({ + "error": "Backend Kubernetes inactif (DEVFORGE_RUNTIME=docker). Voir le wiki : Kubernetes.", + "runtime": "docker", + })), + )); + } + RUNTIME + .get_or_init(|| { + KubeRuntime::from_env() + .map(Arc::new) + .map_err(|e| e.to_string()) + }) + .clone() + .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": e })))) +} + +fn kube_err(e: KubeError) -> ApiError { + let code = StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::BAD_GATEWAY); + (code, Json(json!({ "error": e.to_string() }))) +} + +async fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + let (user, _) = crate::auth_routes::current_workspace(state, headers).await?; + if user.role != "instance_admin" { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({"error": "Réservé à l'administrateur d'instance"})), + )); + } + Ok(()) +} + +async fn info(State(state): State, headers: HeaderMap) -> Result, ApiError> { + require_admin(&state, &headers).await?; + let backend = RuntimeBackend::from_env(); + if !backend.is_kubernetes() { + return Ok(Json( + json!({ "runtime": backend.as_str(), "enabled": false }), + )); + } + let rt = runtime()?; + let d = rt.defaults(); + let version = rt.client().version().await; + Ok(Json(json!({ + "runtime": backend.as_str(), + "enabled": true, + "namespace": d.namespace, + "ingress_class": d.ingress_class, + "image_pull_secret": d.image_pull_secret, + "cluster_issuer": d.cluster_issuer, + "reachable": version.is_ok(), + "server_version": version.as_ref().ok(), + "error": version.err().map(|e| e.to_string()), + "build_in_cluster": false, + }))) +} + +/// Aperçu des manifests (Secret masqué). Fonctionne aussi en mode Docker. +async fn render( + State(state): State, + headers: HeaderMap, + Json(mut spec): Json, +) -> Result, ApiError> { + require_admin(&state, &headers).await?; + spec.normalize() + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))))?; + let defaults = KubeDefaults::from_env(); + let rendered = devforge_kube::render(&spec, &defaults); + Ok(Json(json!({ "data": rendered.redacted() }))) +} + +async fn list(State(state): State, headers: HeaderMap) -> Result, ApiError> { + require_admin(&state, &headers).await?; + let rt = runtime()?; + let apps = rt.list().await.map_err(kube_err)?; + Ok(Json(json!({ "data": apps, "namespace": rt.namespace() }))) +} + +#[derive(Deserialize, Default)] +struct DeployQuery { + /// Attendre la fin du rollout. + #[serde(default)] + wait: bool, + /// Secondes (défaut 120, max 600). + timeout: Option, +} + +async fn deploy( + State(state): State, + headers: HeaderMap, + Query(q): Query, + Json(spec): Json, +) -> Result, ApiError> { + require_admin(&state, &headers).await?; + let rt = runtime()?; + let mut outcome = rt.deploy(&spec).await.map_err(kube_err)?; + if q.wait { + let secs = q.timeout.unwrap_or(120).clamp(5, 600); + outcome.status = rt + .wait_rollout(&outcome.name, Duration::from_secs(secs)) + .await + .map_err(kube_err)?; + } + Ok(Json(json!({ "ok": true, "data": outcome }))) +} + +async fn status( + State(state): State, + headers: HeaderMap, + Path(name): Path, +) -> Result, ApiError> { + require_admin(&state, &headers).await?; + let st = runtime()?.status(&name).await.map_err(kube_err)?; + Ok(Json(json!({ "data": st }))) +} + +#[derive(Deserialize, Default)] +struct LogsQuery { + tail: Option, +} + +async fn logs( + State(state): State, + headers: HeaderMap, + Path(name): Path, + Query(q): Query, +) -> Result, ApiError> { + require_admin(&state, &headers).await?; + let out = runtime()? + .logs(&name, q.tail.unwrap_or(200)) + .await + .map_err(kube_err)?; + Ok(Json(json!({ "data": out }))) +} + +async fn stop( + State(state): State, + headers: HeaderMap, + Path(name): Path, +) -> Result, ApiError> { + require_admin(&state, &headers).await?; + let st = runtime()?.stop(&name).await.map_err(kube_err)?; + Ok(Json(json!({ "ok": true, "data": st }))) +} + +async fn start( + State(state): State, + headers: HeaderMap, + Path(name): Path, +) -> Result, ApiError> { + require_admin(&state, &headers).await?; + let st = runtime()?.start(&name).await.map_err(kube_err)?; + Ok(Json(json!({ "ok": true, "data": st }))) +} + +async fn restart( + State(state): State, + headers: HeaderMap, + Path(name): Path, +) -> Result, ApiError> { + require_admin(&state, &headers).await?; + let st = runtime()?.restart(&name).await.map_err(kube_err)?; + Ok(Json(json!({ "ok": true, "data": st }))) +} + +async fn remove( + State(state): State, + headers: HeaderMap, + Path(name): Path, +) -> Result, ApiError> { + require_admin(&state, &headers).await?; + let removed = runtime()?.remove(&name).await.map_err(kube_err)?; + Ok(Json(json!({ "ok": true, "removed": removed }))) +} diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index afb8089695..bba50f0621 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -43,28 +43,29 @@ mod grok_bot_tests; mod group_routes; mod infra_routes; mod infra_sqlite; +mod kube_routes; mod llm_routes; mod mcp_oauth; mod mcp_routes; mod memory_routes; mod mobile_routes; -mod paths; -mod playbooks; +mod page_watch; +mod page_watch_routes; #[cfg(test)] -mod playbooks_tests; +mod page_watch_tests; +mod paths; mod platform_sso; #[cfg(test)] mod platform_sso_tests; +mod playbooks; +#[cfg(test)] +mod playbooks_tests; mod pocket_id; mod pr_previews; mod project_oidc; mod project_oidc_routes; mod project_pg; mod project_pg_routes; -mod page_watch; -mod page_watch_routes; -#[cfg(test)] -mod page_watch_tests; mod receipts; mod rich_chat; mod routes; @@ -238,7 +239,13 @@ async fn main() -> Result<(), Box> { acme_email_set = acme.is_some(), "email ACME du proxy résolu" ); - if let Err(e) = state.proxy.ensure_traefik().await { + // Kubernetes : le routage passe par l'ingress controller du cluster, pas par Traefik/Docker. + let kube_runtime = devforge_shared::runtime_backend::RuntimeBackend::from_env().is_kubernetes(); + if kube_runtime { + tracing::info!( + "DEVFORGE_RUNTIME=kubernetes — proxy Traefik Docker, GC Docker et auto-update désactivés" + ); + } else if let Err(e) = state.proxy.ensure_traefik().await { tracing::error!(error = %e, "Failed to ensure Traefik container — proxy may be unavailable"); } else { tracing::info!("Traefik reverse proxy ready"); @@ -247,7 +254,7 @@ async fn main() -> Result<(), Box> { // Watchdog: periodic health check for Traefik (fix for 2026-09-14 outage). // Every 2 minutes (reduced from 5), ensure Traefik exists and is running. // More frequent checks catch disappearances faster after deploys or network changes. - { + if !kube_runtime { let proxy = state.proxy.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(120)); @@ -265,7 +272,9 @@ async fn main() -> Result<(), Box> { crate::dns::spawn_dns_loop(state.clone()); crate::dns_failover::spawn_loop(state.clone()); - crate::docker_gc_routes::spawn_loop(state.clone()); + if !kube_runtime { + crate::docker_gc_routes::spawn_loop(state.clone()); + } // P2 : actions coupées par le redémarrage → « issue inconnue », avant toute reprise. receipts::recover_at_boot(&state.pool).await; routes::resume_deploy_queue(state.clone()); @@ -383,7 +392,10 @@ async fn main() -> Result<(), Box> { }); } - update_routes::spawn_auto_update(state.clone()); + if !kube_runtime { + // En mode k8s la mise à jour passe par Helm (`helm upgrade`), pas par Docker. + update_routes::spawn_auto_update(state.clone()); + } cursor_worker_routes::spawn_autostart(state.clone()); // Advertise agent tools on the local MCP server surface. @@ -435,6 +447,7 @@ async fn main() -> Result<(), Box> { .merge(mobile_routes::router()) .merge(android_routes::router()) .merge(cluster_routes::router()) + .merge(kube_routes::router()) .merge(cursor_worker_routes::router()) .merge(worker::exec_route()) .route("/", get(auth_routes::marketing_home)) diff --git a/crates/kube/Cargo.toml b/crates/kube/Cargo.toml new file mode 100644 index 0000000000..53be641715 --- /dev/null +++ b/crates/kube/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "devforge-kube" +version.workspace = true +edition.workspace = true + +[dependencies] +devforge-shared = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } +reqwest = { workspace = true } +sha2 = "0.10" + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/kube/src/client.rs b/crates/kube/src/client.rs new file mode 100644 index 0000000000..849bb6a0eb --- /dev/null +++ b/crates/kube/src/client.rs @@ -0,0 +1,321 @@ +//! Client REST minimal de l'API Kubernetes (server-side apply, get, patch, delete, logs). +//! +//! Volontairement léger (reqwest + JSON) plutôt qu'un client complet : la phase 1 n'utilise +//! qu'une dizaine d'appels sur 5 types de ressources. + +use std::time::Duration; + +use reqwest::{Method, StatusCode}; +use serde_json::Value; + +use crate::config::KubeConfig; +use crate::FIELD_MANAGER; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KubeError { + /// Configuration absente / invalide (hors cluster, CA illisible…). + Config(String), + /// Réseau / TLS. + Transport(String), + /// Réponse HTTP non 2xx de l'API (message `Status` de Kubernetes). + Api { status: u16, message: String }, + /// Spec refusée avant tout appel. + Invalid(String), +} + +impl std::fmt::Display for KubeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Config(m) => write!(f, "configuration Kubernetes : {m}"), + Self::Transport(m) => write!(f, "API Kubernetes injoignable : {m}"), + Self::Api { status, message } => { + let hint = match status { + 401 => " (jeton du compte de service refusé)", + 403 => " (droits RBAC insuffisants — voir Role du chart Helm)", + _ => "", + }; + write!(f, "API Kubernetes HTTP {status} : {message}{hint}") + } + Self::Invalid(m) => write!(f, "{m}"), + } + } +} + +impl std::error::Error for KubeError {} + +impl KubeError { + pub fn is_not_found(&self) -> bool { + matches!(self, Self::Api { status: 404, .. }) + } + + /// Code HTTP à renvoyer par l'API DevForge. + pub fn http_status(&self) -> u16 { + match self { + Self::Invalid(_) => 400, + Self::Api { status: 404, .. } => 404, + Self::Api { status: 409, .. } | Self::Api { status: 422, .. } => 422, + Self::Config(_) => 503, + _ => 502, + } + } +} + +#[derive(Clone)] +pub struct KubeClient { + cfg: KubeConfig, + http: reqwest::Client, +} + +impl std::fmt::Debug for KubeClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KubeClient") + .field("api_url", &self.cfg.api_url) + .finish() + } +} + +impl KubeClient { + pub fn new(cfg: KubeConfig) -> Result { + let mut b = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)); + if let Some(pem) = &cfg.ca_pem { + let certs = reqwest::Certificate::from_pem_bundle(pem) + .map_err(|e| KubeError::Config(format!("CA invalide : {e}")))?; + for c in certs { + b = b.add_root_certificate(c); + } + } + if cfg.insecure_skip_tls_verify { + tracing::warn!("DEVFORGE_KUBE_INSECURE_SKIP_TLS_VERIFY actif — dev uniquement"); + b = b.danger_accept_invalid_certs(true); + } + let http = b + .build() + .map_err(|e| KubeError::Config(format!("client HTTP : {e}")))?; + Ok(Self { cfg, http }) + } + + pub fn from_env() -> Result { + Self::new(KubeConfig::from_env().map_err(KubeError::Config)?) + } + + pub fn api_url(&self) -> &str { + &self.cfg.api_url + } + + async fn send( + &self, + method: Method, + path: &str, + content_type: Option<&str>, + body: Option<&Value>, + ) -> Result { + let url = format!("{}{}", self.cfg.api_url, path); + let mut req = self + .http + .request(method, &url) + .header("Accept", "application/json"); + if let Some(tok) = self.cfg.bearer_token().map_err(KubeError::Config)? { + req = req.bearer_auth(tok); + } + if let Some(body) = body { + req = req + .header("Content-Type", content_type.unwrap_or("application/json")) + .body(body.to_string()); + } + let resp = req + .send() + .await + .map_err(|e| KubeError::Transport(e.without_url().to_string()))?; + if resp.status().is_success() { + return Ok(resp); + } + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + Err(api_error(status, &text)) + } + + async fn json( + &self, + method: Method, + path: &str, + content_type: Option<&str>, + body: Option<&Value>, + ) -> Result { + let resp = self.send(method, path, content_type, body).await?; + let text = resp + .text() + .await + .map_err(|e| KubeError::Transport(e.to_string()))?; + if text.trim().is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(&text).map_err(|e| KubeError::Transport(format!("JSON : {e}"))) + } + + /// `GET`, `None` si 404. + pub async fn get(&self, path: &str) -> Result, KubeError> { + match self.json(Method::GET, path, None, None).await { + Ok(v) => Ok(Some(v)), + Err(e) if e.is_not_found() => Ok(None), + Err(e) => Err(e), + } + } + + pub async fn get_text(&self, path: &str) -> Result { + self.send(Method::GET, path, None, None) + .await? + .text() + .await + .map_err(|e| KubeError::Transport(e.to_string())) + } + + /// Server-side apply (`fieldManager=devforge`, `force=true`). Le JSON est un YAML valide. + pub async fn apply(&self, path: &str, manifest: &Value) -> Result { + let sep = if path.contains('?') { '&' } else { '?' }; + let p = format!("{path}{sep}fieldManager={FIELD_MANAGER}&force=true"); + self.json( + Method::PATCH, + &p, + Some("application/apply-patch+yaml"), + Some(manifest), + ) + .await + } + + /// JSON merge patch (stop / start / restart). + pub async fn merge_patch(&self, path: &str, patch: &Value) -> Result { + let sep = if path.contains('?') { '&' } else { '?' }; + let p = format!("{path}{sep}fieldManager={FIELD_MANAGER}"); + self.json( + Method::PATCH, + &p, + Some("application/merge-patch+json"), + Some(patch), + ) + .await + } + + /// `DELETE` (propagation en arrière-plan). `false` si déjà absent. + pub async fn delete(&self, path: &str) -> Result { + let sep = if path.contains('?') { '&' } else { '?' }; + let p = format!("{path}{sep}propagationPolicy=Background"); + match self.json(Method::DELETE, &p, None, None).await { + Ok(_) => Ok(true), + Err(e) if e.is_not_found() => Ok(false), + Err(e) => Err(e), + } + } + + /// Version du serveur (test de connectivité). + pub async fn version(&self) -> Result { + let v = self.json(Method::GET, "/version", None, None).await?; + Ok(v.get("gitVersion") + .and_then(Value::as_str) + .unwrap_or("inconnue") + .to_string()) + } +} + +fn api_error(status: StatusCode, body: &str) -> KubeError { + let message = serde_json::from_str::(body) + .ok() + .and_then(|v| v.get("message").and_then(Value::as_str).map(str::to_string)) + .unwrap_or_else(|| body.chars().take(300).collect()); + KubeError::Api { + status: status.as_u16(), + message: devforge_shared::log_redact::redact_log_text(&message), + } +} + +/// Chemins REST des ressources gérées. +pub mod paths { + pub fn deployments(ns: &str) -> String { + format!("/apis/apps/v1/namespaces/{ns}/deployments") + } + pub fn deployment(ns: &str, name: &str) -> String { + format!("{}/{name}", deployments(ns)) + } + pub fn service(ns: &str, name: &str) -> String { + format!("/api/v1/namespaces/{ns}/services/{name}") + } + pub fn secret(ns: &str, name: &str) -> String { + format!("/api/v1/namespaces/{ns}/secrets/{name}") + } + pub fn ingress(ns: &str, name: &str) -> String { + format!("/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses/{name}") + } + pub fn pods(ns: &str) -> String { + format!("/api/v1/namespaces/{ns}/pods") + } + /// Sélecteur `clé=valeur` encodé pour une query string. + pub fn label_selector(key: &str, value: &str) -> String { + let raw = format!("{key}={value}"); + let mut out = String::new(); + for b in raw.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + format!("labelSelector={out}") + } + pub fn pod_log(ns: &str, pod: &str, container: &str, tail: u32) -> String { + format!( + "/api/v1/namespaces/{ns}/pods/{pod}/log?container={container}&tailLines={tail}×tamps=true" + ) + } + + /// Chemin d'un manifest (`kind` + `metadata.name`). + pub fn for_manifest(ns: &str, m: &serde_json::Value) -> Option { + let name = m.pointer("/metadata/name")?.as_str()?; + match m.get("kind")?.as_str()? { + "Deployment" => Some(deployment(ns, name)), + "Service" => Some(service(ns, name)), + "Secret" => Some(secret(ns, name)), + "Ingress" => Some(ingress(ns, name)), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn api_error_uses_status_message() { + let e = api_error( + StatusCode::FORBIDDEN, + r#"{"kind":"Status","message":"deployments.apps is forbidden"}"#, + ); + assert_eq!( + e, + KubeError::Api { + status: 403, + message: "deployments.apps is forbidden".into() + } + ); + assert!(e.to_string().contains("RBAC")); + assert_eq!(e.http_status(), 502); + assert!(api_error(StatusCode::NOT_FOUND, "nope").is_not_found()); + } + + #[test] + fn paths_and_selectors() { + assert_eq!( + paths::label_selector("devforge.io/app", "web"), + "labelSelector=devforge.io%2Fapp%3Dweb" + ); + let m = json!({"kind":"Ingress","metadata":{"name":"web"}}); + assert_eq!( + paths::for_manifest("ns", &m).unwrap(), + "/apis/networking.k8s.io/v1/namespaces/ns/ingresses/web" + ); + assert!( + paths::for_manifest("ns", &json!({"kind":"Pod","metadata":{"name":"x"}})).is_none() + ); + } +} diff --git a/crates/kube/src/config.rs b/crates/kube/src/config.rs new file mode 100644 index 0000000000..9a20e61356 --- /dev/null +++ b/crates/kube/src/config.rs @@ -0,0 +1,173 @@ +//! Configuration de connexion à l'API Kubernetes et valeurs par défaut de rendu. +//! +//! Dans le cluster (cas nominal, chart Helm) : compte de service monté dans +//! `/var/run/secrets/kubernetes.io/serviceaccount/` + `KUBERNETES_SERVICE_HOST/PORT`. +//! +//! Variables (toutes optionnelles) : +//! - `DEVFORGE_KUBE_NAMESPACE` : namespace des apps (défaut : namespace du pod, sinon `devforge-apps`) +//! - `DEVFORGE_KUBE_API_URL`, `DEVFORGE_KUBE_TOKEN_FILE`, `DEVFORGE_KUBE_CA_FILE` : hors cluster +//! - `DEVFORGE_KUBE_INSECURE_SKIP_TLS_VERIFY=1` : dev uniquement +//! - `DEVFORGE_KUBE_INGRESS_CLASS`, `DEVFORGE_KUBE_IMAGE_PULL_SECRET`, +//! `DEVFORGE_KUBE_CLUSTER_ISSUER` : valeurs par défaut des apps + +use std::path::PathBuf; + +const SA_DIR: &str = "/var/run/secrets/kubernetes.io/serviceaccount"; +pub const DEFAULT_NAMESPACE: &str = "devforge-apps"; + +/// Valeurs appliquées quand la spec de l'app ne les précise pas. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct KubeDefaults { + pub namespace: String, + pub ingress_class: Option, + pub image_pull_secret: Option, + pub cluster_issuer: Option, +} + +impl KubeDefaults { + pub fn new(namespace: impl Into) -> Self { + Self { + namespace: namespace.into(), + ..Default::default() + } + } + + pub fn from_env() -> Self { + Self { + namespace: env_opt("DEVFORGE_KUBE_NAMESPACE") + .or_else(|| read_trimmed(&PathBuf::from(SA_DIR).join("namespace"))) + .unwrap_or_else(|| DEFAULT_NAMESPACE.into()), + ingress_class: env_opt("DEVFORGE_KUBE_INGRESS_CLASS"), + image_pull_secret: env_opt("DEVFORGE_KUBE_IMAGE_PULL_SECRET"), + cluster_issuer: env_opt("DEVFORGE_KUBE_CLUSTER_ISSUER"), + } + } +} + +/// Connexion à l'API server. Le jeton est relu à chaque requête (jetons projetés tournants) +/// et n'est jamais stocké ni affiché. +#[derive(Clone)] +pub struct KubeConfig { + pub api_url: String, + pub token_file: Option, + pub ca_pem: Option>, + pub insecure_skip_tls_verify: bool, +} + +impl std::fmt::Debug for KubeConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KubeConfig") + .field("api_url", &self.api_url) + .field("token_file", &self.token_file) + .field("ca_pem", &self.ca_pem.as_ref().map(|_| "[set]")) + .field("insecure_skip_tls_verify", &self.insecure_skip_tls_verify) + .finish() + } +} + +impl KubeConfig { + pub fn from_env() -> Result { + let sa = PathBuf::from(SA_DIR); + let api_url = match env_opt("DEVFORGE_KUBE_API_URL") { + Some(u) => u.trim_end_matches('/').to_string(), + None => { + let host = env_opt("KUBERNETES_SERVICE_HOST").ok_or_else(|| { + "API Kubernetes introuvable : ni DEVFORGE_KUBE_API_URL ni KUBERNETES_SERVICE_HOST (DevForge tourne-t-il dans le cluster ?)".to_string() + })?; + let port = env_opt("KUBERNETES_SERVICE_PORT").unwrap_or_else(|| "443".into()); + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host + }; + format!("https://{host}:{port}") + } + }; + let token_file = env_opt("DEVFORGE_KUBE_TOKEN_FILE") + .map(PathBuf::from) + .or_else(|| { + let p = sa.join("token"); + p.is_file().then_some(p) + }); + let ca_file = env_opt("DEVFORGE_KUBE_CA_FILE") + .map(PathBuf::from) + .or_else(|| { + let p = sa.join("ca.crt"); + p.is_file().then_some(p) + }); + let ca_pem = match ca_file { + Some(p) => { + Some(std::fs::read(&p).map_err(|e| format!("CA Kubernetes {}: {e}", p.display()))?) + } + None => None, + }; + let insecure = env_opt("DEVFORGE_KUBE_INSECURE_SKIP_TLS_VERIFY") + .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")); + Ok(Self { + api_url, + token_file, + ca_pem, + insecure_skip_tls_verify: insecure, + }) + } + + /// Jeton courant (relu depuis le fichier), `None` si aucun fichier. + pub fn bearer_token(&self) -> Result, String> { + match &self.token_file { + Some(p) => read_trimmed(p) + .map(Some) + .ok_or_else(|| format!("jeton Kubernetes illisible ({})", p.display())), + None => Ok(None), + } + } +} + +fn env_opt(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn read_trimmed(p: &std::path::Path) -> Option { + std::fs::read_to_string(p) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_never_prints_ca_or_token() { + let cfg = KubeConfig { + api_url: "https://10.0.0.1:443".into(), + token_file: Some("/tmp/t".into()), + ca_pem: Some(b"-----BEGIN CERTIFICATE-----".to_vec()), + insecure_skip_tls_verify: false, + }; + let d = format!("{cfg:?}"); + assert!(!d.contains("BEGIN")); + assert!(d.contains("[set]")); + } + + #[test] + fn token_is_reread_from_file() { + let dir = std::env::temp_dir().join(format!("df-kube-tok-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("token"); + std::fs::write(&p, "abc\n").unwrap(); + let cfg = KubeConfig { + api_url: "https://x".into(), + token_file: Some(p.clone()), + ca_pem: None, + insecure_skip_tls_verify: false, + }; + assert_eq!(cfg.bearer_token().unwrap().as_deref(), Some("abc")); + std::fs::write(&p, "def").unwrap(); + assert_eq!(cfg.bearer_token().unwrap().as_deref(), Some("def")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/kube/src/lib.rs b/crates/kube/src/lib.rs new file mode 100644 index 0000000000..c6ab5c41d8 --- /dev/null +++ b/crates/kube/src/lib.rs @@ -0,0 +1,31 @@ +//! Backend Kubernetes de DevForge — phase 1. +//! +//! Déploie une application à partir d'une **image déjà construite** (registre OCI) : +//! `Deployment` + `Service` + `Ingress` (optionnel) + `Secret` (variables sensibles), +//! sondes de santé, statut, logs (API Pod), stop / start / restart et rolling update. +//! +//! Activé uniquement avec `DEVFORGE_RUNTIME=kubernetes` ; le backend Docker reste le défaut. +//! Pas de build dans le cluster en phase 1 (Kaniko prévu en phase 2). + +pub mod client; +pub mod config; +pub mod manifests; +pub mod runtime; +pub mod spec; +pub mod status; + +pub use client::{KubeClient, KubeError}; +pub use config::{KubeConfig, KubeDefaults}; +pub use manifests::{render, RenderedManifests}; +pub use runtime::{ImageRuntime, KubeRuntime}; +pub use spec::{AppSpec, HealthSpec, ResourceSpec, TlsSpec}; +pub use status::{AppStatus, PodStatus}; + +/// Gestionnaire de champs (server-side apply) et valeur du label `managed-by`. +pub const FIELD_MANAGER: &str = "devforge"; +/// Label commun à toutes les ressources posées par DevForge. +pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by"; +/// Label sélecteur d'une app (stable, utilisé par `Service` et la recherche des pods). +pub const LABEL_APP: &str = "devforge.io/app"; +/// Nom du conteneur principal dans le pod. +pub const CONTAINER_NAME: &str = "app"; diff --git a/crates/kube/src/manifests.rs b/crates/kube/src/manifests.rs new file mode 100644 index 0000000000..3676f0500f --- /dev/null +++ b/crates/kube/src/manifests.rs @@ -0,0 +1,415 @@ +//! Génération pure (sans réseau) des manifests Kubernetes d'une app « image ». + +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::config::KubeDefaults; +use crate::spec::{cpu_quantity, memory_quantity, AppSpec}; +use crate::{CONTAINER_NAME, FIELD_MANAGER, LABEL_APP, LABEL_MANAGED_BY}; + +/// Annotation (template de pod) : empreinte env + secrets → un changement de config +/// déclenche un rolling update même si l'image ne change pas. +pub const ANNOTATION_CONFIG_HASH: &str = "devforge.io/config-hash"; +/// Annotation (Deployment) : réplicas à restaurer après un `stop`. +pub const ANNOTATION_REPLICAS: &str = "devforge.io/replicas"; +pub const ANNOTATION_RESTARTED_AT: &str = "kubectl.kubernetes.io/restartedAt"; +pub const LABEL_PROJECT: &str = "devforge.io/project"; + +/// Manifests d'une app. `ingress` / `secret` sont `None` quand non requis +/// (ils sont alors supprimés du cluster au déploiement). +#[derive(Debug, Clone, PartialEq)] +pub struct RenderedManifests { + pub namespace: String, + pub deployment: Value, + pub service: Value, + pub ingress: Option, + pub secret: Option, +} + +impl RenderedManifests { + /// Liste ordonnée (Secret d'abord : le Deployment le référence). + pub fn all(&self) -> Vec<&Value> { + let mut v = Vec::new(); + if let Some(s) = &self.secret { + v.push(s); + } + v.push(&self.deployment); + v.push(&self.service); + if let Some(i) = &self.ingress { + v.push(i); + } + v + } + + /// Version affichable : valeurs du Secret masquées. + pub fn redacted(&self) -> Value { + let items: Vec = self + .all() + .into_iter() + .map(|m| { + let mut m = m.clone(); + if m.get("kind").and_then(Value::as_str) == Some("Secret") { + if let Some(data) = m.get_mut("stringData").and_then(Value::as_object_mut) { + for v in data.values_mut() { + *v = Value::String("***".into()); + } + } + } + m + }) + .collect(); + json!({ "apiVersion": "v1", "kind": "List", "items": items }) + } +} + +/// Labels communs (et sélecteur via `devforge.io/app`). +pub fn labels(spec: &AppSpec) -> Map { + let mut m = Map::new(); + m.insert("app.kubernetes.io/name".into(), json!(spec.name)); + m.insert("app.kubernetes.io/instance".into(), json!(spec.name)); + m.insert(LABEL_MANAGED_BY.into(), json!(FIELD_MANAGER)); + m.insert(LABEL_APP.into(), json!(spec.name)); + if let Some(p) = &spec.project_uuid { + m.insert(LABEL_PROJECT.into(), json!(p)); + } + m +} + +fn selector(spec: &AppSpec) -> Value { + json!({ LABEL_APP: spec.name }) +} + +/// Empreinte courte et stable de la configuration (env + secrets), sans révéler les valeurs. +pub fn config_hash(spec: &AppSpec) -> String { + let mut h = Sha256::new(); + for (k, v) in &spec.env { + h.update(b"e\0"); + h.update(k.as_bytes()); + h.update(b"\0"); + h.update(v.as_bytes()); + h.update(b"\0"); + } + for (k, v) in &spec.secrets { + h.update(b"s\0"); + h.update(k.as_bytes()); + h.update(b"\0"); + h.update(v.as_bytes()); + h.update(b"\0"); + } + h.finalize() + .iter() + .take(8) + .map(|b| format!("{b:02x}")) + .collect() +} + +/// `latest` ou sans tag (ni digest) → `Always`, sinon `IfNotPresent`. +pub fn pull_policy(image: &str) -> &'static str { + if image.contains('@') { + return "IfNotPresent"; + } + let last = image.rsplit('/').next().unwrap_or(image); + match last.split_once(':') { + Some((_, tag)) if tag != "latest" => "IfNotPresent", + _ => "Always", + } +} + +fn probes(spec: &AppSpec) -> (Value, Value) { + let h = spec.health.clone().unwrap_or_default(); + let action = match &h.path { + Some(path) => json!({ "httpGet": { "path": path, "port": "http" } }), + None => json!({ "tcpSocket": { "port": "http" } }), + }; + let period = h.period_seconds.unwrap_or(10).max(1); + let failure = h.failure_threshold.unwrap_or(3).max(1); + let mut readiness = action.clone(); + let mut liveness = action; + let r = readiness.as_object_mut().expect("probe object"); + r.insert( + "initialDelaySeconds".into(), + json!(h.initial_delay_seconds.unwrap_or(5)), + ); + r.insert("periodSeconds".into(), json!(period)); + r.insert("failureThreshold".into(), json!(failure)); + r.insert("timeoutSeconds".into(), json!(5)); + let l = liveness.as_object_mut().expect("probe object"); + // La liveness démarre plus tard : une app lente ne doit pas boucler en redémarrage. + l.insert( + "initialDelaySeconds".into(), + json!(h.initial_delay_seconds.unwrap_or(5).max(30)), + ); + l.insert("periodSeconds".into(), json!(period.max(20))); + l.insert("failureThreshold".into(), json!(failure.max(3))); + l.insert("timeoutSeconds".into(), json!(5)); + (readiness, liveness) +} + +fn resources(spec: &AppSpec) -> Option { + let res = spec.resources.as_ref()?; + let mut limits = Map::new(); + let mut requests = Map::new(); + if let Some(m) = res.memory.as_deref().and_then(|m| memory_quantity(m).ok()) { + limits.insert("memory".into(), json!(m)); + requests.insert("memory".into(), json!(m)); + } + if let Some(c) = res.cpus.as_deref().and_then(|c| cpu_quantity(c).ok()) { + limits.insert("cpu".into(), json!(c)); + } + if limits.is_empty() { + return None; + } + Some(json!({ "limits": limits, "requests": requests })) +} + +pub fn deployment(spec: &AppSpec, d: &KubeDefaults) -> Value { + let lbl = labels(spec); + let env: Vec = spec + .env + .iter() + .map(|(k, v)| json!({ "name": k, "value": v })) + .collect(); + let (readiness, liveness) = probes(spec); + let mut container = json!({ + "name": CONTAINER_NAME, + "image": spec.image, + "imagePullPolicy": pull_policy(&spec.image), + "ports": [{ "name": "http", "containerPort": spec.port, "protocol": "TCP" }], + "readinessProbe": readiness, + "livenessProbe": liveness, + "securityContext": { + "allowPrivilegeEscalation": false, + "capabilities": { "drop": ["ALL"] } + } + }); + let c = container.as_object_mut().expect("container object"); + if !env.is_empty() { + c.insert("env".into(), Value::Array(env)); + } + if !spec.secrets.is_empty() { + c.insert( + "envFrom".into(), + json!([{ "secretRef": { "name": spec.secret_name() } }]), + ); + } + if let Some(r) = resources(spec) { + c.insert("resources".into(), r); + } + + let mut pod_sc = Map::new(); + pod_sc.insert("seccompProfile".into(), json!({ "type": "RuntimeDefault" })); + if spec.run_as_non_root { + pod_sc.insert("runAsNonRoot".into(), json!(true)); + } + if let Some(uid) = spec.run_as_user { + pod_sc.insert("runAsUser".into(), json!(uid)); + } + + let mut pod_spec = json!({ + "automountServiceAccountToken": false, + "enableServiceLinks": false, + "securityContext": pod_sc, + "containers": [container], + }); + if let Some(pull) = spec + .image_pull_secret + .clone() + .or_else(|| d.image_pull_secret.clone()) + { + pod_spec + .as_object_mut() + .expect("pod spec object") + .insert("imagePullSecrets".into(), json!([{ "name": pull }])); + } + + json!({ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": spec.name, + "namespace": d.namespace, + "labels": lbl, + "annotations": { ANNOTATION_REPLICAS: spec.replicas.max(1).to_string() } + }, + "spec": { + "replicas": spec.replicas, + "revisionHistoryLimit": 5, + "progressDeadlineSeconds": 600, + "selector": { "matchLabels": selector(spec) }, + "strategy": { + "type": "RollingUpdate", + "rollingUpdate": { "maxSurge": 1, "maxUnavailable": 0 } + }, + "template": { + "metadata": { + "labels": lbl, + "annotations": { ANNOTATION_CONFIG_HASH: config_hash(spec) } + }, + "spec": pod_spec + } + } + }) +} + +pub fn service(spec: &AppSpec, d: &KubeDefaults) -> Value { + json!({ + "apiVersion": "v1", + "kind": "Service", + "metadata": { "name": spec.name, "namespace": d.namespace, "labels": labels(spec) }, + "spec": { + "type": "ClusterIP", + "selector": selector(spec), + "ports": [{ "name": "http", "port": 80, "targetPort": "http", "protocol": "TCP" }] + } + }) +} + +pub fn ingress(spec: &AppSpec, d: &KubeDefaults) -> Option { + let host = spec.host.as_ref()?; + let mut annotations = Map::new(); + let issuer = spec + .tls + .as_ref() + .and_then(|t| t.cluster_issuer.clone()) + .or_else(|| spec.tls.as_ref().and(d.cluster_issuer.clone())); + if let Some(issuer) = issuer { + annotations.insert("cert-manager.io/cluster-issuer".into(), json!(issuer)); + } + let mut ing_spec = json!({ + "rules": [{ + "host": host, + "http": { "paths": [{ + "path": "/", + "pathType": "Prefix", + "backend": { "service": { "name": spec.name, "port": { "number": 80 } } } + }]} + }] + }); + let s = ing_spec.as_object_mut().expect("ingress spec object"); + if let Some(class) = spec + .ingress_class + .clone() + .or_else(|| d.ingress_class.clone()) + { + s.insert("ingressClassName".into(), json!(class)); + } + if let Some(secret) = spec.tls_secret_name() { + s.insert( + "tls".into(), + json!([{ "hosts": [host], "secretName": secret }]), + ); + } + let mut meta = json!({ "name": spec.name, "namespace": d.namespace, "labels": labels(spec) }); + if !annotations.is_empty() { + meta.as_object_mut() + .expect("metadata object") + .insert("annotations".into(), Value::Object(annotations)); + } + Some(json!({ + "apiVersion": "networking.k8s.io/v1", + "kind": "Ingress", + "metadata": meta, + "spec": ing_spec + })) +} + +pub fn secret(spec: &AppSpec, d: &KubeDefaults) -> Option { + if spec.secrets.is_empty() { + return None; + } + Some(json!({ + "apiVersion": "v1", + "kind": "Secret", + "type": "Opaque", + "metadata": { "name": spec.secret_name(), "namespace": d.namespace, "labels": labels(spec) }, + "stringData": spec.secrets + })) +} + +/// Rend tous les manifests (la spec doit avoir été validée). +pub fn render(spec: &AppSpec, d: &KubeDefaults) -> RenderedManifests { + RenderedManifests { + namespace: d.namespace.clone(), + deployment: deployment(spec, d), + service: service(spec, d), + ingress: ingress(spec, d), + secret: secret(spec, d), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pull_policy_rules() { + assert_eq!(pull_policy("nginx"), "Always"); + assert_eq!(pull_policy("nginx:latest"), "Always"); + assert_eq!(pull_policy("ghcr.io/o/a:1.2"), "IfNotPresent"); + assert_eq!(pull_policy("localhost:5000/a"), "Always"); + assert_eq!(pull_policy("localhost:5000/a:v1"), "IfNotPresent"); + assert_eq!(pull_policy("a@sha256:abc"), "IfNotPresent"); + } + + #[test] + fn config_hash_changes_with_secret_values() { + let mut a = AppSpec::new("a", "i:1"); + let h0 = config_hash(&a); + a.secrets.insert("K".into(), "v1".into()); + let h1 = config_hash(&a); + a.secrets.insert("K".into(), "v2".into()); + let h2 = config_hash(&a); + assert_ne!(h0, h1); + assert_ne!(h1, h2); + assert_eq!(h2.len(), 16); + assert!(!h2.contains("v2")); + } + + #[test] + fn no_host_means_no_ingress_and_no_secret() { + let r = render(&AppSpec::new("a", "i:1"), &KubeDefaults::new("ns")); + assert!(r.ingress.is_none()); + assert!(r.secret.is_none()); + assert_eq!(r.all().len(), 2); + } + + #[test] + fn redacted_hides_secret_values() { + let mut s = AppSpec::new("a", "i:1"); + s.secrets.insert("DB_PASSWORD".into(), "hunter2".into()); + let r = render(&s, &KubeDefaults::new("ns")); + let txt = r.redacted().to_string(); + assert!(!txt.contains("hunter2")); + assert!(txt.contains("DB_PASSWORD")); + // Le manifest réel, lui, contient bien la valeur (envoyée à l'API uniquement). + assert!(r.secret.unwrap().to_string().contains("hunter2")); + } + + #[test] + fn defaults_apply_only_when_spec_is_silent() { + let mut d = KubeDefaults::new("ns"); + d.ingress_class = Some("traefik".into()); + d.image_pull_secret = Some("ghcr-pull".into()); + d.cluster_issuer = Some("letsencrypt".into()); + let mut s = AppSpec::new("a", "i:1"); + s.host = Some("a.example.com".into()); + let r = render(&s, &d); + let ing = r.ingress.unwrap(); + assert_eq!(ing["spec"]["ingressClassName"], "traefik"); + // Pas de TLS demandé → pas d'annotation cert-manager. + assert!(ing["metadata"].get("annotations").is_none()); + assert_eq!( + r.deployment["spec"]["template"]["spec"]["imagePullSecrets"][0]["name"], + "ghcr-pull" + ); + s.ingress_class = Some("nginx".into()); + s.tls = Some(Default::default()); + let ing = render(&s, &d).ingress.unwrap(); + assert_eq!(ing["spec"]["ingressClassName"], "nginx"); + assert_eq!( + ing["metadata"]["annotations"]["cert-manager.io/cluster-issuer"], + "letsencrypt" + ); + assert_eq!(ing["spec"]["tls"][0]["secretName"], "a-tls"); + } +} diff --git a/crates/kube/src/runtime.rs b/crates/kube/src/runtime.rs new file mode 100644 index 0000000000..dba7ac5d96 --- /dev/null +++ b/crates/kube/src/runtime.rs @@ -0,0 +1,382 @@ +//! Abstraction runtime minimale pour les apps « image » et implémentation Kubernetes. + +use std::time::Duration; + +use async_trait::async_trait; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::client::{paths, KubeClient, KubeError}; +use crate::config::KubeDefaults; +use crate::manifests::{self, ANNOTATION_REPLICAS, ANNOTATION_RESTARTED_AT}; +use crate::spec::AppSpec; +use crate::status::{self, AppStatus}; +use crate::{CONTAINER_NAME, FIELD_MANAGER, LABEL_APP, LABEL_MANAGED_BY}; + +pub const MAX_LOG_TAIL: u32 = 2000; +const MAX_LOG_PODS: usize = 3; +const MAX_LOG_BYTES: usize = 512 * 1024; + +/// Résultat d'un déploiement. +#[derive(Debug, Clone, Serialize)] +pub struct DeployOutcome { + pub name: String, + pub namespace: String, + /// Ressources appliquées (`Kind/nom`). + pub applied: Vec, + /// Ressources supprimées car plus requises (Ingress sans host, Secret vide). + pub pruned: Vec, + pub status: AppStatus, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AppLogs { + pub name: String, + pub pods: Vec, + pub logs: String, + pub truncated: bool, +} + +/// Opérations minimales d'un runtime capable de faire tourner une image déjà construite. +/// +/// Phase 1 : seule l'implémentation Kubernetes existe ; le chemin Docker historique +/// (build + `docker run`) reste dans `devforge-deploy` et n'est pas modifié. +#[async_trait] +pub trait ImageRuntime: Send + Sync { + fn backend(&self) -> &'static str; + async fn deploy(&self, spec: &AppSpec) -> Result; + async fn status(&self, name: &str) -> Result; + async fn list(&self) -> Result, KubeError>; + async fn logs(&self, name: &str, tail: u32) -> Result; + async fn stop(&self, name: &str) -> Result; + async fn start(&self, name: &str) -> Result; + async fn restart(&self, name: &str) -> Result; + async fn remove(&self, name: &str) -> Result, KubeError>; +} + +pub struct KubeRuntime { + client: KubeClient, + defaults: KubeDefaults, +} + +impl KubeRuntime { + pub fn new(client: KubeClient, defaults: KubeDefaults) -> Self { + Self { client, defaults } + } + + pub fn from_env() -> Result { + Ok(Self::new(KubeClient::from_env()?, KubeDefaults::from_env())) + } + + pub fn namespace(&self) -> &str { + &self.defaults.namespace + } + + pub fn defaults(&self) -> &KubeDefaults { + &self.defaults + } + + pub fn client(&self) -> &KubeClient { + &self.client + } + + fn ns(&self) -> &str { + &self.defaults.namespace + } + + fn check_name(name: &str) -> Result<(), KubeError> { + if crate::spec::is_dns_label(name) && name.len() <= crate::spec::MAX_NAME_LEN { + Ok(()) + } else { + Err(KubeError::Invalid(format!("nom d'app invalide ({name})"))) + } + } + + /// Valide la spec puis rend les manifests (aucun appel réseau). + pub fn render(&self, spec: &AppSpec) -> Result { + let mut spec = spec.clone(); + spec.normalize().map_err(KubeError::Invalid)?; + Ok(manifests::render(&spec, &self.defaults)) + } + + async fn managed_deployment(&self, name: &str) -> Result, KubeError> { + let dep = self.client.get(&paths::deployment(self.ns(), name)).await?; + match dep { + Some(d) if is_managed(&d) => Ok(Some(d)), + Some(_) => Err(KubeError::Invalid(format!( + "le Deployment {name} existe mais n'est pas géré par DevForge (label {LABEL_MANAGED_BY})" + ))), + None => Ok(None), + } + } + + async fn pods(&self, name: &str) -> Result, KubeError> { + let path = format!( + "{}?{}", + paths::pods(self.ns()), + paths::label_selector(LABEL_APP, name) + ); + let list = self.client.get(&path).await?.unwrap_or(Value::Null); + Ok(status::pods_from_list(&list)) + } + + /// Attend la fin du rollout (comme `kubectl rollout status`), au plus `timeout`. + pub async fn wait_rollout( + &self, + name: &str, + timeout: Duration, + ) -> Result { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let st = self.status(name).await?; + if st.rollout_complete || st.phase == "degraded" || st.phase == "absent" { + return Ok(st); + } + if tokio::time::Instant::now() >= deadline { + return Ok(st); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + + async fn patch_deployment(&self, name: &str, patch: Value) -> Result { + if self.managed_deployment(name).await?.is_none() { + return Err(KubeError::Api { + status: 404, + message: format!("app {name} introuvable"), + }); + } + self.client + .merge_patch(&paths::deployment(self.ns(), name), &patch) + .await?; + self.status(name).await + } +} + +fn is_managed(obj: &Value) -> bool { + obj.pointer("/metadata/labels") + .and_then(|l| l.get(LABEL_MANAGED_BY)) + .and_then(Value::as_str) + == Some(FIELD_MANAGER) +} + +fn kind_name(m: &Value) -> String { + format!( + "{}/{}", + m.get("kind").and_then(Value::as_str).unwrap_or("?"), + m.pointer("/metadata/name") + .and_then(Value::as_str) + .unwrap_or("?") + ) +} + +#[async_trait] +impl ImageRuntime for KubeRuntime { + fn backend(&self) -> &'static str { + "kubernetes" + } + + async fn deploy(&self, spec: &AppSpec) -> Result { + let mut spec = spec.clone(); + spec.normalize().map_err(KubeError::Invalid)?; + // Refuse d'écraser une ressource posée à la main / par un autre outil. + let _ = self.managed_deployment(&spec.name).await?; + let rendered = manifests::render(&spec, &self.defaults); + let ns = self.ns().to_string(); + let mut applied = Vec::new(); + for m in rendered.all() { + let path = paths::for_manifest(&ns, m) + .ok_or_else(|| KubeError::Invalid(format!("kind non géré : {}", kind_name(m))))?; + self.client.apply(&path, m).await?; + applied.push(kind_name(m)); + } + let mut pruned = Vec::new(); + if rendered.ingress.is_none() { + let p = paths::ingress(&ns, &spec.name); + if let Some(obj) = self.client.get(&p).await? { + if is_managed(&obj) && self.client.delete(&p).await? { + pruned.push(format!("Ingress/{}", spec.name)); + } + } + } + if rendered.secret.is_none() { + let p = paths::secret(&ns, &spec.secret_name()); + if let Some(obj) = self.client.get(&p).await? { + if is_managed(&obj) && self.client.delete(&p).await? { + pruned.push(format!("Secret/{}", spec.secret_name())); + } + } + } + tracing::info!(app = %spec.name, namespace = %ns, image = %spec.image, "kube deploy appliqué"); + let status = self.status(&spec.name).await?; + Ok(DeployOutcome { + name: spec.name.clone(), + namespace: ns, + applied, + pruned, + status, + }) + } + + async fn status(&self, name: &str) -> Result { + Self::check_name(name)?; + let dep = self.managed_deployment(name).await?; + let pods = if dep.is_some() { + self.pods(name).await? + } else { + Vec::new() + }; + Ok(status::app_status(name, self.ns(), dep.as_ref(), pods)) + } + + async fn list(&self) -> Result, KubeError> { + let path = format!( + "{}?{}", + paths::deployments(self.ns()), + paths::label_selector(LABEL_MANAGED_BY, FIELD_MANAGER) + ); + let list = self.client.get(&path).await?.unwrap_or(Value::Null); + let mut out = Vec::new(); + for dep in list + .get("items") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + { + let name = dep + .pointer("/metadata/name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + // Liste sans pods (un appel par app serait coûteux) ; détail via `status`. + out.push(status::app_status(&name, self.ns(), Some(&dep), Vec::new())); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) + } + + async fn logs(&self, name: &str, tail: u32) -> Result { + Self::check_name(name)?; + if self.managed_deployment(name).await?.is_none() { + return Err(KubeError::Api { + status: 404, + message: format!("app {name} introuvable"), + }); + } + let tail = tail.clamp(1, MAX_LOG_TAIL); + let pods = self.pods(name).await?; + let mut names = Vec::new(); + let mut buf = String::new(); + for pod in pods.iter().take(MAX_LOG_PODS) { + names.push(pod.name.clone()); + let path = paths::pod_log(self.ns(), &pod.name, CONTAINER_NAME, tail); + let text = match self.client.get_text(&path).await { + Ok(t) => t, + // Pod pas encore démarré (ContainerCreating, ImagePullBackOff…). + Err(KubeError::Api { + status: 400, + message, + }) => format!("({message})\n"), + Err(e) => return Err(e), + }; + buf.push_str(&format!("==> {} ({}) <==\n", pod.name, pod.phase)); + buf.push_str(&text); + if !buf.ends_with('\n') { + buf.push('\n'); + } + } + let mut logs = devforge_shared::log_redact::redact_log_text(&buf); + let truncated = logs.len() > MAX_LOG_BYTES; + if truncated { + let mut cut = logs.len() - MAX_LOG_BYTES; + while !logs.is_char_boundary(cut) { + cut += 1; + } + logs = logs[cut..].to_string(); + } + Ok(AppLogs { + name: name.into(), + pods: names, + logs, + truncated, + }) + } + + async fn stop(&self, name: &str) -> Result { + Self::check_name(name)?; + let Some(dep) = self.managed_deployment(name).await? else { + return Err(KubeError::Api { + status: 404, + message: format!("app {name} introuvable"), + }); + }; + let current = dep + .pointer("/spec/replicas") + .and_then(Value::as_i64) + .unwrap_or(1); + let remember = if current > 0 { + current + } else { + status::remembered_replicas(&dep) + }; + self.patch_deployment( + name, + json!({ + "metadata": { "annotations": { ANNOTATION_REPLICAS: remember.to_string() } }, + "spec": { "replicas": 0 } + }), + ) + .await + } + + async fn start(&self, name: &str) -> Result { + Self::check_name(name)?; + let Some(dep) = self.managed_deployment(name).await? else { + return Err(KubeError::Api { + status: 404, + message: format!("app {name} introuvable"), + }); + }; + let replicas = status::remembered_replicas(&dep); + self.patch_deployment(name, json!({ "spec": { "replicas": replicas } })) + .await + } + + async fn restart(&self, name: &str) -> Result { + Self::check_name(name)?; + // Même mécanisme que `kubectl rollout restart` : rolling update sans coupure. + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + self.patch_deployment( + name, + json!({ "spec": { "template": { "metadata": { "annotations": { + ANNOTATION_RESTARTED_AT: now + }}}}}), + ) + .await + } + + async fn remove(&self, name: &str) -> Result, KubeError> { + Self::check_name(name)?; + let ns = self.ns().to_string(); + let mut removed = Vec::new(); + let targets = [ + ("Ingress", paths::ingress(&ns, name)), + ("Service", paths::service(&ns, name)), + ("Deployment", paths::deployment(&ns, name)), + ("Secret", paths::secret(&ns, &format!("{name}-env"))), + ]; + for (kind, path) in targets { + if let Some(obj) = self.client.get(&path).await? { + if is_managed(&obj) && self.client.delete(&path).await? { + removed.push(format!( + "{kind}/{}", + obj.pointer("/metadata/name") + .and_then(Value::as_str) + .unwrap_or(name) + )); + } + } + } + Ok(removed) + } +} diff --git a/crates/kube/src/spec.rs b/crates/kube/src/spec.rs new file mode 100644 index 0000000000..905946d36e --- /dev/null +++ b/crates/kube/src/spec.rs @@ -0,0 +1,441 @@ +//! Description d'une app « image » déployable (entrée de l'API `/api/v1/kube/apps`). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Longueur max du nom d'app : laisse la place aux suffixes (`-env`, `-tls`) sous 63. +pub const MAX_NAME_LEN: usize = 40; +pub const MAX_REPLICAS: i32 = 20; + +fn default_port() -> u16 { + 8080 +} +fn default_replicas() -> i32 { + 1 +} +fn default_true() -> bool { + true +} + +/// TLS de l'Ingress. `secret_name` absent → `-tls`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct TlsSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secret_name: Option, + /// Annotation `cert-manager.io/cluster-issuer` (si cert-manager est installé). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cluster_issuer: Option, +} + +/// Sondes HTTP (`path`) ou TCP (pas de `path`) sur le port de l'app. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct HealthSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_delay_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub period_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure_threshold: Option, +} + +/// Limites (même syntaxe que le runtime Docker : `512m`, `2g`, `0.5`) ou quantités k8s. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ResourceSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpus: Option, +} + +/// App à déployer depuis une image déjà construite. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppSpec { + /// Nom DNS-1123 (`[a-z0-9-]`, ≤ 40) : nom du Deployment / Service / Ingress. + pub name: String, + /// Référence d'image complète, ex. `ghcr.io/owner/app:sha-abc1234`. + pub image: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_replicas")] + pub replicas: i32, + /// Nom d'hôte public → Ingress. Absent → Service interne seulement. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, + /// Variables non sensibles (en clair dans le Deployment). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + /// Variables sensibles → `Secret` `-env`. Jamais renvoyées par l'API. + #[serde(default, skip_serializing)] + pub secrets: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub health: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option, + /// Secret `kubernetes.io/dockerconfigjson` pour un registre privé (GHCR…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_pull_secret: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ingress_class: Option, + /// `securityContext.runAsNonRoot` (compatible PodSecurity `restricted`). + #[serde(default = "default_true")] + pub run_as_non_root: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_as_user: Option, + /// Lien optionnel vers un projet DevForge (label `devforge.io/project`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_uuid: Option, +} + +impl std::fmt::Debug for AppSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppSpec") + .field("name", &self.name) + .field("image", &self.image) + .field("port", &self.port) + .field("replicas", &self.replicas) + .field("host", &self.host) + .field("tls", &self.tls) + .field("env", &self.env) + .field("secrets", &self.secrets.keys().collect::>()) + .field("health", &self.health) + .field("resources", &self.resources) + .field("image_pull_secret", &self.image_pull_secret) + .field("ingress_class", &self.ingress_class) + .field("run_as_non_root", &self.run_as_non_root) + .field("run_as_user", &self.run_as_user) + .field("project_uuid", &self.project_uuid) + .finish() + } +} + +impl AppSpec { + /// Spec minimale (tests, exemples). + pub fn new(name: impl Into, image: impl Into) -> Self { + Self { + name: name.into(), + image: image.into(), + port: default_port(), + replicas: default_replicas(), + host: None, + tls: None, + env: BTreeMap::new(), + secrets: BTreeMap::new(), + health: None, + resources: None, + image_pull_secret: None, + ingress_class: None, + run_as_non_root: true, + run_as_user: None, + project_uuid: None, + } + } + + pub fn secret_name(&self) -> String { + format!("{}-env", self.name) + } + + pub fn tls_secret_name(&self) -> Option { + self.tls.as_ref().map(|t| { + t.secret_name + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| format!("{}-tls", self.name)) + }) + } + + /// Normalise (trim, vides → None) puis valide. Erreurs en français. + pub fn normalize(&mut self) -> Result<(), String> { + self.name = self.name.trim().to_string(); + self.image = self.image.trim().to_string(); + trim_opt(&mut self.host); + trim_opt(&mut self.image_pull_secret); + trim_opt(&mut self.ingress_class); + trim_opt(&mut self.project_uuid); + if let Some(h) = self.host.as_mut() { + *h = h.to_ascii_lowercase(); + } + if let Some(tls) = self.tls.as_mut() { + trim_opt(&mut tls.secret_name); + trim_opt(&mut tls.cluster_issuer); + } + if let Some(health) = self.health.as_mut() { + trim_opt(&mut health.path); + } + if let Some(res) = self.resources.as_mut() { + trim_opt(&mut res.memory); + trim_opt(&mut res.cpus); + } + self.validate() + } + + pub fn validate(&self) -> Result<(), String> { + if !is_dns_label(&self.name) || self.name.len() > MAX_NAME_LEN { + return Err(format!( + "nom d'app invalide ({}) — minuscules, chiffres et '-', {MAX_NAME_LEN} caractères max", + self.name + )); + } + validate_image(&self.image)?; + if self.port == 0 { + return Err("port invalide (1-65535)".into()); + } + if !(0..=MAX_REPLICAS).contains(&self.replicas) { + return Err(format!("replicas invalide (0-{MAX_REPLICAS})")); + } + if let Some(host) = &self.host { + if !is_hostname(host) { + return Err(format!("nom d'hôte invalide ({host})")); + } + } + if self.tls.is_some() && self.host.is_none() { + return Err("TLS demandé sans nom d'hôte (host)".into()); + } + if let Some(tls) = &self.tls { + if let Some(s) = &tls.secret_name { + if !is_dns_subdomain(s) { + return Err(format!("secret TLS invalide ({s})")); + } + } + } + for key in self.env.keys().chain(self.secrets.keys()) { + if !is_env_key(key) { + return Err(format!("nom de variable invalide ({key})")); + } + } + if let Some(dup) = self.env.keys().find(|k| self.secrets.contains_key(*k)) { + return Err(format!("variable {dup} à la fois dans env et secrets")); + } + if let Some(p) = &self.image_pull_secret { + if !is_dns_subdomain(p) { + return Err(format!("image_pull_secret invalide ({p})")); + } + } + if let Some(c) = &self.ingress_class { + if !is_dns_subdomain(c) { + return Err(format!("ingress_class invalide ({c})")); + } + } + if let Some(health) = &self.health { + if let Some(path) = &health.path { + if !path.starts_with('/') || path.chars().any(|c| c.is_whitespace()) { + return Err(format!("chemin de santé invalide ({path}) — ex. /health")); + } + } + for v in [ + health.initial_delay_seconds, + health.period_seconds, + health.failure_threshold, + ] + .into_iter() + .flatten() + { + if !(0..=3600).contains(&v) { + return Err("sonde : valeurs entre 0 et 3600".into()); + } + } + } + if let Some(res) = &self.resources { + if let Some(m) = &res.memory { + memory_quantity(m)?; + } + if let Some(c) = &res.cpus { + cpu_quantity(c)?; + } + } + if let Some(uid) = self.run_as_user { + if self.run_as_non_root && uid == 0 { + return Err("run_as_user=0 incompatible avec run_as_non_root".into()); + } + if uid < 0 { + return Err("run_as_user invalide".into()); + } + } + if let Some(p) = &self.project_uuid { + if p.len() > 63 + || !p + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + { + return Err("project_uuid invalide".into()); + } + } + Ok(()) + } +} + +fn trim_opt(v: &mut Option) { + if let Some(s) = v.as_mut() { + *s = s.trim().to_string(); + if s.is_empty() { + *v = None; + } + } +} + +pub fn is_dns_label(s: &str) -> bool { + !s.is_empty() + && s.len() <= 63 + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !s.starts_with('-') + && !s.ends_with('-') +} + +pub fn is_dns_subdomain(s: &str) -> bool { + !s.is_empty() && s.len() <= 253 && s.split('.').all(is_dns_label) +} + +fn is_hostname(s: &str) -> bool { + let s = s.strip_prefix("*.").unwrap_or(s); + s.contains('.') && is_dns_subdomain(s) +} + +fn is_env_key(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +fn validate_image(image: &str) -> Result<(), String> { + if image.is_empty() { + return Err("image manquante — ex. ghcr.io/owner/app:1.0.0".into()); + } + if image.len() > 512 + || image + .chars() + .any(|c| c.is_whitespace() || c.is_control() || c == '\'' || c == '"') + { + return Err(format!("image invalide ({image})")); + } + Ok(()) +} + +/// `512m` / `2g` (syntaxe Docker, base 1024) → `512Mi` / `2Gi` ; `256Mi` / `1G` inchangés. +/// +/// Attention : en k8s `512m` = 0,512 octet. On convertit donc toujours le suffixe Docker. +pub fn memory_quantity(raw: &str) -> Result { + let s = raw.trim(); + let err = || format!("mémoire invalide ({raw}) — ex. 512m, 2g, 256Mi"); + let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + let (num, unit) = s.split_at(digits_end); + if num.is_empty() || num.parse::().ok().filter(|n| *n > 0).is_none() { + return Err(err()); + } + let unit = match unit { + "b" | "B" | "" => "", + "k" | "K" | "Ki" => "Ki", + "m" | "M" | "Mi" => "Mi", + "g" | "G" | "Gi" => "Gi", + _ => return Err(err()), + }; + Ok(format!("{num}{unit}")) +} + +/// `0.5` / `2` / `500m` → quantité CPU k8s. +pub fn cpu_quantity(raw: &str) -> Result { + let s = raw.trim(); + let err = || format!("cpus invalide ({raw}) — ex. 0.5, 2 ou 500m"); + if let Some(milli) = s.strip_suffix('m') { + return milli + .parse::() + .ok() + .filter(|n| *n > 0) + .map(|_| s.to_string()) + .ok_or_else(err); + } + match s.parse::() { + Ok(v) if v > 0.0 && v <= 256.0 && !s.starts_with('.') && !s.ends_with('.') => { + Ok(s.to_string()) + } + _ => Err(err()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_trims_and_validates() { + let mut s = AppSpec::new(" web ", " ghcr.io/o/web:1 "); + s.host = Some(" App.Example.COM ".into()); + s.ingress_class = Some(" ".into()); + s.normalize().unwrap(); + assert_eq!(s.name, "web"); + assert_eq!(s.image, "ghcr.io/o/web:1"); + assert_eq!(s.host.as_deref(), Some("app.example.com")); + assert_eq!(s.ingress_class, None); + } + + #[test] + fn rejects_bad_names_and_images() { + for bad in ["", "Web", "-a", "a_b", &"x".repeat(41)] { + let s = AppSpec::new(bad, "img:1"); + assert!(s.validate().is_err(), "{bad}"); + } + assert!(AppSpec::new("a", "").validate().is_err()); + assert!(AppSpec::new("a", "img 1").validate().is_err()); + assert!(AppSpec::new("a", "img'1").validate().is_err()); + } + + #[test] + fn rejects_tls_without_host_and_dup_env() { + let mut s = AppSpec::new("a", "img:1"); + s.tls = Some(TlsSpec::default()); + assert!(s.validate().is_err()); + let mut s = AppSpec::new("a", "img:1"); + s.env.insert("TOKEN".into(), "x".into()); + s.secrets.insert("TOKEN".into(), "y".into()); + assert!(s.validate().unwrap_err().contains("TOKEN")); + let mut s = AppSpec::new("a", "img:1"); + s.env.insert("1BAD".into(), "x".into()); + assert!(s.validate().is_err()); + } + + #[test] + fn memory_conversion_never_emits_millibytes() { + assert_eq!(memory_quantity("512m").unwrap(), "512Mi"); + assert_eq!(memory_quantity("2g").unwrap(), "2Gi"); + assert_eq!(memory_quantity("256Mi").unwrap(), "256Mi"); + assert_eq!(memory_quantity("1048576").unwrap(), "1048576"); + assert!(memory_quantity("0m").is_err()); + assert!(memory_quantity("abc").is_err()); + assert!(memory_quantity("1.5g").is_err()); + } + + #[test] + fn cpu_quantities() { + assert_eq!(cpu_quantity("0.5").unwrap(), "0.5"); + assert_eq!(cpu_quantity("500m").unwrap(), "500m"); + assert!(cpu_quantity("0").is_err()); + assert!(cpu_quantity("x").is_err()); + assert!(cpu_quantity(".5").is_err()); + } + + #[test] + fn secrets_are_never_serialized_or_debug_printed() { + let mut s = AppSpec::new("a", "img:1"); + s.secrets + .insert("API_KEY".into(), "super-secret-value".into()); + let json = serde_json::to_string(&s).unwrap(); + assert!(!json.contains("super-secret-value")); + assert!(!format!("{s:?}").contains("super-secret-value")); + assert!(format!("{s:?}").contains("API_KEY")); + } + + #[test] + fn deserialize_defaults() { + let s: AppSpec = serde_json::from_str(r#"{"name":"a","image":"i:1"}"#).unwrap(); + assert_eq!(s.port, 8080); + assert_eq!(s.replicas, 1); + assert!(s.run_as_non_root); + } +} diff --git a/crates/kube/src/status.rs b/crates/kube/src/status.rs new file mode 100644 index 0000000000..0c2a7503f2 --- /dev/null +++ b/crates/kube/src/status.rs @@ -0,0 +1,293 @@ +//! Statut d'une app calculé à partir du Deployment et de ses pods (fonctions pures). + +use serde::Serialize; +use serde_json::Value; + +use crate::manifests::ANNOTATION_REPLICAS; +use crate::CONTAINER_NAME; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PodStatus { + pub name: String, + pub phase: String, + pub ready: bool, + pub restarts: i64, + /// Raison d'attente / d'arrêt du conteneur (`ImagePullBackOff`, `CrashLoopBackOff`…). + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AppStatus { + pub name: String, + pub namespace: String, + /// `absent` | `stopped` | `progressing` | `running` | `degraded` + pub phase: String, + pub healthy: bool, + pub rollout_complete: bool, + pub image: Option, + pub replicas: i64, + pub ready_replicas: i64, + pub updated_replicas: i64, + pub available_replicas: i64, + pub message: String, + pub pods: Vec, +} + +fn i(v: &Value, path: &[&str]) -> i64 { + let mut cur = v; + for p in path { + match cur.get(p) { + Some(n) => cur = n, + None => return 0, + } + } + cur.as_i64().unwrap_or(0) +} + +fn s(v: &Value, path: &[&str]) -> Option { + let mut cur = v; + for p in path { + cur = cur.get(p)?; + } + cur.as_str().map(str::to_string) +} + +/// Pods d'une `PodList`, du plus récent au plus ancien. +pub fn pods_from_list(list: &Value) -> Vec { + let mut pods: Vec = list + .get("items") + .and_then(Value::as_array) + .map(|items| items.iter().map(pod_status).collect()) + .unwrap_or_default(); + pods.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.name.cmp(&b.name))); + pods +} + +fn pod_status(pod: &Value) -> PodStatus { + let cs = pod + .pointer("/status/containerStatuses") + .and_then(Value::as_array) + .and_then(|a| { + a.iter() + .find(|c| c.get("name").and_then(Value::as_str) == Some(CONTAINER_NAME)) + .or_else(|| a.first()) + }); + let reason = cs.and_then(|c| { + s(c, &["state", "waiting", "reason"]) + .or_else(|| s(c, &["state", "terminated", "reason"])) + .or_else(|| s(c, &["lastState", "terminated", "reason"])) + }); + let reason = reason.or_else(|| { + pod.pointer("/status/conditions") + .and_then(Value::as_array) + .and_then(|a| { + a.iter().find(|c| { + c.get("type").and_then(Value::as_str) == Some("PodScheduled") + && c.get("status").and_then(Value::as_str) == Some("False") + }) + }) + .and_then(|c| s(c, &["reason"])) + }); + PodStatus { + name: s(pod, &["metadata", "name"]).unwrap_or_default(), + phase: s(pod, &["status", "phase"]).unwrap_or_else(|| "Unknown".into()), + ready: cs + .and_then(|c| c.get("ready")) + .and_then(Value::as_bool) + .unwrap_or(false), + restarts: cs.map(|c| i(c, &["restartCount"])).unwrap_or(0), + reason, + image: cs.and_then(|c| s(c, &["image"])), + created_at: s(pod, &["metadata", "creationTimestamp"]), + } +} + +/// Réplicas à restaurer après un stop (annotation, défaut 1). +pub fn remembered_replicas(deployment: &Value) -> i64 { + deployment + .pointer("/metadata/annotations") + .and_then(|a| a.get(ANNOTATION_REPLICAS)) + .and_then(Value::as_str) + .and_then(|v| v.parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or(1) +} + +/// Statut agrégé. `deployment = None` → `absent`. +pub fn app_status( + name: &str, + namespace: &str, + deployment: Option<&Value>, + pods: Vec, +) -> AppStatus { + let Some(dep) = deployment else { + return AppStatus { + name: name.into(), + namespace: namespace.into(), + phase: "absent".into(), + healthy: false, + rollout_complete: false, + image: None, + replicas: 0, + ready_replicas: 0, + updated_replicas: 0, + available_replicas: 0, + message: "aucun Deployment".into(), + pods, + }; + }; + let desired = dep + .pointer("/spec/replicas") + .and_then(Value::as_i64) + .unwrap_or(1); + let generation = i(dep, &["metadata", "generation"]); + let observed = i(dep, &["status", "observedGeneration"]); + let ready = i(dep, &["status", "readyReplicas"]); + let updated = i(dep, &["status", "updatedReplicas"]); + let available = i(dep, &["status", "availableReplicas"]); + let total = i(dep, &["status", "replicas"]); + let image = dep + .pointer("/spec/template/spec/containers") + .and_then(Value::as_array) + .and_then(|a| a.first()) + .and_then(|c| s(c, &["image"])); + let deadline_exceeded = dep + .pointer("/status/conditions") + .and_then(Value::as_array) + .is_some_and(|a| { + a.iter().any(|c| { + c.get("type").and_then(Value::as_str) == Some("Progressing") + && c.get("reason").and_then(Value::as_str) == Some("ProgressDeadlineExceeded") + }) + }); + // Même règle que `kubectl rollout status`. + let rollout_complete = + observed >= generation && updated >= desired && total <= updated && available >= desired; + let pod_issue = pods.iter().find_map(|p| p.reason.clone()); + let (phase, message) = if desired == 0 { + ("stopped", "arrêtée (0 réplica)".to_string()) + } else if deadline_exceeded { + ( + "degraded", + format!( + "rollout bloqué (ProgressDeadlineExceeded){}", + pod_issue.map(|r| format!(" — {r}")).unwrap_or_default() + ), + ) + } else if rollout_complete { + ("running", format!("{ready}/{desired} prêts")) + } else if pod_issue + .as_deref() + .is_some_and(|r| r.contains("BackOff") || r == "ErrImagePull" || r == "Error") + { + ( + "degraded", + format!( + "{ready}/{desired} prêts — {}", + pod_issue.unwrap_or_default() + ), + ) + } else { + ( + "progressing", + format!("rollout en cours : {updated}/{desired} à jour, {available} disponibles"), + ) + }; + AppStatus { + name: name.into(), + namespace: namespace.into(), + phase: phase.into(), + healthy: phase == "running", + rollout_complete, + image, + replicas: desired, + ready_replicas: ready, + updated_replicas: updated, + available_replicas: available, + message, + pods, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn dep(desired: i64, gen: i64, obs: i64, upd: i64, avail: i64, total: i64) -> Value { + json!({ + "metadata": { "generation": gen, "annotations": { "devforge.io/replicas": "3" } }, + "spec": { "replicas": desired, "template": { "spec": { "containers": [{ "image": "i:2" }] } } }, + "status": { + "observedGeneration": obs, "updatedReplicas": upd, "availableReplicas": avail, + "readyReplicas": avail, "replicas": total + } + }) + } + + #[test] + fn absent_when_no_deployment() { + let s = app_status("a", "ns", None, vec![]); + assert_eq!(s.phase, "absent"); + assert!(!s.healthy); + } + + #[test] + fn running_when_rollout_complete() { + let s = app_status("a", "ns", Some(&dep(2, 3, 3, 2, 2, 2)), vec![]); + assert_eq!(s.phase, "running"); + assert!(s.rollout_complete && s.healthy); + assert_eq!(s.image.as_deref(), Some("i:2")); + } + + #[test] + fn progressing_while_old_pods_remain() { + let s = app_status("a", "ns", Some(&dep(2, 3, 3, 1, 2, 3)), vec![]); + assert_eq!(s.phase, "progressing"); + assert!(!s.rollout_complete); + let s = app_status("a", "ns", Some(&dep(2, 4, 3, 2, 2, 2)), vec![]); + assert_eq!(s.phase, "progressing", "génération pas encore observée"); + } + + #[test] + fn stopped_and_remembered_replicas() { + let d = dep(0, 2, 2, 0, 0, 0); + assert_eq!(app_status("a", "ns", Some(&d), vec![]).phase, "stopped"); + assert_eq!(remembered_replicas(&d), 3); + assert_eq!(remembered_replicas(&json!({})), 1); + } + + #[test] + fn degraded_on_image_pull_backoff() { + let pods = pods_from_list(&json!({ "items": [{ + "metadata": { "name": "a-1", "creationTimestamp": "2026-11-02T10:00:00Z" }, + "status": { "phase": "Pending", "containerStatuses": [{ + "name": "app", "ready": false, "restartCount": 0, "image": "i:2", + "state": { "waiting": { "reason": "ImagePullBackOff" } } + }]} + }]})); + assert_eq!(pods[0].reason.as_deref(), Some("ImagePullBackOff")); + let s = app_status("a", "ns", Some(&dep(1, 1, 1, 1, 0, 1)), pods); + assert_eq!(s.phase, "degraded"); + assert!(s.message.contains("ImagePullBackOff")); + } + + #[test] + fn pods_sorted_newest_first_and_unschedulable_reason() { + let pods = pods_from_list(&json!({ "items": [ + { "metadata": { "name": "old", "creationTimestamp": "2026-11-01T00:00:00Z" }, + "status": { "phase": "Running", "containerStatuses": [{ "name": "app", "ready": true, "restartCount": 2 }] } }, + { "metadata": { "name": "new", "creationTimestamp": "2026-11-02T00:00:00Z" }, + "status": { "phase": "Pending", "conditions": [{ "type": "PodScheduled", "status": "False", "reason": "Unschedulable" }] } } + ]})); + assert_eq!(pods[0].name, "new"); + assert_eq!(pods[0].reason.as_deref(), Some("Unschedulable")); + assert_eq!(pods[1].restarts, 2); + assert!(pods[1].ready); + } +} diff --git a/crates/kube/tests/live_cluster.rs b/crates/kube/tests/live_cluster.rs new file mode 100644 index 0000000000..9dcfe007b8 --- /dev/null +++ b/crates/kube/tests/live_cluster.rs @@ -0,0 +1,125 @@ +//! Test contre une vraie API Kubernetes (kind, Talos, envtest…). Ignoré sans +//! `DEVFORGE_KUBE_IT=1`. Variables : `DEVFORGE_KUBE_API_URL`, `DEVFORGE_KUBE_TOKEN_FILE`, +//! `DEVFORGE_KUBE_NAMESPACE` (namespace déjà créé, RBAC du chart Helm), +//! `DEVFORGE_KUBE_CA_FILE` ou `DEVFORGE_KUBE_INSECURE_SKIP_TLS_VERIFY=1`. +//! +//! Sans contrôleurs (envtest), les pods ne démarrent pas : on vérifie l'API (apply, prune, +//! stop/start/restart, RBAC), pas l'exécution des conteneurs. + +use devforge_kube::client::paths; +use devforge_kube::runtime::ImageRuntime; +use devforge_kube::{AppSpec, KubeError, KubeRuntime, TlsSpec}; +use serde_json::json; + +fn enabled() -> bool { + std::env::var("DEVFORGE_KUBE_IT").as_deref() == Ok("1") +} + +#[tokio::test] +async fn live_deploy_lifecycle() { + if !enabled() { + eprintln!("DEVFORGE_KUBE_IT absent — test cluster ignoré"); + return; + } + let rt = KubeRuntime::from_env().expect("config kube"); + let ns = rt.namespace().to_string(); + let name = "it-web"; + let _ = rt.remove(name).await; + + let mut spec = AppSpec::new(name, "nginxinc/nginx-unprivileged:1.27"); + spec.port = 8080; + spec.replicas = 2; + spec.host = Some("it-web.example.test".into()); + spec.tls = Some(TlsSpec::default()); + spec.env.insert("MODE".into(), "test".into()); + spec.secrets.insert("API_KEY".into(), "it-secret".into()); + let out = rt.deploy(&spec).await.expect("deploy"); + assert_eq!( + out.applied, + vec![ + "Secret/it-web-env", + "Deployment/it-web", + "Service/it-web", + "Ingress/it-web" + ] + ); + assert!(["progressing", "running"].contains(&out.status.phase.as_str())); + let c = rt.client(); + let dep = c + .get(&paths::deployment(&ns, name)) + .await + .unwrap() + .expect("deployment"); + let gen1 = dep["metadata"]["generation"].as_i64().unwrap(); + let hash1 = dep["spec"]["template"]["metadata"]["annotations"]["devforge.io/config-hash"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + dep["spec"]["template"]["spec"]["containers"][0]["envFrom"][0]["secretRef"]["name"], + "it-web-env" + ); + + // Rolling update : nouvelle image, plus d'Ingress ni de Secret → élagués. + let mut v2 = AppSpec::new(name, "nginxinc/nginx-unprivileged:1.27-alpine"); + v2.port = 8080; + v2.replicas = 2; + let out = rt.deploy(&v2).await.expect("redeploy"); + assert_eq!(out.pruned, vec!["Ingress/it-web", "Secret/it-web-env"]); + let dep = c.get(&paths::deployment(&ns, name)).await.unwrap().unwrap(); + assert!(dep["metadata"]["generation"].as_i64().unwrap() > gen1); + assert_ne!( + dep["spec"]["template"]["metadata"]["annotations"]["devforge.io/config-hash"], + json!(hash1) + ); + assert_eq!( + dep["spec"]["template"]["spec"]["containers"][0]["image"], + "nginxinc/nginx-unprivileged:1.27-alpine" + ); + + let st = rt.stop(name).await.expect("stop"); + assert_eq!(st.phase, "stopped"); + let st = rt.start(name).await.expect("start"); + assert_eq!(st.replicas, 2); + rt.restart(name).await.expect("restart"); + let dep = c.get(&paths::deployment(&ns, name)).await.unwrap().unwrap(); + assert!(dep["spec"]["template"]["metadata"]["annotations"] + .get("kubectl.kubernetes.io/restartedAt") + .is_some()); + + let logs = rt.logs(name, 50).await.expect("logs"); + assert_eq!(logs.name, name); + assert!(rt.list().await.unwrap().iter().any(|a| a.name == name)); + + // Une ressource non gérée par DevForge n'est jamais écrasée. + let foreign = json!({ + "apiVersion": "apps/v1", "kind": "Deployment", + "metadata": { "name": "it-foreign", "namespace": ns }, + "spec": { + "selector": { "matchLabels": { "x": "y" } }, + "template": { "metadata": { "labels": { "x": "y" } }, + "spec": { "containers": [{ "name": "c", "image": "busybox" }] } } + } + }); + let fp = paths::deployment(&ns, "it-foreign"); + let _ = c.delete(&fp).await; + if c.apply(&fp, &foreign).await.is_ok() { + let err = rt + .deploy(&AppSpec::new("it-foreign", "busybox:1")) + .await + .unwrap_err(); + assert!(matches!(err, KubeError::Invalid(_)), "{err}"); + assert!(rt.remove("it-foreign").await.unwrap().is_empty()); + let _ = c.delete(&fp).await; + } + + let removed = rt.remove(name).await.expect("remove"); + assert_eq!(removed, vec!["Service/it-web", "Deployment/it-web"]); + assert_eq!(rt.status(name).await.unwrap().phase, "absent"); + + // RBAC : le compte de service n'a aucun droit hors de son namespace. + if let Ok(other) = std::env::var("DEVFORGE_KUBE_IT_FORBIDDEN_NS") { + let err = c.get(&paths::deployments(&other)).await.unwrap_err(); + assert!(matches!(err, KubeError::Api { status: 403, .. }), "{err}"); + } +} diff --git a/crates/kube/tests/manifests_snapshot.rs b/crates/kube/tests/manifests_snapshot.rs new file mode 100644 index 0000000000..5cb510ce1d --- /dev/null +++ b/crates/kube/tests/manifests_snapshot.rs @@ -0,0 +1,71 @@ +//! Snapshots des manifests générés. Régénérer : `UPDATE_SNAPSHOTS=1 cargo test -p devforge-kube`. + +use std::path::PathBuf; + +use devforge_kube::{render, AppSpec, HealthSpec, KubeDefaults, ResourceSpec, TlsSpec}; + +fn check(name: &str, value: &serde_json::Value) { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/snapshots") + .join(format!("{name}.json")); + let actual = serde_json::to_string_pretty(value).unwrap() + "\n"; + if std::env::var_os("UPDATE_SNAPSHOTS").is_some() || !path.exists() { + std::fs::write(&path, &actual).unwrap(); + if std::env::var_os("UPDATE_SNAPSHOTS").is_none() { + panic!("snapshot {name} créé — relancer le test"); + } + return; + } + // Comparaison structurelle : indépendante de l'ordre des clés et des fins de ligne. + let expected: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!( + &expected == value, + "snapshot {name} différent — UPDATE_SNAPSHOTS=1 pour régénérer\n--- attendu\n{}\n--- obtenu\n{actual}", + serde_json::to_string_pretty(&expected).unwrap() + ); +} + +#[test] +fn minimal_app_snapshot() { + let mut spec = AppSpec::new("hello", "ghcr.io/bobdivx/hello:1.0.0"); + spec.normalize().unwrap(); + let r = render(&spec, &KubeDefaults::new("devforge-apps")); + check("minimal", &r.redacted()); +} + +#[test] +fn full_app_snapshot() { + let mut spec = AppSpec::new("shop", "ghcr.io/bobdivx/shop:sha-abc1234"); + spec.port = 3000; + spec.replicas = 2; + spec.host = Some("shop.example.com".into()); + spec.tls = Some(TlsSpec { + secret_name: None, + cluster_issuer: Some("letsencrypt-prod".into()), + }); + spec.env.insert("NODE_ENV".into(), "production".into()); + spec.env.insert("PORT".into(), "3000".into()); + spec.secrets + .insert("DATABASE_URL".into(), "postgres://u:p@db/shop".into()); + spec.health = Some(HealthSpec { + path: Some("/healthz".into()), + initial_delay_seconds: Some(10), + period_seconds: None, + failure_threshold: None, + }); + spec.resources = Some(ResourceSpec { + memory: Some("512m".into()), + cpus: Some("0.5".into()), + }); + spec.image_pull_secret = Some("ghcr-pull".into()); + spec.run_as_user = Some(1000); + spec.project_uuid = Some("0b6f0c1e-1111-4222-8333-944445555666".into()); + spec.normalize().unwrap(); + let mut d = KubeDefaults::new("devforge-apps"); + d.ingress_class = Some("traefik".into()); + let r = render(&spec, &d); + let redacted = r.redacted(); + assert!(!redacted.to_string().contains("postgres://u:p@db/shop")); + check("full", &redacted); +} diff --git a/crates/kube/tests/snapshots/full.json b/crates/kube/tests/snapshots/full.json new file mode 100644 index 0000000000..bbaec3e67c --- /dev/null +++ b/crates/kube/tests/snapshots/full.json @@ -0,0 +1,237 @@ +{ + "apiVersion": "v1", + "items": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app.kubernetes.io/instance": "shop", + "app.kubernetes.io/managed-by": "devforge", + "app.kubernetes.io/name": "shop", + "devforge.io/app": "shop", + "devforge.io/project": "0b6f0c1e-1111-4222-8333-944445555666" + }, + "name": "shop-env", + "namespace": "devforge-apps" + }, + "stringData": { + "DATABASE_URL": "***" + }, + "type": "Opaque" + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "annotations": { + "devforge.io/replicas": "2" + }, + "labels": { + "app.kubernetes.io/instance": "shop", + "app.kubernetes.io/managed-by": "devforge", + "app.kubernetes.io/name": "shop", + "devforge.io/app": "shop", + "devforge.io/project": "0b6f0c1e-1111-4222-8333-944445555666" + }, + "name": "shop", + "namespace": "devforge-apps" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 2, + "revisionHistoryLimit": 5, + "selector": { + "matchLabels": { + "devforge.io/app": "shop" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "devforge.io/config-hash": "c1e42ffe0bb4eb06" + }, + "labels": { + "app.kubernetes.io/instance": "shop", + "app.kubernetes.io/managed-by": "devforge", + "app.kubernetes.io/name": "shop", + "devforge.io/app": "shop", + "devforge.io/project": "0b6f0c1e-1111-4222-8333-944445555666" + } + }, + "spec": { + "automountServiceAccountToken": false, + "containers": [ + { + "env": [ + { + "name": "NODE_ENV", + "value": "production" + }, + { + "name": "PORT", + "value": "3000" + } + ], + "envFrom": [ + { + "secretRef": { + "name": "shop-env" + } + } + ], + "image": "ghcr.io/bobdivx/shop:sha-abc1234", + "imagePullPolicy": "IfNotPresent", + "livenessProbe": { + "failureThreshold": 3, + "httpGet": { + "path": "/healthz", + "port": "http" + }, + "initialDelaySeconds": 30, + "periodSeconds": 20, + "timeoutSeconds": 5 + }, + "name": "app", + "ports": [ + { + "containerPort": 3000, + "name": "http", + "protocol": "TCP" + } + ], + "readinessProbe": { + "failureThreshold": 3, + "httpGet": { + "path": "/healthz", + "port": "http" + }, + "initialDelaySeconds": 10, + "periodSeconds": 10, + "timeoutSeconds": 5 + }, + "resources": { + "limits": { + "cpu": "0.5", + "memory": "512Mi" + }, + "requests": { + "memory": "512Mi" + } + }, + "securityContext": { + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": [ + "ALL" + ] + } + } + } + ], + "enableServiceLinks": false, + "imagePullSecrets": [ + { + "name": "ghcr-pull" + } + ], + "securityContext": { + "runAsNonRoot": true, + "runAsUser": 1000, + "seccompProfile": { + "type": "RuntimeDefault" + } + } + } + } + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app.kubernetes.io/instance": "shop", + "app.kubernetes.io/managed-by": "devforge", + "app.kubernetes.io/name": "shop", + "devforge.io/app": "shop", + "devforge.io/project": "0b6f0c1e-1111-4222-8333-944445555666" + }, + "name": "shop", + "namespace": "devforge-apps" + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 80, + "protocol": "TCP", + "targetPort": "http" + } + ], + "selector": { + "devforge.io/app": "shop" + }, + "type": "ClusterIP" + } + }, + { + "apiVersion": "networking.k8s.io/v1", + "kind": "Ingress", + "metadata": { + "annotations": { + "cert-manager.io/cluster-issuer": "letsencrypt-prod" + }, + "labels": { + "app.kubernetes.io/instance": "shop", + "app.kubernetes.io/managed-by": "devforge", + "app.kubernetes.io/name": "shop", + "devforge.io/app": "shop", + "devforge.io/project": "0b6f0c1e-1111-4222-8333-944445555666" + }, + "name": "shop", + "namespace": "devforge-apps" + }, + "spec": { + "ingressClassName": "traefik", + "rules": [ + { + "host": "shop.example.com", + "http": { + "paths": [ + { + "backend": { + "service": { + "name": "shop", + "port": { + "number": 80 + } + } + }, + "path": "/", + "pathType": "Prefix" + } + ] + } + } + ], + "tls": [ + { + "hosts": [ + "shop.example.com" + ], + "secretName": "shop-tls" + } + ] + } + } + ], + "kind": "List" +} diff --git a/crates/kube/tests/snapshots/minimal.json b/crates/kube/tests/snapshots/minimal.json new file mode 100644 index 0000000000..2c1f04bdf8 --- /dev/null +++ b/crates/kube/tests/snapshots/minimal.json @@ -0,0 +1,131 @@ +{ + "apiVersion": "v1", + "items": [ + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "annotations": { + "devforge.io/replicas": "1" + }, + "labels": { + "app.kubernetes.io/instance": "hello", + "app.kubernetes.io/managed-by": "devforge", + "app.kubernetes.io/name": "hello", + "devforge.io/app": "hello" + }, + "name": "hello", + "namespace": "devforge-apps" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 1, + "revisionHistoryLimit": 5, + "selector": { + "matchLabels": { + "devforge.io/app": "hello" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "devforge.io/config-hash": "e3b0c44298fc1c14" + }, + "labels": { + "app.kubernetes.io/instance": "hello", + "app.kubernetes.io/managed-by": "devforge", + "app.kubernetes.io/name": "hello", + "devforge.io/app": "hello" + } + }, + "spec": { + "automountServiceAccountToken": false, + "containers": [ + { + "image": "ghcr.io/bobdivx/hello:1.0.0", + "imagePullPolicy": "IfNotPresent", + "livenessProbe": { + "failureThreshold": 3, + "initialDelaySeconds": 30, + "periodSeconds": 20, + "tcpSocket": { + "port": "http" + }, + "timeoutSeconds": 5 + }, + "name": "app", + "ports": [ + { + "containerPort": 8080, + "name": "http", + "protocol": "TCP" + } + ], + "readinessProbe": { + "failureThreshold": 3, + "initialDelaySeconds": 5, + "periodSeconds": 10, + "tcpSocket": { + "port": "http" + }, + "timeoutSeconds": 5 + }, + "securityContext": { + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": [ + "ALL" + ] + } + } + } + ], + "enableServiceLinks": false, + "securityContext": { + "runAsNonRoot": true, + "seccompProfile": { + "type": "RuntimeDefault" + } + } + } + } + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app.kubernetes.io/instance": "hello", + "app.kubernetes.io/managed-by": "devforge", + "app.kubernetes.io/name": "hello", + "devforge.io/app": "hello" + }, + "name": "hello", + "namespace": "devforge-apps" + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 80, + "protocol": "TCP", + "targetPort": "http" + } + ], + "selector": { + "devforge.io/app": "hello" + }, + "type": "ClusterIP" + } + } + ], + "kind": "List" +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 1017080df3..e30ab29fd6 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -1,5 +1,6 @@ pub mod log_redact; pub mod redact; +pub mod runtime_backend; use async_trait::async_trait; use serde::{Deserialize, Serialize}; diff --git a/crates/shared/src/runtime_backend.rs b/crates/shared/src/runtime_backend.rs new file mode 100644 index 0000000000..685d65988d --- /dev/null +++ b/crates/shared/src/runtime_backend.rs @@ -0,0 +1,61 @@ +//! Choix du backend d'exécution des apps (`DEVFORGE_RUNTIME`). +//! +//! - `docker` (défaut) : comportement historique (`docker build` / `docker run`, Traefik). +//! - `kubernetes` (alias `k8s`, `kube`) : DevForge tourne dans un cluster et déploie des +//! images déjà construites via l'API Kubernetes (phase 1). + +/// Backend d'exécution sélectionné. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RuntimeBackend { + #[default] + Docker, + Kubernetes, +} + +impl RuntimeBackend { + /// Valeur inconnue ou vide → `Docker` (jamais de bascule silencieuse vers k8s). + pub fn parse(raw: &str) -> Self { + match raw.trim().to_ascii_lowercase().as_str() { + "kubernetes" | "k8s" | "kube" => Self::Kubernetes, + _ => Self::Docker, + } + } + + pub fn from_env() -> Self { + std::env::var("DEVFORGE_RUNTIME") + .map(|v| Self::parse(&v)) + .unwrap_or_default() + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Docker => "docker", + Self::Kubernetes => "kubernetes", + } + } + + pub fn is_kubernetes(self) -> bool { + matches!(self, Self::Kubernetes) + } +} + +#[cfg(test)] +mod tests { + use super::RuntimeBackend; + + #[test] + fn parse_defaults_to_docker() { + assert_eq!(RuntimeBackend::parse(""), RuntimeBackend::Docker); + assert_eq!(RuntimeBackend::parse("docker"), RuntimeBackend::Docker); + assert_eq!(RuntimeBackend::parse("nomad"), RuntimeBackend::Docker); + assert_eq!(RuntimeBackend::default(), RuntimeBackend::Docker); + } + + #[test] + fn parse_kubernetes_aliases() { + for v in ["kubernetes", "K8S", " kube "] { + assert_eq!(RuntimeBackend::parse(v), RuntimeBackend::Kubernetes); + } + assert_eq!(RuntimeBackend::Kubernetes.as_str(), "kubernetes"); + } +} diff --git a/crates/update/src/lib.rs b/crates/update/src/lib.rs index 63c421c813..984e32f578 100644 --- a/crates/update/src/lib.rs +++ b/crates/update/src/lib.rs @@ -94,6 +94,10 @@ impl UpdateConfig { } } +/// En mode Kubernetes, l'auto-update Docker est désactivé : la version suit le chart Helm. +pub const KUBERNETES_UPDATE_HINT: &str = + "DEVFORGE_RUNTIME=kubernetes : mise à jour via Helm (helm upgrade --set image.tag=…), pas via Docker."; + fn detect_auto_mode() -> UpdateMode { if std::env::var("DEVFORGE_UPDATE_COMPOSE_FILE").is_ok() { return UpdateMode::Compose; @@ -365,6 +369,9 @@ impl UpdateFacade { /// Prérequis manquants pour le mode courant (None = OK). fn mode_ready_hint(&self) -> Option<&'static str> { + if devforge_shared::runtime_backend::RuntimeBackend::from_env().is_kubernetes() { + return Some(KUBERNETES_UPDATE_HINT); + } match self.config.mode { UpdateMode::Compose | UpdateMode::Docker => None, UpdateMode::Binary => None, @@ -464,6 +471,9 @@ impl UpdateFacade { } pub async fn start(&self, target: Option) -> Result { + if devforge_shared::runtime_backend::RuntimeBackend::from_env().is_kubernetes() { + return Err(DevForgeError::Message(KUBERNETES_UPDATE_HINT.into())); + } { let guard = self.job.read().await; if let Some(j) = guard.as_ref() { diff --git a/deploy/helm/devforge/.helmignore b/deploy/helm/devforge/.helmignore new file mode 100644 index 0000000000..26c6e13072 --- /dev/null +++ b/deploy/helm/devforge/.helmignore @@ -0,0 +1,3 @@ +.DS_Store +*.tmp +*.orig diff --git a/deploy/helm/devforge/Chart.yaml b/deploy/helm/devforge/Chart.yaml new file mode 100644 index 0000000000..568b0167d8 --- /dev/null +++ b/deploy/helm/devforge/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: devforge +description: DevForge (PaaS self-hosted) sur Kubernetes — backend runtime Kubernetes, phase 1. +type: application +version: 0.1.0 +appVersion: "2.0.278" +home: https://github.com/bobdivx/devforge +sources: + - https://github.com/bobdivx/devforge +keywords: + - paas + - self-hosted + - devforge diff --git a/deploy/helm/devforge/templates/NOTES.txt b/deploy/helm/devforge/templates/NOTES.txt new file mode 100644 index 0000000000..54fadbcedd --- /dev/null +++ b/deploy/helm/devforge/templates/NOTES.txt @@ -0,0 +1,14 @@ +DevForge {{ default .Chart.AppVersion .Values.image.tag }} installé (backend runtime : kubernetes, phase 1). + +{{- if .Values.ingress.enabled }} +Interface : http{{ if .Values.ingress.tls.enabled }}s{{ end }}://{{ .Values.ingress.host }} +{{- else }} +Accès local : + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "devforge.fullname" . }} 8000:{{ .Values.service.port }} + puis http://127.0.0.1:8000 +{{- end }} + +Apps déployées dans le namespace : {{ include "devforge.appsNamespace" . }} +Phase 1 : déploiement depuis une image déjà construite (POST /api/v1/kube/apps). +Pas de build dans le cluster. Mise à jour de DevForge : helm upgrade (auto-update désactivé). +Tuto : https://github.com/bobdivx/devforge/wiki/Kubernetes diff --git a/deploy/helm/devforge/templates/_helpers.tpl b/deploy/helm/devforge/templates/_helpers.tpl new file mode 100644 index 0000000000..703eae5f05 --- /dev/null +++ b/deploy/helm/devforge/templates/_helpers.tpl @@ -0,0 +1,46 @@ +{{- define "devforge.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "devforge.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "devforge.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "devforge.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "devforge.selectorLabels" -}} +app.kubernetes.io/name: {{ include "devforge.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: server +{{- end }} + +{{- define "devforge.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "devforge.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{- define "devforge.appsNamespace" -}} +{{- default .Release.Namespace .Values.apps.namespace }} +{{- end }} + +{{- define "devforge.pgName" -}} +{{- printf "%s-postgres" (include "devforge.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} diff --git a/deploy/helm/devforge/templates/apps-namespace.yaml b/deploy/helm/devforge/templates/apps-namespace.yaml new file mode 100644 index 0000000000..aecd7a1616 --- /dev/null +++ b/deploy/helm/devforge/templates/apps-namespace.yaml @@ -0,0 +1,16 @@ +{{- $ns := include "devforge.appsNamespace" . }} +{{- if and .Values.apps.createNamespace (ne $ns .Release.Namespace) }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ $ns }} + labels: + {{- include "devforge.labels" . | nindent 4 }} + pod-security.kubernetes.io/enforce: {{ .Values.apps.podSecurity }} + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/audit: restricted + annotations: + # Ne pas supprimer les apps des utilisateurs au `helm uninstall`. + helm.sh/resource-policy: keep +{{- end }} diff --git a/deploy/helm/devforge/templates/deployment.yaml b/deploy/helm/devforge/templates/deployment.yaml new file mode 100644 index 0000000000..abffb9d6dd --- /dev/null +++ b/deploy/helm/devforge/templates/deployment.yaml @@ -0,0 +1,145 @@ +{{- if and (not .Values.postgresql.enabled) (not .Values.postgresql.external.existingSecret) }} +{{- fail "postgresql.enabled=false exige postgresql.external.existingSecret (clé database-url = postgres://…)" }} +{{- end }} +{{- $dbSecret := ternary (printf "%s-db" (include "devforge.fullname" .)) .Values.postgresql.external.existingSecret .Values.postgresql.enabled }} +{{- $dbKey := ternary "database-url" .Values.postgresql.external.key .Values.postgresql.enabled }} +{{- $tag := default .Chart.AppVersion .Values.image.tag }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "devforge.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "devforge.labels" . | nindent 4 }} + app.kubernetes.io/component: server +spec: + replicas: {{ .Values.replicaCount }} + # PVC ReadWriteOnce : l'ancien pod doit libérer /data avant le nouveau. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "devforge.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "devforge.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "devforge.serviceAccountName" . }} + automountServiceAccountToken: true + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- if .Values.postgresql.enabled }} + initContainers: + - name: wait-postgres + image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}" + imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + command: ["sh", "-c", "until pg_isready -h {{ include "devforge.pgName" . }} -p 5432; do echo 'attente postgres'; sleep 2; done"] + {{- end }} + containers: + - name: devforge + image: "{{ .Values.image.repository }}:{{ $tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - name: http + containerPort: 8000 + protocol: TCP + env: + - name: HOST + value: "0.0.0.0" + - name: PORT + value: "8000" + - name: DEVFORGE_DATA_DIR + value: /data + # uid 1000 = utilisateur « node » de l'image : HOME inscriptible. + - name: HOME + value: /home/node + - name: DEVFORGE_VERSION + value: {{ $tag | quote }} + - name: DEVFORGE_EXECUTOR + value: local + # Backend Kubernetes : déploiement d'images via l'API du cluster ; + # désactive Traefik Docker, le GC Docker et l'auto-update Docker. + - name: DEVFORGE_RUNTIME + value: kubernetes + - name: DEVFORGE_KUBE_NAMESPACE + value: {{ include "devforge.appsNamespace" . | quote }} + {{- with .Values.apps.ingressClass }} + - name: DEVFORGE_KUBE_INGRESS_CLASS + value: {{ . | quote }} + {{- end }} + {{- with .Values.apps.clusterIssuer }} + - name: DEVFORGE_KUBE_CLUSTER_ISSUER + value: {{ . | quote }} + {{- end }} + {{- with .Values.apps.imagePullSecret }} + - name: DEVFORGE_KUBE_IMAGE_PULL_SECRET + value: {{ . | quote }} + {{- end }} + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ $dbSecret }} + key: {{ $dbKey }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.extraEnvFromSecret }} + envFrom: + - secretRef: + name: {{ . }} + {{- end }} + startupProbe: + httpGet: + path: /api/v1/health + port: http + periodSeconds: 5 + failureThreshold: 60 + readinessProbe: + httpGet: + path: /api/v1/health + port: http + periodSeconds: 10 + timeoutSeconds: 3 + livenessProbe: + httpGet: + path: /api/v1/health + port: http + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ default (printf "%s-data" (include "devforge.fullname" .)) .Values.persistence.existingClaim }} + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/devforge/templates/ingress.yaml b/deploy/helm/devforge/templates/ingress.yaml new file mode 100644 index 0000000000..60f227d669 --- /dev/null +++ b/deploy/helm/devforge/templates/ingress.yaml @@ -0,0 +1,34 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "devforge.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "devforge.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . | quote }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host | quote }} + secretName: {{ default (printf "%s-tls" (include "devforge.fullname" .)) .Values.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "devforge.fullname" . }} + port: + name: http +{{- end }} diff --git a/deploy/helm/devforge/templates/postgresql.yaml b/deploy/helm/devforge/templates/postgresql.yaml new file mode 100644 index 0000000000..246b440ca9 --- /dev/null +++ b/deploy/helm/devforge/templates/postgresql.yaml @@ -0,0 +1,130 @@ +{{- if .Values.postgresql.enabled }} +{{- $name := include "devforge.pgName" . }} +{{- $secretName := printf "%s-db" (include "devforge.fullname" .) }} +{{- $pw := .Values.postgresql.password }} +{{- if not $pw }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }} +{{- if and $existing $existing.data (hasKey $existing.data "postgres-password") }} +{{- $pw = index $existing.data "postgres-password" | b64dec }} +{{- else }} +{{- $pw = randAlphaNum 32 }} +{{- end }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "devforge.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +type: Opaque +stringData: + postgres-password: {{ $pw | quote }} + database-url: {{ printf "postgres://%s:%s@%s:5432/%s?sslmode=disable" .Values.postgresql.user $pw $name .Values.postgresql.database | quote }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "devforge.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: postgresql + ports: + - name: postgres + port: 5432 + targetPort: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "devforge.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +spec: + serviceName: {{ $name }} + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: postgresql + template: + metadata: + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: postgresql + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 70 + runAsGroup: 70 + fsGroup: 70 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: postgres + image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}" + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + env: + - name: POSTGRES_USER + value: {{ .Values.postgresql.user | quote }} + - name: POSTGRES_DB + value: {{ .Values.postgresql.database | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: postgres-password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + ports: + - name: postgres + containerPort: 5432 + readinessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\" -h 127.0.0.1"] + periodSeconds: 5 + failureThreshold: 6 + livenessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\" -h 127.0.0.1"] + initialDelaySeconds: 30 + periodSeconds: 20 + {{- with .Values.postgresql.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + - name: run + mountPath: /var/run/postgresql + volumes: + - name: run + emptyDir: {} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.postgresql.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.postgresql.size }} +{{- end }} diff --git a/deploy/helm/devforge/templates/pvc.yaml b/deploy/helm/devforge/templates/pvc.yaml new file mode 100644 index 0000000000..05c37c90f1 --- /dev/null +++ b/deploy/helm/devforge/templates/pvc.yaml @@ -0,0 +1,20 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "devforge.fullname" . }}-data + namespace: {{ .Release.Namespace }} + labels: + {{- include "devforge.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/deploy/helm/devforge/templates/rbac.yaml b/deploy/helm/devforge/templates/rbac.yaml new file mode 100644 index 0000000000..408474859e --- /dev/null +++ b/deploy/helm/devforge/templates/rbac.yaml @@ -0,0 +1,44 @@ +{{- /* Droits limités au namespace des apps : rien au niveau cluster. */}} +{{- $ns := include "devforge.appsNamespace" . }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "devforge.fullname" . }}-apps + namespace: {{ $ns }} + labels: + {{- include "devforge.labels" . | nindent 4 }} +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services", "secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] + - apiGroups: [""] + resources: ["events"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "devforge.fullname" . }}-apps + namespace: {{ $ns }} + labels: + {{- include "devforge.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "devforge.fullname" . }}-apps +subjects: + - kind: ServiceAccount + name: {{ include "devforge.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} diff --git a/deploy/helm/devforge/templates/service.yaml b/deploy/helm/devforge/templates/service.yaml new file mode 100644 index 0000000000..f67bdf1278 --- /dev/null +++ b/deploy/helm/devforge/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "devforge.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "devforge.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "devforge.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/deploy/helm/devforge/templates/serviceaccount.yaml b/deploy/helm/devforge/templates/serviceaccount.yaml new file mode 100644 index 0000000000..335d6e177f --- /dev/null +++ b/deploy/helm/devforge/templates/serviceaccount.yaml @@ -0,0 +1,14 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "devforge.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "devforge.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: true +{{- end }} diff --git a/deploy/helm/devforge/values.yaml b/deploy/helm/devforge/values.yaml new file mode 100644 index 0000000000..41f375d139 --- /dev/null +++ b/deploy/helm/devforge/values.yaml @@ -0,0 +1,120 @@ +# Valeurs du chart DevForge. Tuto complet : wiki « Kubernetes ». + +image: + # Image publiée par le workflow « Docker images » du dépôt. + repository: ghcr.io/bobdivx/devforge + # Vide → appVersion du chart. + tag: "" + pullPolicy: IfNotPresent + +# Secrets docker-registry pour tirer l'image DevForge elle-même (si registre privé). +imagePullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +# Toujours 1 : le control plane écrit dans /data (PVC ReadWriteOnce). +replicaCount: 1 + +serviceAccount: + create: true + name: "" + annotations: {} + +# Contexte de sécurité compatible PodSecurity « restricted » (Talos l'applique par défaut +# en mode `baseline`/`restricted` selon la config). L'utilisateur 1000 (« node ») existe +# dans l'image. +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + # L'atelier (npm) écrit hors de /data : racine en lecture-écriture. + readOnlyRootFilesystem: false + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + # Ex. traefik, nginx, cilium. Vide → IngressClass par défaut du cluster. + className: "" + annotations: {} + # cert-manager.io/cluster-issuer: letsencrypt-prod + host: devforge.example.com + tls: + enabled: false + # Vide → -tls + secretName: "" + +persistence: + # /data : base SQLite historique, clés, sauvegardes locales, workspaces. + enabled: true + # Vide → StorageClass par défaut du cluster (ex. local-path, longhorn, rook-ceph). + storageClass: "" + accessMode: ReadWriteOnce + size: 10Gi + # Réutiliser un PVC existant (désactive la création). + existingClaim: "" + +resources: {} +# requests: { cpu: 250m, memory: 512Mi } +# limits: { memory: 2Gi } + +nodeSelector: {} +tolerations: [] +affinity: {} + +# Variables supplémentaires pour le serveur (non sensibles). +extraEnv: [] +# - name: DEVFORGE_ALLOW_REGISTER +# value: "0" + +# Secret existant injecté en entier (envFrom), ex. DEVFORGE_GITHUB_TOKEN, DEVFORGE_GITHUB_WEBHOOK_SECRET. +extraEnvFromSecret: "" + +# Base du control plane. DevForge exige PostgreSQL ; en Docker il lance lui-même +# `devforge-pg`, impossible ici (pas de Docker) → Postgres intégré ou externe. +postgresql: + # Postgres intégré (StatefulSet 1 réplica, non-root). Désactiver pour une base externe. + enabled: true + image: + repository: postgres + tag: 16-alpine + user: devforge + database: devforge + # Vide → mot de passe aléatoire conservé entre les upgrades (lookup du Secret). + password: "" + storageClass: "" + size: 5Gi + resources: {} + # Base externe : Secret existant contenant la clé `database-url` (postgres://…). + # Utilisé uniquement si enabled=false. + external: + existingSecret: "" + key: database-url + +# Backend Kubernetes : où et comment DevForge déploie les apps des utilisateurs. +apps: + # Namespace des apps. Vide → même namespace que DevForge. + namespace: devforge-apps + # Créer le namespace (avec labels PodSecurity). false si vous le gérez vous-même. + createNamespace: true + # Niveau PodSecurity appliqué au namespace des apps. « restricted » impose des images + # non-root ; « baseline » si vos images tournent en root (nginx officiel…). + podSecurity: restricted + # IngressClass par défaut des apps (vide → classe par défaut du cluster). + ingressClass: "" + # ClusterIssuer cert-manager par défaut quand une app demande TLS. + clusterIssuer: "" + # Secret dockerconfigjson par défaut (registre privé, ex. GHCR) dans le namespace des apps. + imagePullSecret: "" diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md new file mode 100644 index 0000000000..b53a09e5dc --- /dev/null +++ b/deploy/kubernetes/README.md @@ -0,0 +1,22 @@ +# DevForge sur Kubernetes (phase 1) + +Tutoriel complet : wiki **[Kubernetes](../../wiki/Kubernetes.md)**. + +| Élément | Chemin | +|---------|--------| +| Chart Helm de DevForge | [`deploy/helm/devforge`](../helm/devforge) | +| Exemple CI : build → GHCR → déploiement | [`examples/github-actions-build-push.yml`](examples/github-actions-build-push.yml) | +| Backend Kubernetes (crate Rust) | [`crates/kube`](../../crates/kube) | +| Routes API | `apps/server/src/kube_routes.rs` (`/api/v1/kube/*`) | + +Résumé : + +- `DEVFORGE_RUNTIME=kubernetes` active le backend (le chart le pose). Sans cette variable, + DevForge reste en mode **Docker** — comportement inchangé. +- Phase 1 : DevForge déploie une **image déjà construite** (Deployment + Service + Ingress + + Secret d'env, probes, statut, logs, stop/start/restart, rolling update). Pas de build + dans le cluster. +- Build : en CI (exemple ci-dessus) ou en local, puis push sur un registre (GHCR…). + [crane](https://github.com/google/go-containerregistry/tree/main/cmd/crane) sert à copier + ou re-tagger une image sans Docker : `crane copy src dst`, `crane tag img:sha-abc prod`. +- Phase 2 : build dans le cluster avec **Kaniko** (Job), intégration UI / projets. diff --git a/deploy/kubernetes/examples/github-actions-build-push.yml b/deploy/kubernetes/examples/github-actions-build-push.yml new file mode 100644 index 0000000000..cf9e1d07b1 --- /dev/null +++ b/deploy/kubernetes/examples/github-actions-build-push.yml @@ -0,0 +1,74 @@ +# Exemple : build d'une app sur GitHub Actions, push sur GHCR, puis déploiement par DevForge +# (backend Kubernetes, phase 1). À copier dans le dépôt de l'APP, sous +# .github/workflows/deploy.yml — ce fichier n'est volontairement PAS dans .github/workflows +# du dépôt DevForge. +# +# Secrets à créer dans le dépôt de l'app : +# DEVFORGE_URL ex. https://devforge.example.com +# DEVFORGE_TOKEN jeton API d'un instance_admin (Compte → Tokens) +# Variables (optionnelles) : APP_HOST (ex. app.example.com) +name: build-push-deploy + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + outputs: + image: ${{ steps.meta.outputs.image }} + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + run: | + # GHCR exige un nom en minuscules. Tag immuable = SHA du commit. + img="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]'):sha-${GITHUB_SHA::12}" + echo "image=$img" >> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.image }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Déployer via DevForge + env: + DEVFORGE_URL: ${{ secrets.DEVFORGE_URL }} + DEVFORGE_TOKEN: ${{ secrets.DEVFORGE_TOKEN }} + IMAGE: ${{ needs.build.outputs.image }} + APP_HOST: ${{ vars.APP_HOST }} + run: | + name="$(echo "${GITHUB_REPOSITORY#*/}" | tr '[:upper:]' '[:lower:]' | tr '_.' '--' | cut -c1-40)" + body="$(jq -n --arg n "$name" --arg i "$IMAGE" --arg h "$APP_HOST" '{ + name: $n, image: $i, port: 8080, replicas: 1, + health: { path: "/" } + } + (if $h == "" then {} else { host: $h, tls: {} } end)')" + # wait=true : la requête attend la fin du rollout (échec = job rouge). + curl -fsS -X POST "$DEVFORGE_URL/api/v1/kube/apps?wait=true&timeout=300" \ + -H "Authorization: Bearer $DEVFORGE_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$body" | jq . diff --git a/wiki/FAQ.md b/wiki/FAQ.md index 4cface5915..3b3c8c0593 100644 --- a/wiki/FAQ.md +++ b/wiki/FAQ.md @@ -6,7 +6,9 @@ C’est un **PaaS self-hosted** (Docker, domaines, env, backups) **plus** un bui ## Faut-il Kubernetes / k3s ? -Non. v1 = un leader SQLite + workers DevForge (process / Docker). Pas de k8s. +Non. Le mode par défaut reste un leader + workers DevForge (process / Docker), sans Kubernetes. + +Kubernetes est **optionnel** et en **phase 1 (expérimental)** : chart Helm pour installer DevForge dans un cluster (Talos testé en priorité) et déploiement d'apps depuis une image déjà construite (`DEVFORGE_RUNTIME=kubernetes`). Pas encore de build dans le cluster ni d'intégration UI. [[Kubernetes]] ## Comment ajouter une deuxième machine ? diff --git a/wiki/Home.md b/wiki/Home.md index 3313591ee9..b492bca288 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -15,6 +15,7 @@ Pas d’Electron : tout passe par le web. Un nœud **leader** tient le control p | Clé USB / nœud appliance | [[USB]] | | Passer le wizard | [[Premier-demarrage]] | | Ajouter des machines | [[Cluster]] | +| Installer sur Kubernetes (Talos, Helm) — phase 1 | [[Kubernetes]] | | Déployer une app | [[Projets]] · [[Deploiement]] | | Comprendre le produit | [[Architecture]] · [[Interface]] | diff --git a/wiki/Installation.md b/wiki/Installation.md index 1771af2b75..17814cb0b0 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -109,6 +109,10 @@ L’écran Mise à jour de DevForge fait la même chose à partir de la release Pour transformer une machine en nœud (leader ou worker) en bootant depuis une USB : [[USB]] et le kit [`deploy/usb/`](../deploy/usb/README.md) (`bootstrap.sh`, `prepare-kit.sh`, image `.img`). +## Kubernetes (Helm) — phase 1 + +Chart Helm `deploy/helm/devforge` pour installer DevForge dans un cluster (Talos, k3s, kind…) et déployer des apps depuis une image déjà construite. Expérimental : [[Kubernetes]]. + ## Réseau Docker / Traefik Pour que Traefik route les apps déployées, DevForge et les conteneurs `df-*` doivent partager un réseau : diff --git a/wiki/Kubernetes.md b/wiki/Kubernetes.md new file mode 100644 index 0000000000..44145b6bfc --- /dev/null +++ b/wiki/Kubernetes.md @@ -0,0 +1,287 @@ +# Kubernetes (Talos) + +> **Statut : phase 1 (expérimental).** DevForge peut tourner **dans** un cluster Kubernetes +> et y déployer des apps **à partir d'une image déjà construite**. Docker reste le backend +> par défaut : sans `DEVFORGE_RUNTIME=kubernetes`, rien ne change. + +## Ce que fait la phase 1 + +| Fonction | Kubernetes | +|----------|------------| +| Déployer une image | `Deployment` (rolling update `maxSurge 1` / `maxUnavailable 0`) | +| Exposer l'app | `Service` ClusterIP (port 80 → port de l'app) | +| Domaine + HTTPS | `Ingress` (host, `ingressClassName`, TLS optionnel via cert-manager) | +| Variables / secrets | `env` en clair + `Secret -env` injecté (`envFrom`) | +| Healthcheck | `startupProbe` / `readinessProbe` / `livenessProbe` (HTTP si `health.path`, sinon TCP) | +| Statut | `absent` · `stopped` · `progressing` · `running` · `degraded` + raison par pod (`ImagePullBackOff`…) | +| Logs | API Pod (`pods/log`), 3 pods max, secrets masqués | +| Stop / start / restart | replicas → 0 / restauré · annotation `restartedAt` (redémarrage progressif) | +| Supprimer | Deployment, Service, Ingress, Secret (uniquement ceux étiquetés `managed-by: devforge`) | + +Sécurité des pods d'apps : `runAsNonRoot`, `seccomp RuntimeDefault`, `drop ALL`, +`allowPrivilegeEscalation: false`, pas de jeton de compte de service monté. Compatible +PodSecurity **restricted**. + +En mode Kubernetes, DevForge **désactive** : Traefik Docker, le GC Docker et l'auto-update +Docker (mise à jour = `helm upgrade`). + +## 1. Prérequis Talos + +- Un cluster Talos ≥ 1.7 (Kubernetes ≥ 1.29) et `talosctl`, `kubectl`, `helm` (v3) sur ton poste. +- Récupérer le kubeconfig : + + ```bash + talosctl -n kubeconfig ~/.kube/config + kubectl get nodes + ``` + +- Talos active l'admission **PodSecurity** (par défaut `enforce: baseline`, `warn/audit: restricted`). + Le chart DevForge et les apps qu'il déploie respectent `restricted`. Les add-ons qui ont + besoin de `hostPath` (local-path, Longhorn…) doivent avoir leur namespace en `privileged`. +- Cluster mono-nœud (control plane seul) : autoriser les workloads sur le control plane + dans la machine config : + + ```yaml + cluster: + allowSchedulingOnControlPlanes: true + ``` + +## 2. Stockage (StorageClass) + +Talos n'installe **aucune** StorageClass. DevForge a besoin de deux volumes `ReadWriteOnce` +(`/data` 10 Gi, Postgres 5 Gi). + +**Option simple (mono-nœud / homelab) — local-path-provisioner :** + +```bash +kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.30/deploy/local-path-storage.yaml +kubectl label ns local-path-storage pod-security.kubernetes.io/enforce=privileged --overwrite +kubectl patch storageclass local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' +``` + +Talos : `/opt` est en lecture seule → pointer local-path vers `/var/local-path-provisioner` +(ConfigMap `local-path-config`) et monter ce chemin dans la machine config : + +```yaml +machine: + kubelet: + extraMounts: + - destination: /var/local-path-provisioner + type: bind + source: /var/local-path-provisioner + options: [bind, rshared, rw] +``` + +**Option multi-nœuds** : Longhorn (extensions Talos `iscsi-tools` + `util-linux-tools`) ou +Rook-Ceph. Vérifier : `kubectl get storageclass` (une classe `(default)`). + +## 3. Ingress controller + +Talos n'en fournit pas. Exemple avec ingress-nginx : + +```bash +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx +helm install ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx --create-namespace \ + --set controller.service.type=LoadBalancer # ou NodePort sans LB +``` + +- **LoadBalancer** sur bare metal : MetalLB ou l'IPAM Cilium ; sinon `NodePort` + redirection + du routeur. +- **TLS** : cert-manager + un `ClusterIssuer` (Let's Encrypt) : + + ```bash + helm install cert-manager oci://quay.io/jetstack/charts/cert-manager -n cert-manager \ + --create-namespace --set crds.enabled=true + ``` + +- **Sans IP publique** : Cloudflare Tunnel (`cloudflared` en Deployment) qui pointe vers + `http://ingress-nginx-controller.ingress-nginx.svc`. Dans ce cas TLS est terminé chez + Cloudflare : pas besoin de cert-manager. + +Traefik (le contrôleur utilisé par DevForge en mode Docker) fonctionne aussi : +`ingressClass: traefik`. + +## 4. Registre d'images (GHCR) et imagePullSecret + +Les apps doivent être **déjà construites** et poussées sur un registre. Avec GHCR : + +1. Créer un PAT GitHub (classic) avec `read:packages` (lecture seule suffit au cluster). +2. Créer le Secret dans le namespace des apps (`devforge-apps` par défaut) — à faire + **après** l'install Helm (qui crée le namespace) ou en créant le namespace avant : + + ```bash + kubectl create namespace devforge-apps --dry-run=client -o yaml | kubectl apply -f - + kubectl -n devforge-apps create secret docker-registry ghcr-pull \ + --docker-server=ghcr.io --docker-username= \ + --docker-password='' + ``` + +3. Le déclarer par défaut dans le chart : `--set apps.imagePullSecret=ghcr-pull`. + +Images publiques : rien à faire. + +**crane** (sans Docker) pour copier / re-tagger : + +```bash +crane copy docker.io/library/nginx:1.27 ghcr.io//nginx:1.27 +crane tag ghcr.io//mon-app:sha-1a2b3c4d prod +crane digest ghcr.io//mon-app:prod +``` + +## 5. Installer DevForge avec Helm + +Le chart est dans le dépôt : `deploy/helm/devforge`. + +```bash +git clone https://github.com/bobdivx/devforge && cd devforge + +kubectl create namespace devforge +# DevForge lui-même respecte « restricted » : +kubectl label ns devforge pod-security.kubernetes.io/enforce=restricted + +cat > my-values.yaml <<'YAML' +image: + repository: ghcr.io/bobdivx/devforge + tag: "" # vide = appVersion du chart +ingress: + enabled: true + className: nginx # ou traefik, cilium… + host: devforge.example.com + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + tls: + enabled: true +persistence: + size: 10Gi + storageClass: "" # vide = classe par défaut +postgresql: + enabled: true # Postgres intégré ; false + external.existingSecret pour une base externe +apps: + namespace: devforge-apps + podSecurity: restricted # « baseline » si tes images tournent en root + ingressClass: nginx + clusterIssuer: letsencrypt-prod + imagePullSecret: ghcr-pull +YAML + +helm lint deploy/helm/devforge -f my-values.yaml +helm install devforge deploy/helm/devforge -n devforge -f my-values.yaml +kubectl -n devforge rollout status deploy/devforge +kubectl -n devforge get pods,pvc,ingress +``` + +Sans Ingress : `kubectl -n devforge port-forward svc/devforge 8000:80` puis +http://127.0.0.1:8000. + +Ouvrir l'interface → [[Premier démarrage|Premier-demarrage]] (création du compte admin), puis +**Compte → Tokens** pour créer un jeton API. + +Ce que crée le chart : + +| Ressource | Rôle | +|-----------|------| +| `Deployment devforge` (1 réplica, `Recreate`) | serveur, uid 1000 non-root, probes sur `/api/v1/health` | +| `PVC devforge-data` | `/data` (conservé au `helm uninstall`) | +| `StatefulSet devforge-postgres` + Secret | base du control plane (mot de passe aléatoire conservé aux upgrades) | +| `ServiceAccount` + `Role/RoleBinding` | droits **limités au namespace des apps** : deployments, services, secrets, ingresses, lecture pods / logs / events | +| `Namespace devforge-apps` | labels PodSecurity, conservé au `uninstall` | +| `Service` + `Ingress` | accès à l'interface | + +Base externe : créer un Secret avec la clé `database-url` (`postgres://user:pass@host:5432/db`) +puis `--set postgresql.enabled=false --set postgresql.external.existingSecret=`. + +Mise à jour : `helm upgrade devforge deploy/helm/devforge -n devforge -f my-values.yaml --set image.tag=`. + +## 6. Première app + +Avec le jeton API d'un admin d'instance : + +```bash +export DF=https://devforge.example.com DF_TOKEN=df_xxx + +# État du backend (namespace, version de l'API Kubernetes) +curl -s -H "Authorization: Bearer $DF_TOKEN" $DF/api/v1/kube/info + +# Déployer (attend la fin du rollout, 5 min max) +curl -s -X POST "$DF/api/v1/kube/apps?wait=true&timeout=300" \ + -H "Authorization: Bearer $DF_TOKEN" -H "Content-Type: application/json" -d '{ + "name": "hello", + "image": "nginxinc/nginx-unprivileged:1.27", + "port": 8080, + "replicas": 2, + "host": "hello.example.com", + "tls": {}, + "env": { "MODE": "prod" }, + "secrets": { "API_KEY": "change-me" }, + "health": { "path": "/" }, + "resources": { "memory": "256m", "cpus": "0.5" } + }' + +curl -s -H "Authorization: Bearer $DF_TOKEN" $DF/api/v1/kube/apps/hello # statut +curl -s -H "Authorization: Bearer $DF_TOKEN" "$DF/api/v1/kube/apps/hello/logs?tail=200" +curl -s -X POST -H "Authorization: Bearer $DF_TOKEN" $DF/api/v1/kube/apps/hello/restart +curl -s -X POST -H "Authorization: Bearer $DF_TOKEN" $DF/api/v1/kube/apps/hello/stop +curl -s -X POST -H "Authorization: Bearer $DF_TOKEN" $DF/api/v1/kube/apps/hello/start +curl -s -X DELETE -H "Authorization: Bearer $DF_TOKEN" $DF/api/v1/kube/apps/hello +``` + +Prévisualiser les manifests sans rien appliquer (fonctionne aussi en mode Docker) : +`POST /api/v1/kube/render` avec le même corps (secrets affichés `***`). + +Champs : `name` (DNS, ≤ 40 car.), `image`, `port` (8080), `replicas` (1, 0–20), `host`, +`tls` (`secret_name`, `cluster_issuer`), `env`, `secrets`, `health` (`path`, +`initial_delay_seconds`, `period_seconds`, `failure_threshold`), `resources` (`memory` +`512m`/`1g`, `cpus` `0.5`), `image_pull_secret`, `ingress_class`, `run_as_non_root` (true), +`run_as_user`. + +Redéployer = refaire le `POST` avec une nouvelle image : rolling update sans coupure +(readiness). Un changement d'`env`/`secrets` redémarre aussi les pods (hash de config). + +### Depuis GitHub Actions + +Exemple complet (build → push GHCR → `POST /kube/apps?wait=true`) : +[`deploy/kubernetes/examples/github-actions-build-push.yml`](https://github.com/bobdivx/devforge/blob/main/deploy/kubernetes/examples/github-actions-build-push.yml). +À copier dans le dépôt de l'app (`.github/workflows/deploy.yml`) avec les secrets +`DEVFORGE_URL` et `DEVFORGE_TOKEN`. L'image est taggée `sha-` (immuable). + +## Limites de la phase 1 + +- **Pas de build dans le cluster** : l'image doit exister sur un registre. +- **API seulement** : les apps Kubernetes ne sont pas encore reliées aux projets DevForge ni à + l'interface (pages Deployments, Domains, Env du projet = mode Docker). +- Les fonctions Docker (atelier preview, Traefik géré, runners Docker, GC, auto-update, + backups de volumes Docker) ne s'appliquent pas aux apps Kubernetes. +- Un seul namespace d'apps ; pas de volumes persistants pour les apps ; pas d'HPA. +- `restricted` impose des images **non-root** (ex. `nginxinc/nginx-unprivileged` plutôt + que `nginx`) ; sinon `apps.podSecurity: baseline` et `"run_as_non_root": false`. +- DevForge lui-même : 1 réplica (PVC `ReadWriteOnce`). +- Testé contre un vrai kube-apiserver 1.31 (apply, prune, RBAC, PodSecurity) ; **pas encore + validé sur un cluster Talos réel** (exécution des pods, Ingress, TLS). + +## Feuille de route + +**Phase 2** +- Build dans le cluster avec **Kaniko** (Job éphémère : clone Git → build → push registre), + déclenché par l'auto-deploy GitHub. +- Intégration projets / UI : un projet choisit son backend (Docker ou Kubernetes) ; pages + Deployments, Env, Domains et logs branchées sur le backend Kubernetes. +- Adaptateur Docker du trait `ImageRuntime` (même API pour les deux backends). +- Volumes persistants d'apps (PVC), CronJobs. + +**Phase 3** +- Multi-namespaces / multi-clusters, HPA, NetworkPolicies par app. +- Promotion d'images entre environnements (crane), canary. +- Opérateur / CRD `DevForgeApp`, haute disponibilité du control plane. + +## Dépannage + +| Symptôme | Piste | +|----------|-------| +| `409` sur `/kube/*` | `DEVFORGE_RUNTIME` n'est pas `kubernetes` (mode Docker) | +| `403` Kubernetes | RBAC : namespace des apps ≠ `apps.namespace` du chart | +| `ImagePullBackOff` | `imagePullSecret` absent du namespace des apps, ou nom d'image en majuscules (GHCR) | +| Pod refusé « violates PodSecurity » | image root → `apps.podSecurity: baseline` + `run_as_non_root: false` | +| PVC `Pending` | pas de StorageClass par défaut (§ 2) | +| Ingress sans adresse | pas d'ingress controller ou pas de LoadBalancer (§ 3) | + +Voir aussi : [[Installation]] · [[Architecture]] · [[FAQ]] diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index 68e9b308b8..3b68cdc097 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -5,6 +5,7 @@ - [[USB / appliance|USB]] - [[Premier démarrage|Premier-demarrage]] - [[Cluster]] +- [[Kubernetes]] **Produit** - [[Interface]] From d01ad8b615bd76d4c040669cfb13ca0b59697907 Mon Sep 17 00:00:00 2001 From: Mathieu JESER <6737167+bobdivx@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:25:03 +0100 Subject: [PATCH 2/4] =?UTF-8?q?test(web):=20timeout=20du=20test=20de=20col?= =?UTF-8?q?oration=20Markdown=20align=C3=A9=20sur=20son=20waitFor=20(rouge?= =?UTF-8?q?=20sur=20main)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/lib/markdown-rich.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/markdown-rich.test.tsx b/apps/web/src/lib/markdown-rich.test.tsx index 9d06cb7b64..4871a6fb54 100644 --- a/apps/web/src/lib/markdown-rich.test.tsx +++ b/apps/web/src/lib/markdown-rich.test.tsx @@ -68,7 +68,8 @@ describe('Markdown riche (P5-01)', () => { const el = container.querySelector('code')!; expect(el.textContent).toBe(code); expect(el.querySelectorAll('span[style]').length).toBeGreaterThan(3); - }); + // Coloration chargée en lazy : waitFor attend jusqu'à 10 s, le test doit aller au-delà. + }, 15000); it('sanitizeSvg retire scripts et gestionnaires', () => { const out = sanitizeSvg(''); From 95f557a7305ecd7c75068190cee6f71083a6251f Mon Sep 17 00:00:00 2001 From: Mathieu JESER <6737167+bobdivx@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:28:09 +0100 Subject: [PATCH 3/4] =?UTF-8?q?Revert=20"test(web):=20timeout=20du=20test?= =?UTF-8?q?=20de=20coloration=20Markdown=20align=C3=A9=20sur=20son=20waitF?= =?UTF-8?q?or=20(rouge=20sur=20main)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d01ad8b615bd76d4c040669cfb13ca0b59697907. --- apps/web/src/lib/markdown-rich.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/lib/markdown-rich.test.tsx b/apps/web/src/lib/markdown-rich.test.tsx index 4871a6fb54..9d06cb7b64 100644 --- a/apps/web/src/lib/markdown-rich.test.tsx +++ b/apps/web/src/lib/markdown-rich.test.tsx @@ -68,8 +68,7 @@ describe('Markdown riche (P5-01)', () => { const el = container.querySelector('code')!; expect(el.textContent).toBe(code); expect(el.querySelectorAll('span[style]').length).toBeGreaterThan(3); - // Coloration chargée en lazy : waitFor attend jusqu'à 10 s, le test doit aller au-delà. - }, 15000); + }); it('sanitizeSvg retire scripts et gestionnaires', () => { const out = sanitizeSvg(''); From 9557b30b529bcb5bd3171d61a931a4dbddd0df36 Mon Sep 17 00:00:00 2001 From: Mathieu JESER <6737167+bobdivx@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:37:40 +0100 Subject: [PATCH 4/4] chore(helm): appVersion suit le bump de release ; lockfile devforge-kube 2.0.279 --- .github/workflows/release.yml | 4 +++- Cargo.lock | 2 +- deploy/helm/devforge/Chart.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5aacf8f77c..0f0ea20e08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,8 @@ jobs: local old="$1" new="$2" sed -i "s/^version = \"${old}\"/version = \"${new}\"/" Cargo.toml sed -i "0,/\"version\": \"${old}\"/s//\"version\": \"${new}\"/" apps/web/package.json + # Chart Helm : appVersion = version de l'image DevForge par défaut. + sed -i "s/^appVersion: \"${old}\"/appVersion: \"${new}\"/" deploy/helm/devforge/Chart.yaml # Uniquement les crates du workspace. Un `sed` global réécrit aussi # les dépendances qui ont le même numéro (ex. syn 2.0.119). awk -v old="$old" -v new="$new" ' @@ -99,7 +101,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock apps/web/package.json + git add Cargo.toml Cargo.lock apps/web/package.json deploy/helm/devforge/Chart.yaml git commit -m "chore: bump version ${OLD} → ${VERSION}" git push origin "HEAD:${GITHUB_REF_NAME}" else diff --git a/Cargo.lock b/Cargo.lock index c648d756ed..d88689d830 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -778,7 +778,7 @@ dependencies = [ [[package]] name = "devforge-kube" -version = "2.0.278" +version = "2.0.279" dependencies = [ "async-trait", "chrono", diff --git a/deploy/helm/devforge/Chart.yaml b/deploy/helm/devforge/Chart.yaml index 568b0167d8..04163b3d2b 100644 --- a/deploy/helm/devforge/Chart.yaml +++ b/deploy/helm/devforge/Chart.yaml @@ -3,7 +3,7 @@ name: devforge description: DevForge (PaaS self-hosted) sur Kubernetes — backend runtime Kubernetes, phase 1. type: application version: 0.1.0 -appVersion: "2.0.278" +appVersion: "2.0.279" home: https://github.com/bobdivx/devforge sources: - https://github.com/bobdivx/devforge