Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 66 additions & 11 deletions apps/server/src/kube_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -87,6 +89,9 @@ async fn info(State(state): State<AppState>, headers: HeaderMap) -> Result<Json<
"runtime": backend.as_str(),
"enabled": true,
"namespace": d.namespace,
"namespace_mode": d.namespace_mode,
"namespace_prefix": d.namespace_prefix,
"pod_security": d.pod_security,
"ingress_class": d.ingress_class,
"image_pull_secret": d.image_pull_secret,
"cluster_issuer": d.cluster_issuer,
Expand All @@ -111,11 +116,34 @@ async fn render(
Ok(Json(json!({ "data": rendered.redacted() })))
}

async fn list(State(state): State<AppState>, headers: HeaderMap) -> Result<Json<Value>, ApiError> {
/// `?project=<slug>` : app d'un projet (namespace `<préfixe><projet>` en mode per-project).
#[derive(Deserialize, Default)]
struct ProjectQuery {
project: Option<String>,
}

fn app_ref(name: String, q: &ProjectQuery) -> AppRef {
AppRef::new(name, q.project.as_deref())
}

async fn list(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ProjectQuery>,
) -> Result<Json<Value>, 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)]
Expand All @@ -135,11 +163,14 @@ async fn deploy(
) -> Result<Json<Value>, 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)?;
}
Expand All @@ -150,15 +181,20 @@ async fn status(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
Query(pq): Query<ProjectQuery>,
) -> Result<Json<Value>, 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<u32>,
project: Option<String>,
}

async fn logs(
Expand All @@ -169,7 +205,10 @@ async fn logs(
) -> Result<Json<Value>, 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 })))
Expand All @@ -179,38 +218,54 @@ async fn stop(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
Query(pq): Query<ProjectQuery>,
) -> Result<Json<Value>, 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 })))
}

async fn start(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
Query(pq): Query<ProjectQuery>,
) -> Result<Json<Value>, 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 })))
}

async fn restart(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
Query(pq): Query<ProjectQuery>,
) -> Result<Json<Value>, 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 })))
}

async fn remove(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
Query(pq): Query<ProjectQuery>,
) -> Result<Json<Value>, 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 })))
}
11 changes: 8 additions & 3 deletions apps/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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());

Expand All @@ -239,8 +246,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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"
Expand Down
7 changes: 7 additions & 0 deletions crates/kube/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
111 changes: 106 additions & 5 deletions crates/kube/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<préfixe><projet>`
//! 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 (`<préfixe><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<Self> {
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<String>,
pub image_pull_secret: Option<String>,
pub cluster_issuer: Option<String>,
}

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<String>) -> Self {
Self {
Expand All @@ -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)
Expand Down Expand Up @@ -140,6 +222,25 @@ fn read_trimmed(p: &std::path::Path) -> Option<String> {
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 {
Expand Down
4 changes: 2 additions & 2 deletions crates/kube/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading