From 10d15d57f9325d0c589a2992e407a12040fc6b0f Mon Sep 17 00:00:00 2001 From: Mathieu JESER <6737167+bobdivx@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:42:59 +0100 Subject: [PATCH] =?UTF-8?q?feat(kube):=20d=C3=A9fauts=20Kubernetes=20(name?= =?UTF-8?q?space=20par=20projet,=20Traefik,=20cloudflared)=20+=20correctif?= =?UTF-8?q?s=20du=20test=20r=C3=A9el=20k3s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choix par défaut : GHCR + imagePullSecret, Traefik, local-path (Longhorn documenté en multi-nœud), tunnel Cloudflare, un namespace par projet. - kube : AppSpec.project / AppRef ; namespace `devforge-` créé à la demande (labels PodSecurity, refus d'un namespace existant non géré) ; liste multi-namespaces filtrable par projet ; IngressClass `traefik` par défaut (`none` pour la classe du cluster) ; secret de pull recopié dans les namespaces de projet ; mode `single` conservé. - routes /api/v1/kube/* : `?project=` ; /info expose namespace_mode, préfixe, PodSecurity. - chart : apps.namespaceMode/namespacePrefix, ClusterRole en perProject (Role en single), cloudflared optionnel (Secret token / existingSecret), Traefik par défaut ; deploy/kubernetes/traefik-values.yaml. - docs : wiki/Kubernetes.md et deploy/kubernetes/README.md (choix et comment les changer). Bugs trouvés au test réel (k3s 1.31, Traefik 3.7, local-path) : - chart : init `wait-postgres` bloqué indéfiniment (pg_isready sans -U sous uid 1000 → « no attempt ») ; - rolling update avec coupures (502 / timeouts) : preStop sleep 5 s + grace 35 s ; - serveur : snapshot de bascule pg_dump via Docker lancé en mode Kubernetes (erreur /30 s) ; - valeurs Traefik : `logs` refusé par le schéma, type de Service sous `service.spec`. --- apps/server/src/kube_routes.rs | 77 ++++- apps/server/src/main.rs | 11 +- crates/kube/src/client.rs | 7 + crates/kube/src/config.rs | 111 +++++++- crates/kube/src/lib.rs | 4 +- crates/kube/src/manifests.rs | 70 ++++- crates/kube/src/runtime.rs | 264 ++++++++++++------ crates/kube/src/spec.rs | 51 ++++ crates/kube/src/status.rs | 10 + crates/kube/tests/live_cluster.rs | 44 ++- crates/kube/tests/snapshots/full.json | 10 +- crates/kube/tests/snapshots/minimal.json | 10 +- deploy/helm/devforge/templates/_helpers.tpl | 9 + .../helm/devforge/templates/cloudflared.yaml | 85 ++++++ .../helm/devforge/templates/deployment.yaml | 14 +- deploy/helm/devforge/templates/rbac.yaml | 58 +++- deploy/helm/devforge/values.yaml | 53 +++- deploy/kubernetes/README.md | 29 +- deploy/kubernetes/traefik-values.yaml | 32 +++ wiki/FAQ.md | 2 +- wiki/Kubernetes.md | 223 ++++++++++----- 21 files changed, 939 insertions(+), 235 deletions(-) create mode 100644 deploy/helm/devforge/templates/cloudflared.yaml create mode 100644 deploy/kubernetes/traefik-values.yaml diff --git a/apps/server/src/kube_routes.rs b/apps/server/src/kube_routes.rs index ef8012a85..0ac844cb1 100644 --- a/apps/server/src/kube_routes.rs +++ b/apps/server/src/kube_routes.rs @@ -13,7 +13,9 @@ use axum::{ routing::{get, post}, Router, }; -use devforge_kube::{runtime::ImageRuntime, AppSpec, KubeDefaults, KubeError, KubeRuntime}; +use devforge_kube::{ + runtime::ImageRuntime, spec::AppRef, AppSpec, KubeDefaults, KubeError, KubeRuntime, +}; use devforge_shared::runtime_backend::RuntimeBackend; use serde::Deserialize; use serde_json::{json, Value}; @@ -87,6 +89,9 @@ async fn info(State(state): State, headers: HeaderMap) -> Result, headers: HeaderMap) -> Result, ApiError> { +/// `?project=` : app d'un projet (namespace `` en mode per-project). +#[derive(Deserialize, Default)] +struct ProjectQuery { + project: Option, +} + +fn app_ref(name: String, q: &ProjectQuery) -> AppRef { + AppRef::new(name, q.project.as_deref()) +} + +async fn list( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> 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() }))) + let project = q + .project + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()); + let apps = rt.list(project).await.map_err(kube_err)?; + Ok(Json(json!({ + "data": apps, + "namespace_mode": rt.defaults().namespace_mode, + "namespace": rt.defaults().namespace_for(project), + }))) } #[derive(Deserialize, Default)] @@ -135,11 +163,14 @@ async fn deploy( ) -> Result, ApiError> { require_admin(&state, &headers).await?; let rt = runtime()?; + let mut spec = spec; + spec.normalize() + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))))?; 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)) + .wait_rollout(&spec.app_ref(), Duration::from_secs(secs)) .await .map_err(kube_err)?; } @@ -150,15 +181,20 @@ async fn status( State(state): State, headers: HeaderMap, Path(name): Path, + Query(pq): Query, ) -> Result, ApiError> { require_admin(&state, &headers).await?; - let st = runtime()?.status(&name).await.map_err(kube_err)?; + let st = runtime()? + .status(&app_ref(name, &pq)) + .await + .map_err(kube_err)?; Ok(Json(json!({ "data": st }))) } #[derive(Deserialize, Default)] struct LogsQuery { tail: Option, + project: Option, } async fn logs( @@ -169,7 +205,10 @@ async fn logs( ) -> Result, ApiError> { require_admin(&state, &headers).await?; let out = runtime()? - .logs(&name, q.tail.unwrap_or(200)) + .logs( + &AppRef::new(name, q.project.as_deref()), + q.tail.unwrap_or(200), + ) .await .map_err(kube_err)?; Ok(Json(json!({ "data": out }))) @@ -179,9 +218,13 @@ async fn stop( State(state): State, headers: HeaderMap, Path(name): Path, + Query(pq): Query, ) -> Result, ApiError> { require_admin(&state, &headers).await?; - let st = runtime()?.stop(&name).await.map_err(kube_err)?; + let st = runtime()? + .stop(&app_ref(name, &pq)) + .await + .map_err(kube_err)?; Ok(Json(json!({ "ok": true, "data": st }))) } @@ -189,9 +232,13 @@ async fn start( State(state): State, headers: HeaderMap, Path(name): Path, + Query(pq): Query, ) -> Result, ApiError> { require_admin(&state, &headers).await?; - let st = runtime()?.start(&name).await.map_err(kube_err)?; + let st = runtime()? + .start(&app_ref(name, &pq)) + .await + .map_err(kube_err)?; Ok(Json(json!({ "ok": true, "data": st }))) } @@ -199,9 +246,13 @@ async fn restart( State(state): State, headers: HeaderMap, Path(name): Path, + Query(pq): Query, ) -> Result, ApiError> { require_admin(&state, &headers).await?; - let st = runtime()?.restart(&name).await.map_err(kube_err)?; + let st = runtime()? + .restart(&app_ref(name, &pq)) + .await + .map_err(kube_err)?; Ok(Json(json!({ "ok": true, "data": st }))) } @@ -209,8 +260,12 @@ async fn remove( State(state): State, headers: HeaderMap, Path(name): Path, + Query(pq): Query, ) -> Result, ApiError> { require_admin(&state, &headers).await?; - let removed = runtime()?.remove(&name).await.map_err(kube_err)?; + let removed = runtime()? + .remove(&app_ref(name, &pq)) + .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 bba50f062..d4ad25541 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -225,9 +225,16 @@ async fn main() -> Result<(), Box> { return Ok(()); } + // 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(); + worker::maybe_reclaim_preferred(&state).await; worker::spawn_fence_watch(state.clone()); - cluster_routes::spawn_snapshot_loop(state.clone()); + // Snapshot de bascule entre nœuds DevForge (pg_dump via Docker) : sans objet sous + // Kubernetes (Postgres du chart ou externe, sauvegardé à part) — il échouait toutes les 30 s. + if !kube_runtime { + cluster_routes::spawn_snapshot_loop(state.clone()); + } cluster_routes::spawn_evacuate_watch(state.clone()); project_pg::spawn_standby_loop(state.clone()); @@ -239,8 +246,6 @@ async fn main() -> Result<(), Box> { acme_email_set = acme.is_some(), "email ACME du proxy résolu" ); - // 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" diff --git a/crates/kube/src/client.rs b/crates/kube/src/client.rs index 849bb6a0e..53eade150 100644 --- a/crates/kube/src/client.rs +++ b/crates/kube/src/client.rs @@ -231,6 +231,13 @@ fn api_error(status: StatusCode, body: &str) -> KubeError { /// Chemins REST des ressources gérées. pub mod paths { + pub fn namespace(name: &str) -> String { + format!("/api/v1/namespaces/{name}") + } + /// Deployments de tous les namespaces (mode `per-project`). + pub fn all_deployments() -> String { + "/apis/apps/v1/deployments".into() + } pub fn deployments(ns: &str) -> String { format!("/apis/apps/v1/namespaces/{ns}/deployments") } diff --git a/crates/kube/src/config.rs b/crates/kube/src/config.rs index 9a20e6135..849e111c6 100644 --- a/crates/kube/src/config.rs +++ b/crates/kube/src/config.rs @@ -4,26 +4,75 @@ //! `/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_NAMESPACE_MODE` : `per-project` (défaut : un namespace `` +//! par projet, créé à la demande) ou `single` (toutes les apps dans `DEVFORGE_KUBE_NAMESPACE`) +//! - `DEVFORGE_KUBE_NAMESPACE_PREFIX` : préfixe des namespaces de projet (défaut `devforge-`) +//! - `DEVFORGE_KUBE_POD_SECURITY` : niveau PodSecurity posé sur les namespaces créés +//! (`restricted` par défaut, `baseline`, `privileged`) +//! - `DEVFORGE_KUBE_NAMESPACE` : namespace des apps sans projet / mode `single` +//! (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 +//! - `DEVFORGE_KUBE_INGRESS_CLASS` (défaut `traefik`, `none` → classe par défaut du cluster), +//! `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"; +pub const DEFAULT_NAMESPACE_PREFIX: &str = "devforge-"; +pub const DEFAULT_INGRESS_CLASS: &str = "traefik"; +pub const DEFAULT_POD_SECURITY: &str = "restricted"; + +/// Où vivent les apps. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum NamespaceMode { + /// Un namespace par projet (``), créé à la demande. Défaut. + #[default] + PerProject, + /// Toutes les apps dans un namespace unique (RBAC namespacé, sans droits cluster). + Single, +} + +impl NamespaceMode { + pub fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() { + "per-project" | "perproject" | "project" => Some(Self::PerProject), + "single" => Some(Self::Single), + _ => None, + } + } +} /// Valeurs appliquées quand la spec de l'app ne les précise pas. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct KubeDefaults { + /// Namespace des apps sans projet (et de toutes les apps en mode `single`). pub namespace: String, + pub namespace_mode: NamespaceMode, + pub namespace_prefix: String, + /// Niveau PodSecurity des namespaces créés par DevForge. + pub pod_security: String, pub ingress_class: Option, pub image_pull_secret: Option, pub cluster_issuer: Option, } +impl Default for KubeDefaults { + fn default() -> Self { + Self { + namespace: DEFAULT_NAMESPACE.into(), + namespace_mode: NamespaceMode::default(), + namespace_prefix: DEFAULT_NAMESPACE_PREFIX.into(), + pod_security: DEFAULT_POD_SECURITY.into(), + ingress_class: Some(DEFAULT_INGRESS_CLASS.into()), + image_pull_secret: None, + cluster_issuer: None, + } + } +} + impl KubeDefaults { pub fn new(namespace: impl Into) -> Self { Self { @@ -33,15 +82,48 @@ impl KubeDefaults { } pub fn from_env() -> Self { + let ingress_class = match env_opt("DEVFORGE_KUBE_INGRESS_CLASS") { + None => Some(DEFAULT_INGRESS_CLASS.to_string()), + Some(v) if v.eq_ignore_ascii_case("none") || v == "-" => None, + Some(v) => Some(v), + }; + let pod_security = env_opt("DEVFORGE_KUBE_POD_SECURITY") + .map(|v| v.to_ascii_lowercase()) + .filter(|v| matches!(v.as_str(), "restricted" | "baseline" | "privileged")) + .unwrap_or_else(|| DEFAULT_POD_SECURITY.into()); 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"), + namespace_mode: env_opt("DEVFORGE_KUBE_NAMESPACE_MODE") + .and_then(|v| NamespaceMode::parse(&v)) + .unwrap_or_default(), + namespace_prefix: env_opt("DEVFORGE_KUBE_NAMESPACE_PREFIX") + .filter(|p| valid_prefix(p)) + .unwrap_or_else(|| DEFAULT_NAMESPACE_PREFIX.into()), + pod_security, + ingress_class, image_pull_secret: env_opt("DEVFORGE_KUBE_IMAGE_PULL_SECRET"), cluster_issuer: env_opt("DEVFORGE_KUBE_CLUSTER_ISSUER"), } } + + /// Namespace d'une app. `project` doit déjà être validé (label DNS). + pub fn namespace_for(&self, project: Option<&str>) -> String { + match (self.namespace_mode, project) { + (NamespaceMode::PerProject, Some(p)) => format!("{}{p}", self.namespace_prefix), + _ => self.namespace.clone(), + } + } +} + +/// Préfixe : minuscules, chiffres, '-', 1–20 caractères, commence par une lettre. +fn valid_prefix(p: &str) -> bool { + !p.is_empty() + && p.len() <= 20 + && p.starts_with(|c: char| c.is_ascii_lowercase()) + && p.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } /// Connexion à l'API server. Le jeton est relu à chaque requête (jetons projetés tournants) @@ -140,6 +222,25 @@ fn read_trimmed(p: &std::path::Path) -> Option { mod tests { use super::*; + #[test] + fn namespace_per_project_and_single() { + let d = KubeDefaults::default(); + assert_eq!(d.namespace_for(Some("blog")), "devforge-blog"); + assert_eq!(d.namespace_for(None), "devforge-apps"); + assert_eq!(d.ingress_class.as_deref(), Some("traefik")); + let single = KubeDefaults { + namespace_mode: NamespaceMode::Single, + ..KubeDefaults::new("apps") + }; + assert_eq!(single.namespace_for(Some("blog")), "apps"); + assert_eq!( + NamespaceMode::parse("per_project"), + Some(NamespaceMode::PerProject) + ); + assert_eq!(NamespaceMode::parse("SINGLE"), Some(NamespaceMode::Single)); + assert!(valid_prefix("df-") && !valid_prefix("-x") && !valid_prefix("Df")); + } + #[test] fn debug_never_prints_ca_or_token() { let cfg = KubeConfig { diff --git a/crates/kube/src/lib.rs b/crates/kube/src/lib.rs index c6ab5c41d..eda860324 100644 --- a/crates/kube/src/lib.rs +++ b/crates/kube/src/lib.rs @@ -15,10 +15,10 @@ pub mod spec; pub mod status; pub use client::{KubeClient, KubeError}; -pub use config::{KubeConfig, KubeDefaults}; +pub use config::{KubeConfig, KubeDefaults, NamespaceMode}; pub use manifests::{render, RenderedManifests}; pub use runtime::{ImageRuntime, KubeRuntime}; -pub use spec::{AppSpec, HealthSpec, ResourceSpec, TlsSpec}; +pub use spec::{AppRef, AppSpec, HealthSpec, ResourceSpec, TlsSpec}; pub use status::{AppStatus, PodStatus}; /// Gestionnaire de champs (server-side apply) et valeur du label `managed-by`. diff --git a/crates/kube/src/manifests.rs b/crates/kube/src/manifests.rs index 3676f0500..a166dd350 100644 --- a/crates/kube/src/manifests.rs +++ b/crates/kube/src/manifests.rs @@ -14,6 +14,35 @@ pub const ANNOTATION_CONFIG_HASH: &str = "devforge.io/config-hash"; 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"; +/// Slug du projet propriétaire d'un namespace créé par DevForge. +pub const LABEL_PROJECT_NAME: &str = "devforge.io/project-name"; + +/// Délai `preStop` avant l'arrêt d'un pod (retrait de l'endpoint côté ingress). +pub const PRESTOP_SLEEP_SECONDS: u64 = 5; + +/// Namespace de projet créé à la demande (mode `per-project`), avec labels PodSecurity. +pub fn namespace(name: &str, project: Option<&str>, pod_security: &str) -> Value { + let mut labels = Map::new(); + labels.insert(LABEL_MANAGED_BY.into(), json!(FIELD_MANAGER)); + if let Some(p) = project { + labels.insert(LABEL_PROJECT_NAME.into(), json!(p)); + } + for mode in ["enforce", "audit", "warn"] { + labels.insert( + format!("pod-security.kubernetes.io/{mode}"), + json!(pod_security), + ); + } + labels.insert( + "pod-security.kubernetes.io/enforce-version".into(), + json!("latest"), + ); + json!({ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": { "name": name, "labels": labels } + }) +} /// Manifests d'une app. `ingress` / `secret` sont `None` quand non requis /// (ils sont alors supprimés du cluster au déploiement). @@ -72,6 +101,9 @@ pub fn labels(spec: &AppSpec) -> Map { if let Some(p) = &spec.project_uuid { m.insert(LABEL_PROJECT.into(), json!(p)); } + if let Some(p) = &spec.project { + m.insert(LABEL_PROJECT_NAME.into(), json!(p)); + } m } @@ -177,6 +209,10 @@ pub fn deployment(spec: &AppSpec, d: &KubeDefaults) -> Value { "ports": [{ "name": "http", "containerPort": spec.port, "protocol": "TCP" }], "readinessProbe": readiness, "livenessProbe": liveness, + // Rolling update sans coupure : le pod sortant continue de servir le temps que + // l'ingress controller retire son endpoint (sinon 502 / timeouts constatés sur k3s + + // Traefik). Action `sleep` native (Kubernetes ≥ 1.30) : marche sans shell dans l'image. + "lifecycle": { "preStop": { "sleep": { "seconds": PRESTOP_SLEEP_SECONDS } } }, "securityContext": { "allowPrivilegeEscalation": false, "capabilities": { "drop": ["ALL"] } @@ -207,6 +243,7 @@ pub fn deployment(spec: &AppSpec, d: &KubeDefaults) -> Value { let mut pod_spec = json!({ "automountServiceAccountToken": false, + "terminationGracePeriodSeconds": PRESTOP_SLEEP_SECONDS + 30, "enableServiceLinks": false, "securityContext": pod_sc, "containers": [container], @@ -227,7 +264,7 @@ pub fn deployment(spec: &AppSpec, d: &KubeDefaults) -> Value { "kind": "Deployment", "metadata": { "name": spec.name, - "namespace": d.namespace, + "namespace": d.namespace_for(spec.project.as_deref()), "labels": lbl, "annotations": { ANNOTATION_REPLICAS: spec.replicas.max(1).to_string() } }, @@ -255,7 +292,7 @@ pub fn service(spec: &AppSpec, d: &KubeDefaults) -> Value { json!({ "apiVersion": "v1", "kind": "Service", - "metadata": { "name": spec.name, "namespace": d.namespace, "labels": labels(spec) }, + "metadata": { "name": spec.name, "namespace": d.namespace_for(spec.project.as_deref()), "labels": labels(spec) }, "spec": { "type": "ClusterIP", "selector": selector(spec), @@ -299,7 +336,7 @@ pub fn ingress(spec: &AppSpec, d: &KubeDefaults) -> Option { json!([{ "hosts": [host], "secretName": secret }]), ); } - let mut meta = json!({ "name": spec.name, "namespace": d.namespace, "labels": labels(spec) }); + let mut meta = json!({ "name": spec.name, "namespace": d.namespace_for(spec.project.as_deref()), "labels": labels(spec) }); if !annotations.is_empty() { meta.as_object_mut() .expect("metadata object") @@ -321,7 +358,7 @@ pub fn secret(spec: &AppSpec, d: &KubeDefaults) -> Option { "apiVersion": "v1", "kind": "Secret", "type": "Opaque", - "metadata": { "name": spec.secret_name(), "namespace": d.namespace, "labels": labels(spec) }, + "metadata": { "name": spec.secret_name(), "namespace": d.namespace_for(spec.project.as_deref()), "labels": labels(spec) }, "stringData": spec.secrets })) } @@ -329,7 +366,7 @@ pub fn secret(spec: &AppSpec, d: &KubeDefaults) -> Option { /// Rend tous les manifests (la spec doit avoir été validée). pub fn render(spec: &AppSpec, d: &KubeDefaults) -> RenderedManifests { RenderedManifests { - namespace: d.namespace.clone(), + namespace: d.namespace_for(spec.project.as_deref()), deployment: deployment(spec, d), service: service(spec, d), ingress: ingress(spec, d), @@ -365,6 +402,29 @@ mod tests { assert!(!h2.contains("v2")); } + #[test] + fn project_app_goes_to_its_own_namespace_with_traefik() { + let mut spec = AppSpec::new("web", "nginx:1"); + spec.project = Some("shop".into()); + spec.host = Some("shop.example.com".into()); + let r = render(&spec, &KubeDefaults::new("devforge-apps")); + assert_eq!(r.namespace, "devforge-shop"); + assert_eq!(r.deployment["metadata"]["namespace"], "devforge-shop"); + assert_eq!( + r.deployment["metadata"]["labels"][LABEL_PROJECT_NAME], + "shop" + ); + let ing = r.ingress.expect("ingress"); + assert_eq!(ing["metadata"]["namespace"], "devforge-shop"); + assert_eq!(ing["spec"]["ingressClassName"], "traefik"); + let ns = namespace("devforge-shop", Some("shop"), "restricted"); + assert_eq!( + ns["metadata"]["labels"]["pod-security.kubernetes.io/enforce"], + "restricted" + ); + assert_eq!(ns["metadata"]["labels"][LABEL_MANAGED_BY], FIELD_MANAGER); + } + #[test] fn no_host_means_no_ingress_and_no_secret() { let r = render(&AppSpec::new("a", "i:1"), &KubeDefaults::new("ns")); diff --git a/crates/kube/src/runtime.rs b/crates/kube/src/runtime.rs index dba7ac5d9..1c74cc39b 100644 --- a/crates/kube/src/runtime.rs +++ b/crates/kube/src/runtime.rs @@ -7,9 +7,9 @@ use serde::Serialize; use serde_json::{json, Value}; use crate::client::{paths, KubeClient, KubeError}; -use crate::config::KubeDefaults; +use crate::config::{KubeDefaults, NamespaceMode}; use crate::manifests::{self, ANNOTATION_REPLICAS, ANNOTATION_RESTARTED_AT}; -use crate::spec::AppSpec; +use crate::spec::{AppRef, AppSpec}; use crate::status::{self, AppStatus}; use crate::{CONTAINER_NAME, FIELD_MANAGER, LABEL_APP, LABEL_MANAGED_BY}; @@ -45,13 +45,14 @@ pub struct AppLogs { 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>; + async fn status(&self, app: &AppRef) -> Result; + /// Toutes les apps gérées ; `project` filtre sur un projet. + async fn list(&self, project: Option<&str>) -> Result, KubeError>; + async fn logs(&self, app: &AppRef, tail: u32) -> Result; + async fn stop(&self, app: &AppRef) -> Result; + async fn start(&self, app: &AppRef) -> Result; + async fn restart(&self, app: &AppRef) -> Result; + async fn remove(&self, app: &AppRef) -> Result, KubeError>; } pub struct KubeRuntime { @@ -80,16 +81,10 @@ impl KubeRuntime { &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})"))) - } + /// Namespace d'une app (valide la référence). + pub fn namespace_of(&self, app: &AppRef) -> Result { + app.validate().map_err(KubeError::Invalid)?; + Ok(self.defaults.namespace_for(app.project.as_deref())) } /// Valide la spec puis rend les manifests (aucun appel réseau). @@ -99,8 +94,73 @@ impl KubeRuntime { 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?; + /// Crée le namespace du projet s'il manque (mode `per-project`). Un namespace existant + /// non créé par DevForge n'est utilisé que s'il s'agit du namespace par défaut. + async fn ensure_namespace(&self, ns: &str, project: Option<&str>) -> Result<(), KubeError> { + let path = paths::namespace(ns); + match self.client.get(&path).await { + Ok(Some(obj)) => { + if is_managed(&obj) || ns == self.defaults.namespace { + Ok(()) + } else { + Err(KubeError::Invalid(format!( + "le namespace {ns} existe mais n'est pas géré par DevForge (label {LABEL_MANAGED_BY})" + ))) + } + } + Ok(None) => { + let m = manifests::namespace(ns, project, &self.defaults.pod_security); + self.client.apply(&path, &m).await?; + tracing::info!(namespace = %ns, "namespace de projet créé"); + Ok(()) + } + // RBAC sans droits sur les namespaces (mode `single`) : on suppose qu'il existe. + Err(KubeError::Api { status: 403, .. }) if ns == self.defaults.namespace => Ok(()), + Err(e) => Err(e), + } + } + + /// Mode per-project : recopie le secret de pull par défaut (ex. GHCR) depuis le + /// namespace des apps vers celui du projet s'il n'y existe pas encore. Un secret + /// déjà présent (géré à la main ou par DevForge) n'est jamais écrasé. + async fn ensure_pull_secret(&self, ns: &str) -> Result<(), KubeError> { + let Some(name) = self.defaults.image_pull_secret.as_deref() else { + return Ok(()); + }; + if ns == self.defaults.namespace { + return Ok(()); + } + let target = paths::secret(ns, name); + if self.client.get(&target).await?.is_some() { + return Ok(()); + } + let Some(src) = self + .client + .get(&paths::secret(&self.defaults.namespace, name)) + .await? + else { + tracing::warn!(secret = %name, source = %self.defaults.namespace, + "secret de pull introuvable : à créer dans le namespace des apps"); + return Ok(()); + }; + let copy = json!({ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": name, + "namespace": ns, + "labels": { LABEL_MANAGED_BY: FIELD_MANAGER }, + }, + "type": src.get("type").cloned().unwrap_or(json!("kubernetes.io/dockerconfigjson")), + "data": src.get("data").cloned().unwrap_or(json!({})), + }); + self.client.apply(&target, ©).await?; + tracing::info!(secret = %name, namespace = %ns, "secret de pull recopié"); + Ok(()) + } + + async fn managed_deployment(&self, ns: &str, name: &str) -> Result, KubeError> { + let dep = self.client.get(&paths::deployment(ns, name)).await?; match dep { Some(d) if is_managed(&d) => Ok(Some(d)), Some(_) => Err(KubeError::Invalid(format!( @@ -110,10 +170,19 @@ impl KubeRuntime { } } - async fn pods(&self, name: &str) -> Result, KubeError> { + async fn require_deployment(&self, ns: &str, name: &str) -> Result { + self.managed_deployment(ns, name) + .await? + .ok_or_else(|| KubeError::Api { + status: 404, + message: format!("app {name} introuvable (namespace {ns})"), + }) + } + + async fn pods(&self, ns: &str, name: &str) -> Result, KubeError> { let path = format!( "{}?{}", - paths::pods(self.ns()), + paths::pods(ns), paths::label_selector(LABEL_APP, name) ); let list = self.client.get(&path).await?.unwrap_or(Value::Null); @@ -123,12 +192,12 @@ impl KubeRuntime { /// Attend la fin du rollout (comme `kubectl rollout status`), au plus `timeout`. pub async fn wait_rollout( &self, - name: &str, + app: &AppRef, timeout: Duration, ) -> Result { let deadline = tokio::time::Instant::now() + timeout; loop { - let st = self.status(name).await?; + let st = self.status(app).await?; if st.rollout_complete || st.phase == "degraded" || st.phase == "absent" { return Ok(st); } @@ -139,17 +208,13 @@ impl KubeRuntime { } } - 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"), - }); - } + async fn patch_deployment(&self, app: &AppRef, patch: Value) -> Result { + let ns = self.namespace_of(app)?; + self.require_deployment(&ns, &app.name).await?; self.client - .merge_patch(&paths::deployment(self.ns(), name), &patch) + .merge_patch(&paths::deployment(&ns, &app.name), &patch) .await?; - self.status(name).await + self.status(app).await } } @@ -179,10 +244,12 @@ impl ImageRuntime for KubeRuntime { async fn deploy(&self, spec: &AppSpec) -> Result { let mut spec = spec.clone(); spec.normalize().map_err(KubeError::Invalid)?; + let ns = self.defaults.namespace_for(spec.project.as_deref()); + self.ensure_namespace(&ns, spec.project.as_deref()).await?; + self.ensure_pull_secret(&ns).await?; // Refuse d'écraser une ressource posée à la main / par un autre outil. - let _ = self.managed_deployment(&spec.name).await?; + let _ = self.managed_deployment(&ns, &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) @@ -208,7 +275,7 @@ impl ImageRuntime for KubeRuntime { } } tracing::info!(app = %spec.name, namespace = %ns, image = %spec.image, "kube deploy appliqué"); - let status = self.status(&spec.name).await?; + let status = self.status(&spec.app_ref()).await?; Ok(DeployOutcome { name: spec.name.clone(), namespace: ns, @@ -218,24 +285,44 @@ impl ImageRuntime for KubeRuntime { }) } - async fn status(&self, name: &str) -> Result { - Self::check_name(name)?; - let dep = self.managed_deployment(name).await?; + async fn status(&self, app: &AppRef) -> Result { + let ns = self.namespace_of(app)?; + let dep = self.managed_deployment(&ns, &app.name).await?; let pods = if dep.is_some() { - self.pods(name).await? + self.pods(&ns, &app.name).await? } else { Vec::new() }; - Ok(status::app_status(name, self.ns(), dep.as_ref(), pods)) + let mut st = status::app_status(&app.name, &ns, dep.as_ref(), pods); + if st.project.is_none() { + st.project = app.project.clone(); + } + Ok(st) } - 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); + async fn list(&self, project: Option<&str>) -> Result, KubeError> { + crate::spec::validate_project(project).map_err(KubeError::Invalid)?; + let mut selector = paths::label_selector(LABEL_MANAGED_BY, FIELD_MANAGER); + if let Some(p) = project { + // `,` encodé : sélecteur ET. + selector.push_str("%2C"); + selector.push_str( + paths::label_selector(manifests::LABEL_PROJECT_NAME, p) + .trim_start_matches("labelSelector="), + ); + } + let base = match (self.defaults.namespace_mode, project) { + (NamespaceMode::Single, _) => paths::deployments(&self.defaults.namespace), + (NamespaceMode::PerProject, Some(p)) => { + paths::deployments(&self.defaults.namespace_for(Some(p))) + } + (NamespaceMode::PerProject, None) => paths::all_deployments(), + }; + let list = self + .client + .get(&format!("{base}?{selector}")) + .await? + .unwrap_or(Value::Null); let mut out = Vec::new(); for dep in list .get("items") @@ -243,33 +330,37 @@ impl ImageRuntime for KubeRuntime { .cloned() .unwrap_or_default() { - let name = dep - .pointer("/metadata/name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); + let meta = |k: &str| { + dep.pointer(&format!("/metadata/{k}")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + let (name, ns) = (meta("name"), meta("namespace")); + // Mode per-project : seuls les namespaces DevForge (préfixe ou défaut). + if self.defaults.namespace_mode == NamespaceMode::PerProject + && ns != self.defaults.namespace + && !ns.starts_with(&self.defaults.namespace_prefix) + { + continue; + } // 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.push(status::app_status(&name, &ns, Some(&dep), Vec::new())); } - out.sort_by(|a, b| a.name.cmp(&b.name)); + out.sort_by(|a, b| (&a.namespace, &a.name).cmp(&(&b.namespace, &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"), - }); - } + async fn logs(&self, app: &AppRef, tail: u32) -> Result { + let ns = self.namespace_of(app)?; + self.require_deployment(&ns, &app.name).await?; let tail = tail.clamp(1, MAX_LOG_TAIL); - let pods = self.pods(name).await?; + let pods = self.pods(&ns, &app.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 path = paths::pod_log(&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…). @@ -295,21 +386,16 @@ impl ImageRuntime for KubeRuntime { logs = logs[cut..].to_string(); } Ok(AppLogs { - name: name.into(), + name: app.name.clone(), 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"), - }); - }; + async fn stop(&self, app: &AppRef) -> Result { + let ns = self.namespace_of(app)?; + let dep = self.require_deployment(&ns, &app.name).await?; let current = dep .pointer("/spec/replicas") .and_then(Value::as_i64) @@ -320,7 +406,7 @@ impl ImageRuntime for KubeRuntime { status::remembered_replicas(&dep) }; self.patch_deployment( - name, + app, json!({ "metadata": { "annotations": { ANNOTATION_REPLICAS: remember.to_string() } }, "spec": { "replicas": 0 } @@ -329,25 +415,19 @@ impl ImageRuntime for KubeRuntime { .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"), - }); - }; + async fn start(&self, app: &AppRef) -> Result { + let ns = self.namespace_of(app)?; + let dep = self.require_deployment(&ns, &app.name).await?; let replicas = status::remembered_replicas(&dep); - self.patch_deployment(name, json!({ "spec": { "replicas": replicas } })) + self.patch_deployment(app, json!({ "spec": { "replicas": replicas } })) .await } - async fn restart(&self, name: &str) -> Result { - Self::check_name(name)?; + async fn restart(&self, app: &AppRef) -> Result { // 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, + app, json!({ "spec": { "template": { "metadata": { "annotations": { ANNOTATION_RESTARTED_AT: now }}}}}), @@ -355,9 +435,11 @@ impl ImageRuntime for KubeRuntime { .await } - async fn remove(&self, name: &str) -> Result, KubeError> { - Self::check_name(name)?; - let ns = self.ns().to_string(); + /// Supprime les ressources de l'app. Le namespace du projet est conservé (autres apps, + /// secrets de pull) : `kubectl delete ns` pour le retirer. + async fn remove(&self, app: &AppRef) -> Result, KubeError> { + let ns = self.namespace_of(app)?; + let name = app.name.as_str(); let mut removed = Vec::new(); let targets = [ ("Ingress", paths::ingress(&ns, name)), diff --git a/crates/kube/src/spec.rs b/crates/kube/src/spec.rs index 905946d36..de079ae2d 100644 --- a/crates/kube/src/spec.rs +++ b/crates/kube/src/spec.rs @@ -89,6 +89,46 @@ pub struct AppSpec { /// Lien optionnel vers un projet DevForge (label `devforge.io/project`). #[serde(default, skip_serializing_if = "Option::is_none")] pub project_uuid: Option, + /// Projet (slug DNS) : en mode `per-project`, l'app vit dans le namespace + /// `` (ex. `devforge-blog`). Absent → namespace par défaut. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, +} + +/// Référence d'une app existante : nom + projet (namespace). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppRef { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, +} + +impl AppRef { + pub fn new(name: impl Into, project: Option<&str>) -> Self { + Self { + name: name.into(), + project: project + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()), + } + } + + 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 ({})", self.name)); + } + validate_project(self.project.as_deref()) + } +} + +/// Slug de projet : label DNS, `MAX_NAME_LEN` caractères max (préfixe ≤ 20 → namespace ≤ 63). +pub fn validate_project(project: Option<&str>) -> Result<(), String> { + match project { + Some(p) if !is_dns_label(p) || p.len() > MAX_NAME_LEN => Err(format!( + "projet invalide ({p}) — minuscules, chiffres et '-', {MAX_NAME_LEN} caractères max" + )), + _ => Ok(()), + } } impl std::fmt::Debug for AppSpec { @@ -109,6 +149,7 @@ impl std::fmt::Debug for AppSpec { .field("run_as_non_root", &self.run_as_non_root) .field("run_as_user", &self.run_as_user) .field("project_uuid", &self.project_uuid) + .field("project", &self.project) .finish() } } @@ -132,9 +173,14 @@ impl AppSpec { run_as_non_root: true, run_as_user: None, project_uuid: None, + project: None, } } + pub fn app_ref(&self) -> AppRef { + AppRef::new(&self.name, self.project.as_deref()) + } + pub fn secret_name(&self) -> String { format!("{}-env", self.name) } @@ -156,6 +202,10 @@ impl AppSpec { trim_opt(&mut self.image_pull_secret); trim_opt(&mut self.ingress_class); trim_opt(&mut self.project_uuid); + trim_opt(&mut self.project); + if let Some(p) = self.project.as_mut() { + *p = p.to_ascii_lowercase(); + } if let Some(h) = self.host.as_mut() { *h = h.to_ascii_lowercase(); } @@ -181,6 +231,7 @@ impl AppSpec { )); } validate_image(&self.image)?; + validate_project(self.project.as_deref())?; if self.port == 0 { return Err("port invalide (1-65535)".into()); } diff --git a/crates/kube/src/status.rs b/crates/kube/src/status.rs index 0c2a7503f..794c2ae29 100644 --- a/crates/kube/src/status.rs +++ b/crates/kube/src/status.rs @@ -25,6 +25,9 @@ pub struct PodStatus { pub struct AppStatus { pub name: String, pub namespace: String, + /// Projet (label `devforge.io/project-name`), si l'app en a un. + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, /// `absent` | `stopped` | `progressing` | `running` | `degraded` pub phase: String, pub healthy: bool, @@ -129,6 +132,7 @@ pub fn app_status( return AppStatus { name: name.into(), namespace: namespace.into(), + project: None, phase: "absent".into(), healthy: false, rollout_complete: false, @@ -198,9 +202,15 @@ pub fn app_status( format!("rollout en cours : {updated}/{desired} à jour, {available} disponibles"), ) }; + let project = dep + .pointer("/metadata/labels") + .and_then(|l| l.get(crate::manifests::LABEL_PROJECT_NAME)) + .and_then(Value::as_str) + .map(String::from); AppStatus { name: name.into(), namespace: namespace.into(), + project, phase: phase.into(), healthy: phase == "running", rollout_complete, diff --git a/crates/kube/tests/live_cluster.rs b/crates/kube/tests/live_cluster.rs index 9dcfe007b..43c8fdd48 100644 --- a/crates/kube/tests/live_cluster.rs +++ b/crates/kube/tests/live_cluster.rs @@ -2,13 +2,15 @@ //! `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`. +//! `DEVFORGE_KUBE_IT_PROJECT=` : teste le mode namespace par projet +//! (`DEVFORGE_KUBE_NAMESPACE_MODE=per-project`, RBAC ClusterRole du chart). //! //! 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 devforge_kube::{AppRef, AppSpec, KubeError, KubeRuntime, TlsSpec}; use serde_json::json; fn enabled() -> bool { @@ -22,11 +24,14 @@ async fn live_deploy_lifecycle() { return; } let rt = KubeRuntime::from_env().expect("config kube"); - let ns = rt.namespace().to_string(); + let project = std::env::var("DEVFORGE_KUBE_IT_PROJECT").ok(); + let ns = rt.defaults().namespace_for(project.as_deref()); let name = "it-web"; - let _ = rt.remove(name).await; + let app = AppRef::new(name, project.as_deref()); + let _ = rt.remove(&app).await; let mut spec = AppSpec::new(name, "nginxinc/nginx-unprivileged:1.27"); + spec.project = project.clone(); spec.port = 8080; spec.replicas = 2; spec.host = Some("it-web.example.test".into()); @@ -63,6 +68,7 @@ async fn live_deploy_lifecycle() { // 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.project = project.clone(); v2.replicas = 2; let out = rt.deploy(&v2).await.expect("redeploy"); assert_eq!(out.pruned, vec!["Ingress/it-web", "Secret/it-web-env"]); @@ -77,19 +83,24 @@ async fn live_deploy_lifecycle() { "nginxinc/nginx-unprivileged:1.27-alpine" ); - let st = rt.stop(name).await.expect("stop"); + let st = rt.stop(&app).await.expect("stop"); assert_eq!(st.phase, "stopped"); - let st = rt.start(name).await.expect("start"); + let st = rt.start(&app).await.expect("start"); assert_eq!(st.replicas, 2); - rt.restart(name).await.expect("restart"); + rt.restart(&app).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"); + let logs = rt.logs(&app, 50).await.expect("logs"); assert_eq!(logs.name, name); - assert!(rt.list().await.unwrap().iter().any(|a| a.name == name)); + assert!(rt + .list(project.as_deref()) + .await + .unwrap() + .iter() + .any(|a| a.name == name && a.namespace == ns)); // Une ressource non gérée par DevForge n'est jamais écrasée. let foreign = json!({ @@ -104,18 +115,21 @@ async fn live_deploy_lifecycle() { 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(); + let mut f = AppSpec::new("it-foreign", "busybox:1"); + f.project = project.clone(); + let err = rt.deploy(&f).await.unwrap_err(); assert!(matches!(err, KubeError::Invalid(_)), "{err}"); - assert!(rt.remove("it-foreign").await.unwrap().is_empty()); + assert!(rt + .remove(&AppRef::new("it-foreign", project.as_deref())) + .await + .unwrap() + .is_empty()); let _ = c.delete(&fp).await; } - let removed = rt.remove(name).await.expect("remove"); + let removed = rt.remove(&app).await.expect("remove"); assert_eq!(removed, vec!["Service/it-web", "Deployment/it-web"]); - assert_eq!(rt.status(name).await.unwrap().phase, "absent"); + assert_eq!(rt.status(&app).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") { diff --git a/crates/kube/tests/snapshots/full.json b/crates/kube/tests/snapshots/full.json index bbaec3e67..5116892b5 100644 --- a/crates/kube/tests/snapshots/full.json +++ b/crates/kube/tests/snapshots/full.json @@ -89,6 +89,13 @@ ], "image": "ghcr.io/bobdivx/shop:sha-abc1234", "imagePullPolicy": "IfNotPresent", + "lifecycle": { + "preStop": { + "sleep": { + "seconds": 5 + } + } + }, "livenessProbe": { "failureThreshold": 3, "httpGet": { @@ -148,7 +155,8 @@ "seccompProfile": { "type": "RuntimeDefault" } - } + }, + "terminationGracePeriodSeconds": 35 } } } diff --git a/crates/kube/tests/snapshots/minimal.json b/crates/kube/tests/snapshots/minimal.json index 2c1f04bdf..e48824e8f 100644 --- a/crates/kube/tests/snapshots/minimal.json +++ b/crates/kube/tests/snapshots/minimal.json @@ -51,6 +51,13 @@ { "image": "ghcr.io/bobdivx/hello:1.0.0", "imagePullPolicy": "IfNotPresent", + "lifecycle": { + "preStop": { + "sleep": { + "seconds": 5 + } + } + }, "livenessProbe": { "failureThreshold": 3, "initialDelaySeconds": 30, @@ -93,7 +100,8 @@ "seccompProfile": { "type": "RuntimeDefault" } - } + }, + "terminationGracePeriodSeconds": 35 } } } diff --git a/deploy/helm/devforge/templates/_helpers.tpl b/deploy/helm/devforge/templates/_helpers.tpl index 703eae5f0..7b6249e0f 100644 --- a/deploy/helm/devforge/templates/_helpers.tpl +++ b/deploy/helm/devforge/templates/_helpers.tpl @@ -41,6 +41,15 @@ app.kubernetes.io/component: server {{- default .Release.Namespace .Values.apps.namespace }} {{- end }} +{{- /* « per-project » ou « single » (valeur normalisée pour le serveur). */}} +{{- define "devforge.namespaceMode" -}} +{{- $m := lower (toString .Values.apps.namespaceMode) | replace "_" "-" }} +{{- if or (eq $m "single") (eq $m "namespace") }}single +{{- else if or (eq $m "perproject") (eq $m "per-project") (eq $m "project") }}per-project +{{- else }}{{ fail (printf "apps.namespaceMode invalide : %s (perProject|single)" .Values.apps.namespaceMode) }} +{{- end }} +{{- end }} + {{- define "devforge.pgName" -}} {{- printf "%s-postgres" (include "devforge.fullname" .) | trunc 63 | trimSuffix "-" }} {{- end }} diff --git a/deploy/helm/devforge/templates/cloudflared.yaml b/deploy/helm/devforge/templates/cloudflared.yaml new file mode 100644 index 000000000..8baa2d32e --- /dev/null +++ b/deploy/helm/devforge/templates/cloudflared.yaml @@ -0,0 +1,85 @@ +{{- if .Values.cloudflared.enabled }} +{{- $c := .Values.cloudflared }} +{{- if not (or $c.token $c.existingSecret) }} +{{- fail "cloudflared.enabled=true : renseigner cloudflared.existingSecret (clé token) ou cloudflared.token" }} +{{- end }} +{{- $secret := default (printf "%s-cloudflared" (include "devforge.fullname" .)) $c.existingSecret }} +{{- if not $c.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secret }} + labels: + {{- include "devforge.labels" . | nindent 4 }} +type: Opaque +stringData: + token: {{ $c.token | quote }} +--- +{{- end }} +# Tunnel Cloudflare : les noms d'hôte publics se configurent côté Cloudflare et pointent +# vers le Service Traefik (ex. http://traefik.traefik.svc.cluster.local:80). +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "devforge.fullname" . }}-cloudflared + labels: + {{- include "devforge.labels" . | nindent 4 }} + app.kubernetes.io/component: cloudflared +spec: + replicas: {{ $c.replicas }} + selector: + # Pas de selectorLabels : le Service DevForge ne doit pas cibler ces pods. + matchLabels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: cloudflared + template: + metadata: + labels: + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: cloudflared + annotations: + checksum/token: {{ $c.token | sha256sum | trunc 16 | quote }} + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: cloudflared + image: "{{ $c.image.repository }}:{{ $c.image.tag }}" + args: ["tunnel", "--no-autoupdate", "--metrics", "0.0.0.0:2000", "run"] + env: + - name: TUNNEL_TOKEN + valueFrom: + secretKeyRef: + name: {{ $secret }} + key: token + ports: + - name: metrics + containerPort: 2000 + readinessProbe: + httpGet: + path: /ready + port: metrics + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /ready + port: metrics + initialDelaySeconds: 30 + periodSeconds: 20 + failureThreshold: 6 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + {{- with $c.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/devforge/templates/deployment.yaml b/deploy/helm/devforge/templates/deployment.yaml index abffb9d6d..9271b4b34 100644 --- a/deploy/helm/devforge/templates/deployment.yaml +++ b/deploy/helm/devforge/templates/deployment.yaml @@ -40,7 +40,9 @@ spec: 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"] + # -U obligatoire : l'uid 1000 n'existe pas dans l'image postgres, libpq ne peut + # pas deviner l'utilisateur et pg_isready répond « no attempt » indéfiniment. + command: ["sh", "-c", "until pg_isready -h {{ include "devforge.pgName" . }} -p 5432 -U {{ .Values.postgresql.user }} -d {{ .Values.postgresql.database }}; do echo 'attente postgres'; sleep 2; done"] {{- end }} containers: - name: devforge @@ -72,10 +74,14 @@ spec: value: kubernetes - name: DEVFORGE_KUBE_NAMESPACE value: {{ include "devforge.appsNamespace" . | quote }} - {{- with .Values.apps.ingressClass }} + - name: DEVFORGE_KUBE_NAMESPACE_MODE + value: {{ include "devforge.namespaceMode" . | quote }} + - name: DEVFORGE_KUBE_NAMESPACE_PREFIX + value: {{ .Values.apps.namespacePrefix | quote }} + - name: DEVFORGE_KUBE_POD_SECURITY + value: {{ .Values.apps.podSecurity | quote }} - name: DEVFORGE_KUBE_INGRESS_CLASS - value: {{ . | quote }} - {{- end }} + value: {{ default "none" .Values.apps.ingressClass | quote }} {{- with .Values.apps.clusterIssuer }} - name: DEVFORGE_KUBE_CLUSTER_ISSUER value: {{ . | quote }} diff --git a/deploy/helm/devforge/templates/rbac.yaml b/deploy/helm/devforge/templates/rbac.yaml index 408474859..b54cd5c32 100644 --- a/deploy/helm/devforge/templates/rbac.yaml +++ b/deploy/helm/devforge/templates/rbac.yaml @@ -1,13 +1,6 @@ -{{- /* 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: +{{- /* Règles communes : ressources des apps (Deployment, Service, Secret, Ingress) et lecture des pods/logs. */}} +{{- define "devforge.appRules" }} - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] @@ -26,6 +19,52 @@ rules: - apiGroups: [""] resources: ["events"] verbs: ["get", "list", "watch"] +{{- end }} +{{- if eq (include "devforge.namespaceMode" .) "per-project" }} +{{- /* + Namespace par projet : les namespaces sont créés à la demande, donc droits au niveau + cluster. Garde-fou côté code : DevForge n'agit que dans `` ou le + namespace par défaut, jamais dans un namespace existant non étiqueté + app.kubernetes.io/managed-by=devforge, et seulement sur des ressources qu'il a créées. + Pour un RBAC strictement namespacé : apps.namespaceMode=single. +*/}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "devforge.fullname" . }}-{{ .Release.Namespace }}-apps + labels: + {{- include "devforge.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "create", "patch"] + {{- include "devforge.appRules" . }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "devforge.fullname" . }}-{{ .Release.Namespace }}-apps + labels: + {{- include "devforge.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "devforge.fullname" . }}-{{ .Release.Namespace }}-apps +subjects: + - kind: ServiceAccount + name: {{ include "devforge.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- else }} +{{- /* Mode single : droits limités au namespace des apps, rien au niveau cluster. */}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "devforge.fullname" . }}-apps + namespace: {{ $ns }} + labels: + {{- include "devforge.labels" . | nindent 4 }} +rules: + {{- include "devforge.appRules" . }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding @@ -42,3 +81,4 @@ subjects: - kind: ServiceAccount name: {{ include "devforge.serviceAccountName" . }} namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/deploy/helm/devforge/values.yaml b/deploy/helm/devforge/values.yaml index 41f375d13..63e66996c 100644 --- a/deploy/helm/devforge/values.yaml +++ b/deploy/helm/devforge/values.yaml @@ -45,9 +45,12 @@ service: port: 80 ingress: + # Désactivé par défaut : l'accès public passe par le tunnel Cloudflare (cloudflared → + # Traefik). Activer pour exposer l'UI DevForge via Traefik (hôte ci-dessous). enabled: false - # Ex. traefik, nginx, cilium. Vide → IngressClass par défaut du cluster. - className: "" + # Traefik par défaut (voir deploy/kubernetes/traefik-values.yaml). Vide → classe par + # défaut du cluster. + className: traefik annotations: {} # cert-manager.io/cluster-issuer: letsencrypt-prod host: devforge.example.com @@ -59,7 +62,8 @@ ingress: 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). + # Vide → StorageClass par défaut du cluster : local-path (k3s, Talos + local-path-provisioner) + # en mono-nœud ; « longhorn » recommandé en multi-nœud (volumes répliqués). storageClass: "" accessMode: ReadWriteOnce size: 10Gi @@ -105,16 +109,43 @@ postgresql: # Backend Kubernetes : où et comment DevForge déploie les apps des utilisateurs. apps: - # Namespace des apps. Vide → même namespace que DevForge. + # perProject (défaut) : un namespace par projet, ``, créé à la + # demande avec les labels PodSecurity ci-dessous (RBAC : ClusterRole). Les apps sans + # projet vont dans `namespace`. + # single : tout dans `namespace` (RBAC : Role limité à ce namespace). + namespaceMode: perProject + namespacePrefix: devforge- + # Namespace par défaut des apps (apps sans projet, mode single, secret de pull source). + # 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. + # Créer ce 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…). + # Niveau PodSecurity des namespaces d'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. + # IngressClass des apps. Traefik par défaut ; « none » → classe par défaut du cluster. + ingressClass: traefik + # ClusterIssuer cert-manager quand une app demande TLS. Vide par défaut : avec le tunnel + # Cloudflare, le TLS public est terminé chez Cloudflare. clusterIssuer: "" - # Secret dockerconfigjson par défaut (registre privé, ex. GHCR) dans le namespace des apps. + # Secret dockerconfigjson pour tirer les images des apps (GHCR privé…), à créer dans + # `apps.namespace` ; DevForge le recopie dans chaque namespace de projet. + # kubectl -n devforge-apps create secret docker-registry ghcr-pull \ + # --docker-server=ghcr.io --docker-username= --docker-password= imagePullSecret: "" + +# Tunnel Cloudflare (exposition publique sans ouvrir de port) : un Deployment cloudflared. +# Dans le tableau de bord Cloudflare (Zero Trust → Tunnels), faire pointer chaque nom +# d'hôte public vers http://traefik.traefik.svc.cluster.local:80 (Traefik route ensuite +# selon l'en-tête Host des Ingress). +cloudflared: + enabled: false + image: + repository: cloudflare/cloudflared + tag: "2026.10.7" + replicas: 1 + # Jeton du tunnel (« cloudflared tunnel run --token … »). Préférer existingSecret. + token: "" + # Secret existant contenant la clé `token`. + existingSecret: "" + resources: {} diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md index b53a09e5d..92ab07a50 100644 --- a/deploy/kubernetes/README.md +++ b/deploy/kubernetes/README.md @@ -4,19 +4,42 @@ Tutoriel complet : wiki **[Kubernetes](../../wiki/Kubernetes.md)**. | Élément | Chemin | |---------|--------| -| Chart Helm de DevForge | [`deploy/helm/devforge`](../helm/devforge) | +| Chart Helm de DevForge (+ `cloudflared` optionnel) | [`deploy/helm/devforge`](../helm/devforge) | +| Valeurs Traefik (IngressClass `traefik`) | [`traefik-values.yaml`](traefik-values.yaml) | | 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/*`) | +## Choix par défaut (modifiables) + +| Élément | Défaut | Changer | +|---------|--------|---------| +| Registre | GHCR + `apps.imagePullSecret` (Secret créé dans `devforge-apps`, recopié dans chaque namespace de projet) | `apps.imagePullSecret: ""` (images publiques) ou autre registre | +| Ingress | Traefik, `apps.ingressClass: traefik` | `apps.ingressClass: nginx` / `none` | +| Stockage | local-path (mono-nœud) | Longhorn en multi-nœud : `persistence.storageClass` / `postgresql.storageClass: longhorn` | +| Exposition | tunnel Cloudflare : `cloudflared.enabled: true` → `http://traefik.traefik.svc.cluster.local:80` | Traefik `LoadBalancer`/`NodePort` + cert-manager (`apps.clusterIssuer`) | +| Namespaces | un par projet : `apps.namespaceMode: perProject`, `devforge-` (ClusterRole) | `apps.namespaceMode: single` (Role limité à `apps.namespace`) | + +Installation express (cluster avec StorageClass par défaut) : + +```bash +helm repo add traefik https://traefik.github.io/charts && helm repo update +helm install traefik traefik/traefik -n traefik --create-namespace -f deploy/kubernetes/traefik-values.yaml +kubectl create ns devforge +kubectl -n devforge create secret generic cloudflared-token --from-literal=token='' +helm install devforge deploy/helm/devforge -n devforge \ + --set cloudflared.enabled=true --set cloudflared.existingSecret=cloudflared-token +``` + 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. + Secret d'env, probes, statut, logs, stop/start/restart, rolling update), dans le namespace + du projet (`"project": "shop"` → `devforge-shop`). 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`. +- Testé de bout en bout sur k3s 1.31 (Traefik 3.7, local-path) ; Talos réel pas encore validé. - Phase 2 : build dans le cluster avec **Kaniko** (Job), intégration UI / projets. diff --git a/deploy/kubernetes/traefik-values.yaml b/deploy/kubernetes/traefik-values.yaml new file mode 100644 index 000000000..58a8ce4c7 --- /dev/null +++ b/deploy/kubernetes/traefik-values.yaml @@ -0,0 +1,32 @@ +# Traefik pour DevForge (chart officiel traefik/traefik, testé en 42.2.0 / Traefik v3.7) : +# helm repo add traefik https://traefik.github.io/charts && helm repo update +# helm install traefik traefik/traefik -n traefik --create-namespace \ +# -f deploy/kubernetes/traefik-values.yaml +# Crée l'IngressClass « traefik » (par défaut du cluster), utilisée par DevForge. +# k3s embarque déjà Traefik : inutile de le réinstaller (IngressClass « traefik » présente). + +ingressClass: + enabled: true + isDefaultClass: true + name: traefik + +providers: + kubernetesIngress: + enabled: true + # Rapporte l'adresse du Service dans le statut des Ingress. + publishedService: + enabled: true + +# Exposition : ClusterIP derrière le tunnel Cloudflare (cloudflared → traefik.traefik:80). +# Sans tunnel : type LoadBalancer (MetalLB, cloud) ou NodePort (ports 30080/30443). +service: + spec: + type: ClusterIP +# type: NodePort +ports: + web: + exposedPort: 80 +# nodePort: 30080 + websecure: + exposedPort: 443 +# nodePort: 30443 diff --git a/wiki/FAQ.md b/wiki/FAQ.md index 3b3c8c059..83c9dad95 100644 --- a/wiki/FAQ.md +++ b/wiki/FAQ.md @@ -8,7 +8,7 @@ C’est un **PaaS self-hosted** (Docker, domaines, env, backups) **plus** un bui 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]] +Kubernetes est **optionnel** et en **phase 1 (expérimental)** : chart Helm pour installer DevForge dans un cluster (cible Talos ; testé de bout en bout sur k3s) et déploiement d'apps depuis une image déjà construite (`DEVFORGE_RUNTIME=kubernetes`). Choix par défaut : GHCR + imagePullSecret, Traefik, local-path (Longhorn en multi-nœud), tunnel Cloudflare, un namespace par projet. Pas encore de build dans le cluster ni d'intégration UI. [[Kubernetes]] ## Comment ajouter une deuxième machine ? diff --git a/wiki/Kubernetes.md b/wiki/Kubernetes.md index 44145b6bf..c1af468c5 100644 --- a/wiki/Kubernetes.md +++ b/wiki/Kubernetes.md @@ -4,30 +4,44 @@ > 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. +## Choix par défaut + +DevForge fait ces choix pour toi ; chacun se change par une valeur du chart. + +| Élément | Défaut | Pour changer | +|---------|--------|--------------| +| Registre d'images | **GHCR** + `imagePullSecret` (recopié dans chaque namespace de projet) | `apps.imagePullSecret: ""` pour des images publiques ; tout registre `docker-registry` fonctionne (Docker Hub, Harbor…) | +| Ingress controller | **Traefik** (IngressClass `traefik`) | `apps.ingressClass: nginx` (ou `none` → classe par défaut du cluster) ; champ `ingress_class` par app | +| Stockage | **local-path** (mono-nœud) | multi-nœud : **Longhorn** (§ 2) puis `persistence.storageClass: longhorn`, `postgresql.storageClass: longhorn` | +| Exposition publique | **tunnel Cloudflare** : `cloudflared` → Service Traefik, TLS terminé chez Cloudflare | `cloudflared.enabled: false` + Traefik en `LoadBalancer`/`NodePort` et cert-manager (`apps.clusterIssuer`) | +| Namespaces | **un namespace par projet** : `devforge-`, créé à la demande (labels PodSecurity) | `apps.namespacePrefix: …` ; `apps.namespaceMode: single` → tout dans `apps.namespace` (RBAC limité à ce namespace) | +| PodSecurity des apps | `restricted` (images non-root) | `apps.podSecurity: baseline` + `"run_as_non_root": false` par app | + ## Ce que fait la phase 1 | Fonction | Kubernetes | |----------|------------| | Déployer une image | `Deployment` (rolling update `maxSurge 1` / `maxUnavailable 0`) | +| Namespace | `devforge-` (champ `project`) ; sans projet → `devforge-apps` | | Exposer l'app | `Service` ClusterIP (port 80 → port de l'app) | -| Domaine + HTTPS | `Ingress` (host, `ingressClassName`, TLS optionnel via cert-manager) | +| Domaine + HTTPS | `Ingress` Traefik (host ; TLS chez Cloudflare, ou cert-manager si `tls`) | | 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`) | +| Supprimer | Deployment, Service, Ingress, Secret (uniquement ceux étiquetés `managed-by: devforge`) ; le namespace du projet est conservé | 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`). +En mode Kubernetes, DevForge **désactive** : Traefik Docker, le GC Docker, l'auto-update +Docker (mise à jour = `helm upgrade`) et le snapshot de bascule entre nœuds DevForge. ## 1. Prérequis Talos -- Un cluster Talos ≥ 1.7 (Kubernetes ≥ 1.29) et `talosctl`, `kubectl`, `helm` (v3) sur ton poste. +- Un cluster Talos ≥ 1.7 (Kubernetes ≥ 1.30 : action `preStop.sleep`) et `talosctl`, `kubectl`, `helm` (v3) sur ton poste. - Récupérer le kubeconfig : ```bash @@ -46,15 +60,15 @@ Docker (mise à jour = `helm upgrade`). allowSchedulingOnControlPlanes: true ``` -## 2. Stockage (StorageClass) +k3s (autre option mono-nœud) : fournit déjà local-path et Traefik — sauter les § 2 et 3. + +## 2. Stockage : local-path (défaut mono-nœud) 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 apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.32/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"}}}' ``` @@ -72,42 +86,75 @@ machine: 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)`). +local-path attache un volume à **un** nœud : si ce nœud tombe, DevForge et sa base aussi. + +**Multi-nœud : Longhorn** (volumes répliqués). Sur Talos, ajouter les extensions +`siderolabs/iscsi-tools` et `siderolabs/util-linux-tools` (Image Factory), puis : + +```bash +kubectl create ns longhorn-system +kubectl label ns longhorn-system pod-security.kubernetes.io/enforce=privileged +helm repo add longhorn https://charts.longhorn.io && helm repo update +helm install longhorn longhorn/longhorn -n longhorn-system \ + --set defaultSettings.defaultDataPath=/var/lib/longhorn +kubectl get storageclass # « longhorn » +``` + +Puis dans les valeurs DevForge : `persistence.storageClass: longhorn` et +`postgresql.storageClass: longhorn` (ou rendre `longhorn` classe par défaut). -## 3. Ingress controller +## 3. Traefik (ingress controller par défaut) -Talos n'en fournit pas. Exemple avec ingress-nginx : +Valeurs prêtes à l'emploi : [`deploy/kubernetes/traefik-values.yaml`](https://github.com/bobdivx/devforge/blob/main/deploy/kubernetes/traefik-values.yaml) +(IngressClass `traefik` par défaut du cluster, Service `ClusterIP` derrière le tunnel). ```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 +helm repo add traefik https://traefik.github.io/charts && helm repo update +helm install traefik traefik/traefik -n traefik --create-namespace \ + -f deploy/kubernetes/traefik-values.yaml +kubectl get ingressclass # traefik ``` -- **LoadBalancer** sur bare metal : MetalLB ou l'IPAM Cilium ; sinon `NodePort` + redirection - du routeur. -- **TLS** : cert-manager + un `ClusterIssuer` (Let's Encrypt) : +Sans tunnel Cloudflare : `service.spec.type: LoadBalancer` (MetalLB, IPAM Cilium) ou +`NodePort` (+ redirection du routeur), et cert-manager pour le TLS : - ```bash - helm install cert-manager oci://quay.io/jetstack/charts/cert-manager -n cert-manager \ - --create-namespace --set crds.enabled=true - ``` +```bash +helm install cert-manager oci://quay.io/jetstack/charts/cert-manager -n cert-manager \ + --create-namespace --set crds.enabled=true +# puis un ClusterIssuer Let's Encrypt et --set apps.clusterIssuer=letsencrypt-prod +``` -- **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. +Autre contrôleur (ingress-nginx, Cilium) : `apps.ingressClass: nginx` et +`ingress.className: nginx`. -Traefik (le contrôleur utilisé par DevForge en mode Docker) fonctionne aussi : -`ingressClass: traefik`. +## 4. Exposition : tunnel Cloudflare (défaut) -## 4. Registre d'images (GHCR) et imagePullSecret +Comme en prod Docker : aucun port ouvert, TLS public chez Cloudflare. -Les apps doivent être **déjà construites** et poussées sur un registre. Avec GHCR : +1. Cloudflare Zero Trust → **Networks → Tunnels** → créer un tunnel (type `cloudflared`) et + copier son **jeton**. +2. Le mettre dans un Secret (clé `token`) : + + ```bash + kubectl create namespace devforge + kubectl -n devforge create secret generic cloudflared-token --from-literal=token='' + ``` + +3. Activer `cloudflared` dans le chart (§ 6) : `cloudflared.enabled: true`, + `cloudflared.existingSecret: cloudflared-token`. +4. Dans le tunnel, **Public hostnames** : pour chaque domaine (`devforge.example.com`, + `*.apps.example.com`…) → service `HTTP` `traefik.traefik.svc.cluster.local:80`. + Traefik route ensuite selon l'en-tête `Host` des Ingress. + +Le chart lance `cloudflared tunnel --no-autoupdate run` (non-root, racine en lecture seule, +sonde `/ready`). Pas besoin de cert-manager : laisse `tls` vide sur les apps. + +## 5. Registre : GHCR + imagePullSecret (défaut) + +Les apps doivent être **déjà construites** et poussées sur un registre. Avec GHCR privé : 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 : +2. Créer le Secret **une fois**, dans le namespace par défaut des apps (`devforge-apps`) : ```bash kubectl create namespace devforge-apps --dry-run=client -o yaml | kubectl apply -f - @@ -116,9 +163,13 @@ Les apps doivent être **déjà construites** et poussées sur un registre. Avec --docker-password='' ``` -3. Le déclarer par défaut dans le chart : `--set apps.imagePullSecret=ghcr-pull`. +3. Le déclarer par défaut : `apps.imagePullSecret: ghcr-pull`. Au premier déploiement d'un + projet, DevForge **recopie** ce Secret dans `devforge-` (sans jamais écraser un + Secret du même nom déjà présent). Après rotation du PAT : supprimer les copies + (`kubectl delete secret ghcr-pull -n devforge-`), elles sont recréées au + déploiement suivant. -Images publiques : rien à faire. +Images publiques : `apps.imagePullSecret: ""`. **crane** (sans Docker) pour copier / re-tagger : @@ -128,40 +179,40 @@ crane tag ghcr.io//mon-app:sha-1a2b3c4d prod crane digest ghcr.io//mon-app:prod ``` -## 5. Installer DevForge avec Helm +## 6. 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 +kubectl create namespace devforge --dry-run=client -o yaml | kubectl apply -f - # DevForge lui-même respecte « restricted » : -kubectl label ns devforge pod-security.kubernetes.io/enforce=restricted +kubectl label ns devforge pod-security.kubernetes.io/enforce=restricted --overwrite cat > my-values.yaml <<'YAML' image: repository: ghcr.io/bobdivx/devforge - tag: "" # vide = appVersion du chart -ingress: + tag: "" # vide = appVersion du chart +ingress: # UI DevForge via Traefik (hôte publié dans le tunnel) enabled: true - className: nginx # ou traefik, cilium… + className: traefik host: devforge.example.com - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod - tls: - enabled: true persistence: size: 10Gi - storageClass: "" # vide = classe par défaut + storageClass: "" # vide = classe par défaut (local-path) ; longhorn en multi-nœud postgresql: - enabled: true # Postgres intégré ; false + external.existingSecret pour une base externe + enabled: true # Postgres intégré ; false + external.existingSecret pour une base externe apps: + namespaceMode: perProject # ou single + namespacePrefix: devforge- namespace: devforge-apps - podSecurity: restricted # « baseline » si tes images tournent en root - ingressClass: nginx - clusterIssuer: letsencrypt-prod - imagePullSecret: ghcr-pull + podSecurity: restricted # « baseline » si tes images tournent en root + ingressClass: traefik + imagePullSecret: ghcr-pull # "" si images publiques +cloudflared: + enabled: true + existingSecret: cloudflared-token YAML helm lint deploy/helm/devforge -f my-values.yaml @@ -183,8 +234,9 @@ Ce que crée le chart : | `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 | +| `ServiceAccount` + RBAC | `perProject` : `ClusterRole` (namespaces get/list/create/patch ; deployments, services, secrets, ingresses ; lecture pods / logs / events). `single` : `Role` limité à `apps.namespace` | | `Namespace devforge-apps` | labels PodSecurity, conservé au `uninstall` | +| `Deployment cloudflared` (+ Secret si `cloudflared.token`) | tunnel Cloudflare, si `cloudflared.enabled` | | `Service` + `Ingress` | accès à l'interface | Base externe : créer un Secret avec la clé `database-url` (`postgres://user:pass@host:5432/db`) @@ -192,47 +244,64 @@ puis `--set postgresql.enabled=false --set postgresql.external.existingSecret=`. -## 6. Première app +### Namespaces par projet et RBAC + +Les namespaces sont créés à la demande : le compte de service a donc des droits au niveau +du cluster. Garde-fous dans DevForge : + +- il n'agit que dans `` (slug DNS validé) ou `apps.namespace` ; +- il refuse un namespace existant qui n'a pas le label `app.kubernetes.io/managed-by=devforge` ; +- il ne modifie / supprime que des objets portant ce label (jamais un Deployment posé à la main). + +Pour un RBAC strictement namespacé, `apps.namespaceMode: single`. Supprimer la dernière app +d'un projet ne supprime pas son namespace : `kubectl delete ns devforge-`. + +## 7. 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) +# État du backend (mode de namespace, IngressClass, 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) +# Déployer dans le projet « shop » → namespace devforge-shop (attend le 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 '{ + "project": "shop", "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 +Q="?project=shop" +curl -s -H "Authorization: Bearer $DF_TOKEN" "$DF/api/v1/kube/apps$Q" # apps du projet +curl -s -H "Authorization: Bearer $DF_TOKEN" "$DF/api/v1/kube/apps/hello$Q" # statut +curl -s -H "Authorization: Bearer $DF_TOKEN" "$DF/api/v1/kube/apps/hello/logs$Q&tail=200" +curl -s -X POST -H "Authorization: Bearer $DF_TOKEN" "$DF/api/v1/kube/apps/hello/restart$Q" +curl -s -X POST -H "Authorization: Bearer $DF_TOKEN" "$DF/api/v1/kube/apps/hello/stop$Q" +curl -s -X POST -H "Authorization: Bearer $DF_TOKEN" "$DF/api/v1/kube/apps/hello/start$Q" +curl -s -X DELETE -H "Authorization: Bearer $DF_TOKEN" "$DF/api/v1/kube/apps/hello$Q" ``` +`GET /kube/apps` sans `project` liste toutes les apps DevForge (tous projets). +Le domaine `hello.example.com` doit être publié dans le tunnel Cloudflare (§ 4). + 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`. +Champs : `project` (slug DNS, optionnel), `name` (DNS, ≤ 40 car.), `image`, `port` (8080), +`replicas` (1, 0–20), `host`, `tls` (`secret_name`, `cluster_issuer` — inutile derrière +Cloudflare), `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). @@ -248,15 +317,20 @@ Exemple complet (build → push GHCR → `POST /kube/apps?wait=true`) : - **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). + l'interface (pages Deployments, Domains, Env du projet = mode Docker) ; le champ `project` + est un slug libre qui choisit le namespace. - 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. +- Pas de volumes persistants pour les apps ; pas d'HPA ; namespaces de projet non supprimés + automatiquement. - `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). +- Testé de bout en bout sur **k3s 1.31** mono-nœud (Traefik 3.7 via Helm, local-path, + chart DevForge, app déployée par l'API, trafic HTTP via Traefik, logs, restart, rolling + update, suppression). **Pas encore validé sur un cluster Talos réel**, ni avec Longhorn, + ni avec un vrai tunnel Cloudflare (le Deployment `cloudflared` est rendu et validé par + l'API, sans jeton réel). ## Feuille de route @@ -269,7 +343,7 @@ Exemple complet (build → push GHCR → `POST /kube/apps?wait=true`) : - Volumes persistants d'apps (PVC), CronJobs. **Phase 3** -- Multi-namespaces / multi-clusters, HPA, NetworkPolicies par app. +- Multi-clusters, HPA, NetworkPolicies par projet. - Promotion d'images entre environnements (crane), canary. - Opérateur / CRD `DevForgeApp`, haute disponibilité du control plane. @@ -278,10 +352,13 @@ Exemple complet (build → push GHCR → `POST /kube/apps?wait=true`) : | 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) | +| `403` Kubernetes | RBAC : mode `single` mais app avec `project` hors de `apps.namespace`, ou chart installé sans le ClusterRole (`perProject`) | +| « namespace … n'est pas géré par DevForge » | un namespace `devforge-` existe déjà sans le label `app.kubernetes.io/managed-by=devforge` : le renommer, ou ajouter le label si c'est voulu | +| `ImagePullBackOff` | `imagePullSecret` absent de `devforge-apps` (source de la copie), PAT expiré (supprimer les copies, § 5), ou nom d'image en majuscules (GHCR) | +| `404` sur une app | oubli de `?project=` (l'app est dans `devforge-`) | | 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) | +| `502` / `1033` Cloudflare | tunnel arrêté (`kubectl -n devforge logs deploy/devforge-cloudflared`) ou hostname public ne pointant pas vers `traefik.traefik.svc.cluster.local:80` | Voir aussi : [[Installation]] · [[Architecture]] · [[FAQ]]