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
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ jobs:
local old="$1" new="$2"
sed -i "s/^version = \"${old}\"/version = \"${new}\"/" Cargo.toml
sed -i "0,/\"version\": \"${old}\"/s//\"version\": \"${new}\"/" apps/web/package.json
# Chart Helm : appVersion = version de l'image DevForge par défaut.
sed -i "s/^appVersion: \"${old}\"/appVersion: \"${new}\"/" deploy/helm/devforge/Chart.yaml
# Uniquement les crates du workspace. Un `sed` global réécrit aussi
# les dépendances qui ont le même numéro (ex. syn 2.0.119).
awk -v old="$old" -v new="$new" '
Expand Down Expand Up @@ -99,7 +101,7 @@ jobs:

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Cargo.toml Cargo.lock apps/web/package.json
git add Cargo.toml Cargo.lock apps/web/package.json deploy/helm/devforge/Chart.yaml
git commit -m "chore: bump version ${OLD} → ${VERSION}"
git push origin "HEAD:${GITHUB_REF_NAME}"
else
Expand Down
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ members = [
"crates/cron",
"crates/cluster",
"crates/memory",
"crates/kube",
]

[workspace.package]
Expand Down Expand Up @@ -67,6 +68,7 @@ devforge-update = { path = "crates/update" }
devforge-runner = { path = "crates/runner" }
devforge-cron = { path = "crates/cron" }
devforge-cluster = { path = "crates/cluster" }
devforge-kube = { path = "crates/kube" }

[workspace.metadata.cargo-machete]
ignored = ["idna"]
1 change: 1 addition & 0 deletions apps/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ devforge-update = { workspace = true }
devforge-runner = { workspace = true }
devforge-cron = { workspace = true }
devforge-cluster = { workspace = true }
devforge-kube = { workspace = true }
axum = { workspace = true }
tower-http = { workspace = true }
tokio = { workspace = true }
Expand Down
216 changes: 216 additions & 0 deletions apps/server/src/kube_routes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
//! API du backend Kubernetes (phase 1) : déployer une image déjà construite.
//!
//! Actif seulement avec `DEVFORGE_RUNTIME=kubernetes` (sauf `render`, qui ne touche pas
//! au cluster). Réservé à l'administrateur d'instance. Le backend Docker reste le défaut.

use std::sync::{Arc, OnceLock};
use std::time::Duration;

use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::Json,
routing::{get, post},
Router,
};
use devforge_kube::{runtime::ImageRuntime, AppSpec, KubeDefaults, KubeError, KubeRuntime};
use devforge_shared::runtime_backend::RuntimeBackend;
use serde::Deserialize;
use serde_json::{json, Value};

use crate::state::AppState;

type ApiError = (StatusCode, Json<Value>);

pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/kube/info", get(info))
.route("/api/v1/kube/render", post(render))
.route("/api/v1/kube/apps", get(list).post(deploy))
.route("/api/v1/kube/apps/{name}", get(status).delete(remove))
.route("/api/v1/kube/apps/{name}/logs", get(logs))
.route("/api/v1/kube/apps/{name}/stop", post(stop))
.route("/api/v1/kube/apps/{name}/start", post(start))
.route("/api/v1/kube/apps/{name}/restart", post(restart))
}

static RUNTIME: OnceLock<Result<Arc<KubeRuntime>, String>> = OnceLock::new();

fn runtime() -> Result<Arc<KubeRuntime>, ApiError> {
if !RuntimeBackend::from_env().is_kubernetes() {
return Err((
StatusCode::CONFLICT,
Json(json!({
"error": "Backend Kubernetes inactif (DEVFORGE_RUNTIME=docker). Voir le wiki : Kubernetes.",
"runtime": "docker",
})),
));
}
RUNTIME
.get_or_init(|| {
KubeRuntime::from_env()
.map(Arc::new)
.map_err(|e| e.to_string())
})
.clone()
.map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": e }))))
}

fn kube_err(e: KubeError) -> ApiError {
let code = StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::BAD_GATEWAY);
(code, Json(json!({ "error": e.to_string() })))
}

async fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
let (user, _) = crate::auth_routes::current_workspace(state, headers).await?;
if user.role != "instance_admin" {
return Err((
StatusCode::FORBIDDEN,
Json(json!({"error": "Réservé à l'administrateur d'instance"})),
));
}
Ok(())
}

async fn info(State(state): State<AppState>, headers: HeaderMap) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers).await?;
let backend = RuntimeBackend::from_env();
if !backend.is_kubernetes() {
return Ok(Json(
json!({ "runtime": backend.as_str(), "enabled": false }),
));
}
let rt = runtime()?;
let d = rt.defaults();
let version = rt.client().version().await;
Ok(Json(json!({
"runtime": backend.as_str(),
"enabled": true,
"namespace": d.namespace,
"ingress_class": d.ingress_class,
"image_pull_secret": d.image_pull_secret,
"cluster_issuer": d.cluster_issuer,
"reachable": version.is_ok(),
"server_version": version.as_ref().ok(),
"error": version.err().map(|e| e.to_string()),
"build_in_cluster": false,
})))
}

/// Aperçu des manifests (Secret masqué). Fonctionne aussi en mode Docker.
async fn render(
State(state): State<AppState>,
headers: HeaderMap,
Json(mut spec): Json<AppSpec>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers).await?;
spec.normalize()
.map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))))?;
let defaults = KubeDefaults::from_env();
let rendered = devforge_kube::render(&spec, &defaults);
Ok(Json(json!({ "data": rendered.redacted() })))
}

async fn list(State(state): State<AppState>, headers: HeaderMap) -> 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() })))
}

#[derive(Deserialize, Default)]
struct DeployQuery {
/// Attendre la fin du rollout.
#[serde(default)]
wait: bool,
/// Secondes (défaut 120, max 600).
timeout: Option<u64>,
}

async fn deploy(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<DeployQuery>,
Json(spec): Json<AppSpec>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers).await?;
let rt = runtime()?;
let mut outcome = rt.deploy(&spec).await.map_err(kube_err)?;
if q.wait {
let secs = q.timeout.unwrap_or(120).clamp(5, 600);
outcome.status = rt
.wait_rollout(&outcome.name, Duration::from_secs(secs))
.await
.map_err(kube_err)?;
}
Ok(Json(json!({ "ok": true, "data": outcome })))
}

async fn status(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers).await?;
let st = runtime()?.status(&name).await.map_err(kube_err)?;
Ok(Json(json!({ "data": st })))
}

#[derive(Deserialize, Default)]
struct LogsQuery {
tail: Option<u32>,
}

async fn logs(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
Query(q): Query<LogsQuery>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers).await?;
let out = runtime()?
.logs(&name, q.tail.unwrap_or(200))
.await
.map_err(kube_err)?;
Ok(Json(json!({ "data": out })))
}

async fn stop(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers).await?;
let st = runtime()?.stop(&name).await.map_err(kube_err)?;
Ok(Json(json!({ "ok": true, "data": st })))
}

async fn start(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers).await?;
let st = runtime()?.start(&name).await.map_err(kube_err)?;
Ok(Json(json!({ "ok": true, "data": st })))
}

async fn restart(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers).await?;
let st = runtime()?.restart(&name).await.map_err(kube_err)?;
Ok(Json(json!({ "ok": true, "data": st })))
}

async fn remove(
State(state): State<AppState>,
headers: HeaderMap,
Path(name): Path<String>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers).await?;
let removed = runtime()?.remove(&name).await.map_err(kube_err)?;
Ok(Json(json!({ "ok": true, "removed": removed })))
}
35 changes: 24 additions & 11 deletions apps/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,28 +43,29 @@ mod grok_bot_tests;
mod group_routes;
mod infra_routes;
mod infra_sqlite;
mod kube_routes;
mod llm_routes;
mod mcp_oauth;
mod mcp_routes;
mod memory_routes;
mod mobile_routes;
mod paths;
mod playbooks;
mod page_watch;
mod page_watch_routes;
#[cfg(test)]
mod playbooks_tests;
mod page_watch_tests;
mod paths;
mod platform_sso;
#[cfg(test)]
mod platform_sso_tests;
mod playbooks;
#[cfg(test)]
mod playbooks_tests;
mod pocket_id;
mod pr_previews;
mod project_oidc;
mod project_oidc_routes;
mod project_pg;
mod project_pg_routes;
mod page_watch;
mod page_watch_routes;
#[cfg(test)]
mod page_watch_tests;
mod receipts;
mod rich_chat;
mod routes;
Expand Down Expand Up @@ -238,7 +239,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
acme_email_set = acme.is_some(),
"email ACME du proxy résolu"
);
if let Err(e) = state.proxy.ensure_traefik().await {
// Kubernetes : le routage passe par l'ingress controller du cluster, pas par Traefik/Docker.
let kube_runtime = devforge_shared::runtime_backend::RuntimeBackend::from_env().is_kubernetes();
if kube_runtime {
tracing::info!(
"DEVFORGE_RUNTIME=kubernetes — proxy Traefik Docker, GC Docker et auto-update désactivés"
);
} else if let Err(e) = state.proxy.ensure_traefik().await {
tracing::error!(error = %e, "Failed to ensure Traefik container — proxy may be unavailable");
} else {
tracing::info!("Traefik reverse proxy ready");
Expand All @@ -247,7 +254,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Watchdog: periodic health check for Traefik (fix for 2026-09-14 outage).
// Every 2 minutes (reduced from 5), ensure Traefik exists and is running.
// More frequent checks catch disappearances faster after deploys or network changes.
{
if !kube_runtime {
let proxy = state.proxy.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(120));
Expand All @@ -265,7 +272,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

crate::dns::spawn_dns_loop(state.clone());
crate::dns_failover::spawn_loop(state.clone());
crate::docker_gc_routes::spawn_loop(state.clone());
if !kube_runtime {
crate::docker_gc_routes::spawn_loop(state.clone());
}
// P2 : actions coupées par le redémarrage → « issue inconnue », avant toute reprise.
receipts::recover_at_boot(&state.pool).await;
routes::resume_deploy_queue(state.clone());
Expand Down Expand Up @@ -383,7 +392,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}

update_routes::spawn_auto_update(state.clone());
if !kube_runtime {
// En mode k8s la mise à jour passe par Helm (`helm upgrade`), pas par Docker.
update_routes::spawn_auto_update(state.clone());
}
cursor_worker_routes::spawn_autostart(state.clone());

// Advertise agent tools on the local MCP server surface.
Expand Down Expand Up @@ -435,6 +447,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.merge(mobile_routes::router())
.merge(android_routes::router())
.merge(cluster_routes::router())
.merge(kube_routes::router())
.merge(cursor_worker_routes::router())
.merge(worker::exec_route())
.route("/", get(auth_routes::marketing_home))
Expand Down
18 changes: 18 additions & 0 deletions crates/kube/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "devforge-kube"
version.workspace = true
edition.workspace = true

[dependencies]
devforge-shared = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
reqwest = { workspace = true }
sha2 = "0.10"

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
Loading
Loading