From bb795d8d2eca93edf27b79eb91e8a09a433bad96 Mon Sep 17 00:00:00 2001 From: bobdivx <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:04:52 +0200 Subject: [PATCH] feat(github-app): P4 Checks API, commit statuses, GitHub Deployments + rerun pins requested SHA --- apps/server/src/db.rs | 8 + apps/server/src/dev_env.rs | 1 + apps/server/src/github_app_events.rs | 1 + apps/server/src/github_checks.rs | 432 +++++++++++++++++++++++++ apps/server/src/github_checks_tests.rs | 253 +++++++++++++++ apps/server/src/infra_routes.rs | 4 +- apps/server/src/main.rs | 1 + apps/server/src/routes.rs | 46 ++- crates/deploy/src/lib.rs | 36 +++ crates/github/src/checks.rs | 267 +++++++++++++++ crates/github/src/lib.rs | 1 + 11 files changed, 1048 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/github_checks.rs create mode 100644 apps/server/src/github_checks_tests.rs create mode 100644 crates/github/src/checks.rs diff --git a/apps/server/src/db.rs b/apps/server/src/db.rs index 8d7f42ff3..aa23f7728 100644 --- a/apps/server/src/db.rs +++ b/apps/server/src/db.rs @@ -1057,6 +1057,14 @@ pub async fn migrate_github_app(pool: &PgPool) -> Result<(), sqlx::Error> { ] { sqlx::query(sql).execute(pool).await?; } + // P4 : liens GitHub (Check Run / Deployment) par déploiement DevForge. + for sql in [ + "ALTER TABLE deployments ADD COLUMN check_run_id BIGINT", + "ALTER TABLE deployments ADD COLUMN github_deployment_id BIGINT", + "ALTER TABLE deployments ADD COLUMN github_head_sha TEXT", + ] { + let _ = sqlx::query(sql).execute(pool).await; + } // P3 : observabilité des livraisons webhook app-level. for sql in [ "ALTER TABLE github_webhook_deliveries ADD COLUMN duration_ms BIGINT", diff --git a/apps/server/src/dev_env.rs b/apps/server/src/dev_env.rs index 883178f91..712d939e2 100644 --- a/apps/server/src/dev_env.rs +++ b/apps/server/src/dev_env.rs @@ -589,6 +589,7 @@ pub(crate) async fn run_dev_deploy( is_static: project.is_static != 0, github_token: deploy_tok.token, github_token_ephemeral: deploy_tok.ephemeral, + pin_sha: None, env_file, proxy_labels: Some(labels), gpu_nvidia: project.gpu_nvidia != 0, diff --git a/apps/server/src/github_app_events.rs b/apps/server/src/github_app_events.rs index 606d123f2..4bcd5b3de 100644 --- a/apps/server/src/github_app_events.rs +++ b/apps/server/src/github_app_events.rs @@ -300,6 +300,7 @@ pub fn rerequest_as_push(event: &str, payload: &Value) -> Option { "repository": payload.get("repository")?, "installation": payload.get("installation"), "head_commit": { "id": sha, "message": "Relance demandée depuis GitHub" }, + "devforge_pin_sha": sha, })) } diff --git a/apps/server/src/github_checks.rs b/apps/server/src/github_checks.rs new file mode 100644 index 000000000..e6f450af9 --- /dev/null +++ b/apps/server/src/github_checks.rs @@ -0,0 +1,432 @@ +//! P4 — Retour d'état vers GitHub pour chaque déploiement : Check Run « DevForge / deploy » +//! (projets liés à l'installation), statut de commit `devforge/deploy` en repli (PAT), +//! et GitHub Deployment (« View deployment » dans la PR). +//! +//! Actif uniquement en mode GitHub App (`DEVFORGE_GITHUB_APP=1` ou app enregistrée) : +//! sans ce mode, aucun appel n'est fait (comportement inchangé). Les appels GitHub sont +//! « best effort » : un échec est journalisé et retenté une fois, jamais propagé. + +use std::sync::Arc; +use std::time::Duration; + +use devforge_github::checks::RepoApi; +use tokio::sync::Notify; + +use crate::github_auth::GithubAuth; +use crate::state::{AppState, Project}; + +pub const RETRY_DELAY: Duration = Duration::from_secs(20); +const LOG_TAIL_LINES: usize = 50; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// Checks API (exige un token d'installation GitHub App). + Checks, + /// Statuts de commit (projets encore en PAT). + Statuses, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Ids { + pub head_sha: String, + pub check_run_id: Option, + pub deployment_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Final { + Success { + summary: String, + }, + Failure { + title: String, + summary: String, + text: String, + }, + Cancelled, +} + +#[derive(Debug, Clone)] +pub struct Reporter { + pub api: RepoApi, + pub mode: Mode, + pub details_url: Option, + pub environment_url: Option, + pub environment: String, + pub retry_delay: Duration, +} + +async fn attempt(what: &str, delay: Duration, f: F) -> Option +where + F: Fn() -> Fut, + Fut: std::future::Future>, +{ + match f().await { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!("GitHub {what} : {e} — nouvel essai différé"); + tokio::time::sleep(delay).await; + match f().await { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!("GitHub {what} : {e} — abandon (déploiement non affecté)"); + None + } + } + } + } +} + +impl Reporter { + /// Enfilé : Check Run `queued` (ou statut `pending`) + GitHub Deployment. + pub async fn queued(&self, head_sha: &str, deployment_uuid: &str) -> Ids { + let d = self.retry_delay; + let details = self.details_url.as_deref(); + let check_run_id = match self.mode { + Mode::Checks => { + attempt("check run", d, || { + self.api + .create_check_run(head_sha, details, deployment_uuid) + }) + .await + } + Mode::Statuses => { + attempt("statut", d, || { + self.api.create_status( + head_sha, + "pending", + "Déploiement en file d’attente", + details, + ) + }) + .await; + None + } + }; + let deployment_id = attempt("deployment", d, || { + self.api.create_deployment(head_sha, &self.environment) + }) + .await; + Ids { + head_sha: head_sha.to_string(), + check_run_id, + deployment_id, + } + } + + /// Build démarré. + pub async fn in_progress(&self, ids: &Ids) { + let d = self.retry_delay; + let details = self.details_url.as_deref(); + match (self.mode, ids.check_run_id) { + (Mode::Checks, Some(id)) => { + attempt("check run", d, || { + self.api.update_check_run(id, "in_progress", None, None) + }) + .await; + } + (Mode::Statuses, _) => { + attempt("statut", d, || { + self.api.create_status( + &ids.head_sha, + "pending", + "Déploiement en cours", + details, + ) + }) + .await; + } + _ => {} + } + if let Some(dep) = ids.deployment_id { + attempt("deployment status", d, || { + self.api + .create_deployment_status(dep, "in_progress", details, None) + }) + .await; + } + } + + pub async fn finish(&self, ids: &Ids, fin: &Final) { + let d = self.retry_delay; + let details = self.details_url.as_deref(); + let (conclusion, status_state, dep_state, desc) = match fin { + Final::Success { .. } => ("success", "success", "success", "Déploiement réussi"), + Final::Failure { .. } => ("failure", "failure", "failure", "Échec du déploiement"), + Final::Cancelled => ("cancelled", "error", "inactive", "Déploiement annulé"), + }; + match (self.mode, ids.check_run_id) { + (Mode::Checks, Some(id)) => { + let (title, summary, text) = match fin { + Final::Success { summary } => ( + "Déploiement réussi".to_string(), + summary.clone(), + String::new(), + ), + Final::Failure { + title, + summary, + text, + } => (title.clone(), summary.clone(), text.clone()), + Final::Cancelled => ( + "Déploiement annulé".into(), + "Remplacé ou annulé dans DevForge.".into(), + String::new(), + ), + }; + attempt("check run", d, || { + self.api.update_check_run( + id, + "completed", + Some(conclusion), + Some((&title, &summary, &text)), + ) + }) + .await; + } + (Mode::Statuses, _) => { + let desc = match fin { + Final::Failure { title, .. } => title.as_str(), + _ => desc, + }; + attempt("statut", d, || { + self.api + .create_status(&ids.head_sha, status_state, desc, details) + }) + .await; + } + _ => {} + } + if let Some(dep) = ids.deployment_id { + let env_url = matches!(fin, Final::Success { .. }) + .then_some(self.environment_url.as_deref()) + .flatten(); + attempt("deployment status", d, || { + self.api + .create_deployment_status(dep, dep_state, details, env_url) + }) + .await; + } + } +} + +/// Masque les valeurs d'env du projet et les jetons GitHub dans un texte. +pub fn mask_secrets(text: &str, secrets: &[String]) -> String { + let mut out = text.to_string(); + let mut s: Vec<&String> = secrets.iter().filter(|v| v.trim().len() >= 4).collect(); + s.sort_by_key(|v| std::cmp::Reverse(v.len())); + for v in s { + out = out.replace(v.as_str(), "***"); + } + devforge_github::app_auth::redact_tokens(&out) +} + +pub fn log_tail(logs: &str, n: usize) -> String { + let lines: Vec<&str> = logs.lines().collect(); + lines[lines.len().saturating_sub(n)..].join("\n") +} + +/// Contenu Check Run d'un échec : titre lisible (`error_parser`) + 50 dernières lignes masquées. +pub fn failure_output(logs: &str, secrets: &[String]) -> Final { + let parsed = devforge_deploy::error_parser::parse_deploy_error_fr(logs); + let title = parsed + .as_ref() + .map(|e| e.summary.clone()) + .unwrap_or_else(|| "Échec du déploiement".into()); + let summary = parsed + .as_ref() + .and_then(|e| e.hint.clone()) + .unwrap_or_else(|| "Voir les logs complets dans DevForge.".into()); + let text = format!( + "```\n{}\n```", + mask_secrets(&log_tail(logs, LOG_TAIL_LINES), secrets) + ); + Final::Failure { + title: mask_secrets(&title, secrets), + summary: mask_secrets(&summary, secrets), + text, + } +} + +async fn env_values(state: &AppState, project_uuid: &str) -> Vec { + sqlx::query_as::<_, (String,)>("SELECT value FROM project_env_vars WHERE project_uuid = $1") + .bind(project_uuid) + .fetch_all(&state.pool) + .await + .unwrap_or_default() + .into_iter() + .map(|(v,)| v) + .collect() +} + +async fn instance_url(state: &AppState) -> String { + sqlx::query_as::<_, (String,)>("SELECT instance_url FROM instance_settings WHERE id = 1") + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .map(|(u,)| u.trim().trim_end_matches('/').to_string()) + .unwrap_or_default() +} + +async fn instance_pat(state: &AppState) -> String { + sqlx::query_as::<_, (String,)>("SELECT github_token FROM instance_settings WHERE id = 1") + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .map(|(t,)| crate::secret_box::open_compat(&t)) + .unwrap_or_default() +} + +/// Reporter pour un projet, ou `None` (mode App inactif, dépôt non GitHub, pas de token). +pub async fn resolve( + state: &AppState, + project: &Project, + deployment_uuid: &str, +) -> Option { + if !crate::github_auth::app_enabled(&state.pool).await { + return None; + } + let (owner, repo) = devforge_github::checks::parse_repo(project.git_repository.as_deref()?)?; + let pat = instance_pat(state).await; + let auth = crate::github_auth::for_project(&state.pool, &project.uuid, pat.clone()).await; + let mode = match auth { + GithubAuth::Installation { .. } => Mode::Checks, + GithubAuth::Pat(_) => Mode::Statuses, + }; + let token = crate::github_auth::token(state, &auth, &pat).await; + if token.trim().is_empty() { + return None; + } + // Repli PAT après échec du token d'installation : statuts uniquement. + let mode = if mode == Mode::Checks && token == pat { + Mode::Statuses + } else { + mode + }; + let base = instance_url(state).await; + let details_url = (!base.is_empty()).then(|| { + format!( + "{base}/app/projects/view?uuid={}&tab=deployments&deployment={deployment_uuid}", + project.uuid + ) + }); + Some(Reporter { + api: RepoApi::new( + devforge_github::app_auth::default_api_base(), + &token, + &owner, + &repo, + ), + mode, + details_url, + environment_url: project + .production_url + .clone() + .filter(|u| u.starts_with("http")), + environment: "Production".into(), + retry_delay: RETRY_DELAY, + }) +} + +async fn save_ids(state: &AppState, deployment_uuid: &str, ids: &Ids) { + let _ = sqlx::query( + "UPDATE deployments SET check_run_id = $1, github_deployment_id = $2, github_head_sha = $3 WHERE uuid = $4", + ) + .bind(ids.check_run_id) + .bind(ids.deployment_id) + .bind(&ids.head_sha) + .bind(deployment_uuid) + .execute(&state.pool) + .await; +} + +/// Cycle de vie GitHub d'un déploiement, piloté par `run_real_deploy`. +pub struct Lifecycle { + started: Arc, + task: tokio::task::JoinHandle>, +} + +impl Lifecycle { + /// Signal « build démarré » (appelé dans le slot du nœud). + pub fn started_signal(&self) -> Arc { + self.started.clone() + } +} + +/// Démarre le suivi (tâche détachée : aucun appel GitHub ne retarde le déploiement). +pub fn begin( + state: &AppState, + project: &Project, + deployment_uuid: &str, + pin_sha: Option<&str>, +) -> Lifecycle { + let started = Arc::new(Notify::new()); + let (state, project, dep, pin) = ( + state.clone(), + project.clone(), + deployment_uuid.to_string(), + pin_sha.map(str::to_string), + ); + let sig = started.clone(); + let task = tokio::spawn(async move { + let reporter = resolve(&state, &project, &dep).await?; + let branch = project + .git_branch + .clone() + .filter(|b| !b.trim().is_empty()) + .unwrap_or_else(|| "main".into()); + let head_sha = match pin { + Some(s) if s.len() == 40 => s, + _ => match reporter.api.branch_head(&branch).await { + Ok(s) => s, + Err(e) => { + tracing::warn!(project = %project.uuid, "GitHub : tête de branche introuvable : {e}"); + return None; + } + }, + }; + let ids = reporter.queued(&head_sha, &dep).await; + save_ids(&state, &dep, &ids).await; + sig.notified().await; + reporter.in_progress(&ids).await; + Some((reporter, ids)) + }); + Lifecycle { started, task } +} + +/// Clôture (tâche détachée). +pub fn end( + state: &AppState, + project: &Project, + lc: Lifecycle, + ok: bool, + cancelled: bool, + logs: String, +) { + let (state, project_uuid) = (state.clone(), project.uuid.clone()); + lc.started.notify_one(); + tokio::spawn(async move { + let Ok(Some((reporter, ids))) = lc.task.await else { + return; + }; + let fin = if cancelled { + Final::Cancelled + } else if ok { + Final::Success { + summary: match &reporter.environment_url { + Some(u) => format!("Déployé sur {u}"), + None => "Déployé.".into(), + }, + } + } else { + failure_output(&logs, &env_values(&state, &project_uuid).await) + }; + reporter.finish(&ids, &fin).await; + }); +} + +#[cfg(test)] +#[path = "github_checks_tests.rs"] +mod tests; diff --git a/apps/server/src/github_checks_tests.rs b/apps/server/src/github_checks_tests.rs new file mode 100644 index 000000000..e9a96e692 --- /dev/null +++ b/apps/server/src/github_checks_tests.rs @@ -0,0 +1,253 @@ +use super::*; +use serde_json::json; +use wiremock::matchers::{body_partial_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const SHA: &str = "6dcb09b5b57875f334f61aebed695e2e4193db5e"; + +fn reporter(uri: &str, mode: Mode) -> Reporter { + Reporter { + api: RepoApi::new(uri, "ghs_testtoken123456", "bobdivx", "demo"), + mode, + details_url: Some( + "https://df.example/app/projects/view?uuid=p&tab=deployments&deployment=d".into(), + ), + environment_url: Some("https://demo.example".into()), + environment: "Production".into(), + retry_delay: Duration::from_millis(1), + } +} + +async fn mount( + gh: &MockServer, + m: &str, + p: &str, + body: serde_json::Value, + resp: serde_json::Value, + n: u64, +) { + Mock::given(method(m)) + .and(path(p)) + .and(body_partial_json(body)) + .respond_with(ResponseTemplate::new(201).set_body_json(resp)) + .expect(n) + .mount(gh) + .await; +} + +async fn deployments(gh: &MockServer, final_state: &str) { + mount(gh, "POST", "/repos/bobdivx/demo/deployments", + json!({"ref": SHA, "environment": "Production", "auto_merge": false, "required_contexts": []}), json!({"id": 9}), 1).await; + mount( + gh, + "POST", + "/repos/bobdivx/demo/deployments/9/statuses", + json!({"state": "in_progress"}), + json!({}), + 1, + ) + .await; + mount( + gh, + "POST", + "/repos/bobdivx/demo/deployments/9/statuses", + json!({"state": final_state}), + json!({}), + 1, + ) + .await; +} + +#[tokio::test] +async fn checks_success_lifecycle() { + let gh = MockServer::start().await; + mount(&gh, "POST", "/repos/bobdivx/demo/check-runs", + json!({"name": "DevForge / deploy", "head_sha": SHA, "status": "queued", "external_id": "dep-1"}), json!({"id": 1}), 1).await; + mount( + &gh, + "PATCH", + "/repos/bobdivx/demo/check-runs/1", + json!({"status": "in_progress"}), + json!({}), + 1, + ) + .await; + mount( + &gh, + "PATCH", + "/repos/bobdivx/demo/check-runs/1", + json!({"status": "completed", "conclusion": "success"}), + json!({}), + 1, + ) + .await; + deployments(&gh, "success").await; + let r = reporter(&gh.uri(), Mode::Checks); + let ids = r.queued(SHA, "dep-1").await; + assert_eq!( + ids, + Ids { + head_sha: SHA.into(), + check_run_id: Some(1), + deployment_id: Some(9) + } + ); + r.in_progress(&ids).await; + r.finish( + &ids, + &Final::Success { + summary: "ok".into(), + }, + ) + .await; + gh.verify().await; +} + +#[tokio::test] +async fn checks_failure_and_cancel() { + let gh = MockServer::start().await; + mount( + &gh, + "POST", + "/repos/bobdivx/demo/check-runs", + json!({"status": "queued"}), + json!({"id": 2}), + 2, + ) + .await; + mount( + &gh, + "PATCH", + "/repos/bobdivx/demo/check-runs/2", + json!({"status": "completed", "conclusion": "failure"}), + json!({}), + 1, + ) + .await; + mount( + &gh, + "PATCH", + "/repos/bobdivx/demo/check-runs/2", + json!({"status": "completed", "conclusion": "cancelled"}), + json!({}), + 1, + ) + .await; + mount( + &gh, + "POST", + "/repos/bobdivx/demo/deployments", + json!({}), + json!({"id": 9}), + 2, + ) + .await; + mount( + &gh, + "POST", + "/repos/bobdivx/demo/deployments/9/statuses", + json!({"state": "failure"}), + json!({}), + 1, + ) + .await; + mount( + &gh, + "POST", + "/repos/bobdivx/demo/deployments/9/statuses", + json!({"state": "inactive"}), + json!({}), + 1, + ) + .await; + let r = reporter(&gh.uri(), Mode::Checks); + let ids = r.queued(SHA, "dep-2").await; + r.finish(&ids, &failure_output("step 1\nnpm ERR! boom\n", &[])) + .await; + let ids = r.queued(SHA, "dep-3").await; + r.finish(&ids, &Final::Cancelled).await; + gh.verify().await; +} + +#[tokio::test] +async fn pat_projects_use_commit_statuses() { + let gh = MockServer::start().await; + let p = format!("/repos/bobdivx/demo/statuses/{SHA}"); + mount( + &gh, + "POST", + &p, + json!({"state": "pending", "context": "devforge/deploy"}), + json!({}), + 2, + ) + .await; + mount( + &gh, + "POST", + &p, + json!({"state": "failure", "context": "devforge/deploy"}), + json!({}), + 1, + ) + .await; + deployments(&gh, "failure").await; + Mock::given(method("POST")) + .and(path("/repos/bobdivx/demo/check-runs")) + .respond_with(ResponseTemplate::new(201)) + .expect(0) + .mount(&gh) + .await; + let r = reporter(&gh.uri(), Mode::Statuses); + let ids = r.queued(SHA, "dep-4").await; + assert_eq!(ids.check_run_id, None); + r.in_progress(&ids).await; + r.finish(&ids, &failure_output("x", &[])).await; + gh.verify().await; +} + +#[tokio::test] +async fn github_network_failure_never_breaks_the_deploy() { + let gh = MockServer::start().await; + Mock::given(wiremock::matchers::any()) + .respond_with(ResponseTemplate::new(502)) + .mount(&gh) + .await; + let r = reporter(&gh.uri(), Mode::Checks); + let ids = r.queued(SHA, "dep-5").await; + assert_eq!(ids.check_run_id, None); + assert_eq!(ids.deployment_id, None); + r.in_progress(&ids).await; + r.finish( + &ids, + &Final::Success { + summary: "ok".into(), + }, + ) + .await; + // 1 essai + 1 nouvel essai par appel : check run + deployment. + assert_eq!(gh.received_requests().await.unwrap().len(), 4); + // Hôte injoignable : idem, pas de panique. + let r = reporter("http://127.0.0.1:9", Mode::Statuses); + let ids = r.queued(SHA, "dep-6").await; + r.finish(&ids, &Final::Cancelled).await; +} + +#[test] +fn failure_output_masks_project_secrets_and_tokens() { + let mut logs: String = (0..80).map(|i| format!("line {i}\n")).collect(); + logs.push_str("DATABASE_URL=postgres://u:SuperSecretPw@db/x\n"); + logs.push_str("fatal: https://x-access-token:ghs_abcdefghijklmnop@github.com/o/r.git\n"); + logs.push_str("token ghp_abcdefghijklmnop12\n"); + let secrets = vec!["SuperSecretPw".to_string(), "ab".to_string()]; + let Final::Failure { text, title, .. } = failure_output(&logs, &secrets) else { + panic!() + }; + assert!(!text.contains("SuperSecretPw")); + assert!(!text.contains("ghs_abcdefghijklmnop")); + assert!(!text.contains("ghp_abcdefghijklmnop12")); + assert!(text.contains("x-access-token:***")); + assert!(!text.contains("line 0\n") && text.contains("line 79")); + assert!(text.lines().count() <= 52); + assert!(!title.is_empty()); +} diff --git a/apps/server/src/infra_routes.rs b/apps/server/src/infra_routes.rs index 07fbf286e..f75aa4b51 100644 --- a/apps/server/src/infra_routes.rs +++ b/apps/server/src/infra_routes.rs @@ -2266,7 +2266,9 @@ pub async fn handle_push( .execute(&state.pool) .await; - let outcome = crate::routes::run_real_deploy(&state, &project, &dep_uuid).await; + // Relance GitHub (« Re-run ») : on déploie exactement le commit demandé. + let pin = payload.get("devforge_pin_sha").and_then(Value::as_str); + let outcome = crate::routes::run_real_deploy_at(&state, &project, &dep_uuid, pin).await; if outcome.cancelled { deployed.push(json!({ "project": project.uuid, diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index e5dc750db..03657123b 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -30,6 +30,7 @@ mod git_routes; mod github_app_events; mod github_app_routes; mod github_auth; +mod github_checks; mod group_routes; mod infra_routes; mod infra_sqlite; diff --git a/apps/server/src/routes.rs b/apps/server/src/routes.rs index 5cfb14a26..ad0491a19 100644 --- a/apps/server/src/routes.rs +++ b/apps/server/src/routes.rs @@ -1444,6 +1444,45 @@ pub(crate) async fn run_real_deploy( state: &AppState, project: &Project, deployment_uuid: &str, +) -> DeployRunOutcome { + run_real_deploy_at(state, project, deployment_uuid, None).await +} + +/// Déploiement, éventuellement fixé sur un commit précis (`pin_sha`, relance GitHub). +/// Le retour d'état GitHub (Checks/statuts/Deployments, P4) est piloté ici : tous les +/// chemins de déploiement prod passent par cette fonction. +pub(crate) async fn run_real_deploy_at( + state: &AppState, + project: &Project, + deployment_uuid: &str, + pin_sha: Option<&str>, +) -> DeployRunOutcome { + let gh_lifecycle = crate::github_checks::begin(state, project, deployment_uuid, pin_sha); + let run = run_real_deploy_inner( + state, + project, + deployment_uuid, + pin_sha, + gh_lifecycle.started_signal(), + ) + .await; + crate::github_checks::end( + state, + project, + gh_lifecycle, + run.result.ok, + run.cancelled, + run.result.logs.clone(), + ); + run +} + +async fn run_real_deploy_inner( + state: &AppState, + project: &Project, + deployment_uuid: &str, + pin_sha: Option<&str>, + build_started: std::sync::Arc, ) -> DeployRunOutcome { { let _start = state.deploy_queue.lock_project_start(project.id).await; @@ -1542,6 +1581,7 @@ pub(crate) async fn run_real_deploy( is_static: project.is_static != 0, github_token: deploy_tok.token, github_token_ephemeral: deploy_tok.ephemeral, + pin_sha: pin_sha.map(str::to_string), env_file, proxy_labels: proxy_labels_for_project(state, project).await, gpu_nvidia: project.gpu_nvidia != 0, @@ -1560,7 +1600,11 @@ pub(crate) async fn run_real_deploy( deployment_uuid, move || { let deploy = deploy.clone(); - async move { deploy.deploy(&req).await } + let started = build_started.clone(); + async move { + started.notify_one(); + deploy.deploy(&req).await + } }, ) .await; diff --git a/crates/deploy/src/lib.rs b/crates/deploy/src/lib.rs index 33c266dd2..8a0e22667 100644 --- a/crates/deploy/src/lib.rs +++ b/crates/deploy/src/lib.rs @@ -139,6 +139,8 @@ pub struct DeployRequest { /// Token d'installation GitHub App éphémère : passé en en-tête HTTP à git, /// jamais écrit dans l'URL du remote ni dans `.git/config`. pub github_token_ephemeral: bool, + /// SHA exact à déployer (relance GitHub « Re-run ») ; `None` = tête de branche. + pub pin_sha: Option, pub env_file: Option, /// Labels Traefik (tous les hosts) appliqués au `docker run`. pub proxy_labels: Option, @@ -374,6 +376,18 @@ fn git_sync_command_header_auth( ) } +/// Suffixe shell qui fixe le dépôt sur un commit précis (après la synchro de branche). +fn pin_sha_suffix(workdir: &str, sha: &str) -> Option { + let sha = sha.trim(); + if !(7..=40).contains(&sha.len()) || !sha.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let w = shell_single_quote(workdir); + Some(format!( + " && git -C {w} fetch origin {sha} && git -C {w} reset --hard {sha}" + )) +} + fn shell_single_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } @@ -722,6 +736,19 @@ impl DeployFacade { git_sync_command(&workdir, &clone_url, branch) } }; + let sync_cmd = match req.pin_sha.as_deref() { + Some(sha) if !cfg!(windows) => match pin_sha_suffix(&workdir, sha) { + Some(suffix) => { + logs.push_str(&format!("[git] commit demandé : {sha}\n")); + format!("{sync_cmd}{suffix}") + } + None => { + logs.push_str("[git] SHA demandé invalide — tête de branche déployée\n"); + sync_cmd + } + }, + _ => sync_cmd, + }; match self.executor.exec(server, "", &sync_cmd, 600).await { Ok(r) => { logs.push_str(&format!( @@ -2067,6 +2094,15 @@ mod tests { assert_eq!(result.len(), 1000); } + #[test] + fn pin_sha_is_validated() { + let s = pin_sha_suffix("/data/w", "6dcb09b5b57875f334f61aebed695e2e4193db5e").unwrap(); + assert!(s.contains("fetch origin 6dcb09b5b57875f334f61aebed695e2e4193db5e")); + assert!(s.contains("reset --hard 6dcb09b5b57875f334f61aebed695e2e4193db5e")); + assert!(pin_sha_suffix("/w", "main; rm -rf /").is_none()); + assert!(pin_sha_suffix("/w", "abc").is_none()); + } + #[cfg(not(windows))] #[test] fn header_auth_never_puts_token_in_url_or_config() { diff --git a/crates/github/src/checks.rs b/crates/github/src/checks.rs new file mode 100644 index 000000000..2d039cbb3 --- /dev/null +++ b/crates/github/src/checks.rs @@ -0,0 +1,267 @@ +//! Checks API, commit statuses et Deployments GitHub (P4-01 / P4-03). +//! +//! Toutes les fonctions sont « best effort » : l'appelant journalise l'erreur sans jamais +//! faire échouer un déploiement. Aucun token n'apparaît dans les erreurs. + +use serde_json::{json, Value}; + +pub const CHECK_NAME: &str = "DevForge / deploy"; +pub const STATUS_CONTEXT: &str = "devforge/deploy"; +/// Limite GitHub pour `output.text` / `output.summary` (65 535 caractères). +pub const OUTPUT_MAX: usize = 60_000; + +#[derive(Clone)] +pub struct RepoApi { + http: reqwest::Client, + api: String, + token: crate::app_auth::SecretString, + pub owner: String, + pub repo: String, +} + +impl std::fmt::Debug for RepoApi { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RepoApi({}/{}, token: ***)", self.owner, self.repo) + } +} + +/// `owner/repo` depuis une URL ou un nom de dépôt GitHub. +pub fn parse_repo(raw: &str) -> Option<(String, String)> { + let r = raw.trim().trim_end_matches('/').trim_end_matches(".git"); + let path = if let Some(p) = r.strip_prefix("git@github.com:") { + p + } else if let Some(i) = r.find("github.com/") { + &r[i + "github.com/".len()..] + } else if r.contains("://") { + return None; + } else { + r + }; + let mut it = path.split('/'); + let (o, n) = (it.next()?, it.next()?); + if o.is_empty() || n.is_empty() || it.next().is_some() { + return None; + } + let ok = |s: &str| { + !s.starts_with('.') + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) + }; + (ok(o) && ok(n)).then(|| (o.to_string(), n.to_string())) +} + +fn clip(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +impl RepoApi { + pub fn new(api: impl Into, token: &str, owner: &str, repo: &str) -> Self { + Self { + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .unwrap_or_default(), + api: api.into().trim_end_matches('/').to_string(), + token: crate::app_auth::SecretString::new(token), + owner: owner.into(), + repo: repo.into(), + } + } + + fn url(&self, tail: &str) -> String { + format!("{}/repos/{}/{}{tail}", self.api, self.owner, self.repo) + } + + async fn send(&self, req: reqwest::RequestBuilder) -> Result { + let res = req + .bearer_auth(self.token.expose()) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "devforge-server/2") + .send() + .await + .map_err(|e| format!("GitHub injoignable : {}", e.without_url()))?; + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("GitHub a répondu {}", status.as_u16())); + } + Ok(serde_json::from_str(&body).unwrap_or(Value::Null)) + } + + /// SHA complet de la tête d'une branche. + pub async fn branch_head(&self, branch: &str) -> Result { + let v = self + .send( + self.http + .get(self.url(&format!("/commits/{}", urlencode(branch)))), + ) + .await?; + v.get("sha") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "sha absent".into()) + } + + pub async fn create_check_run( + &self, + head_sha: &str, + details_url: Option<&str>, + external_id: &str, + ) -> Result { + let mut body = json!({ + "name": CHECK_NAME, "head_sha": head_sha, "status": "queued", + "external_id": external_id, + }); + if let Some(u) = details_url { + body["details_url"] = json!(u); + } + let v = self + .send(self.http.post(self.url("/check-runs")).json(&body)) + .await?; + v.get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "id absent".into()) + } + + /// `status` : queued | in_progress | completed ; `conclusion` requis si completed. + pub async fn update_check_run( + &self, + id: i64, + status: &str, + conclusion: Option<&str>, + output: Option<(&str, &str, &str)>, + ) -> Result<(), String> { + let mut body = json!({ "status": status }); + if let Some(c) = conclusion { + body["conclusion"] = json!(c); + } + if let Some((title, summary, text)) = output { + body["output"] = json!({ + "title": clip(title, 200), + "summary": clip(summary, OUTPUT_MAX), + "text": clip(text, OUTPUT_MAX), + }); + } + self.send( + self.http + .patch(self.url(&format!("/check-runs/{id}"))) + .json(&body), + ) + .await + .map(|_| ()) + } + + /// Statut de commit (`pending|success|failure|error`), repli pour les projets en PAT. + pub async fn create_status( + &self, + sha: &str, + state: &str, + description: &str, + target_url: Option<&str>, + ) -> Result<(), String> { + let mut body = json!({ + "state": state, "context": STATUS_CONTEXT, + "description": clip(description, 139), + }); + if let Some(u) = target_url { + body["target_url"] = json!(u); + } + self.send( + self.http + .post(self.url(&format!("/statuses/{sha}"))) + .json(&body), + ) + .await + .map(|_| ()) + } + + pub async fn create_deployment(&self, sha: &str, environment: &str) -> Result { + let body = json!({ + "ref": sha, "environment": environment, "auto_merge": false, + "required_contexts": [], "description": "DevForge", + "production_environment": environment == "Production", + "transient_environment": environment != "Production", + }); + let v = self + .send(self.http.post(self.url("/deployments")).json(&body)) + .await?; + v.get("id") + .and_then(Value::as_i64) + .ok_or_else(|| "id absent".into()) + } + + /// `state` : in_progress | success | failure | error | inactive. + pub async fn create_deployment_status( + &self, + deployment_id: i64, + state: &str, + log_url: Option<&str>, + environment_url: Option<&str>, + ) -> Result<(), String> { + let mut body = json!({ "state": state, "auto_inactive": state == "success" }); + if let Some(u) = log_url { + body["log_url"] = json!(u); + } + if let Some(u) = environment_url { + body["environment_url"] = json!(u); + } + self.send( + self.http + .post(self.url(&format!("/deployments/{deployment_id}/statuses"))) + .json(&body), + ) + .await + .map(|_| ()) + } +} + +fn urlencode(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_repo_variants() { + let r = |s| parse_repo(s).map(|(a, b)| format!("{a}/{b}")); + assert_eq!( + r("https://github.com/bobdivx/devforge"), + Some("bobdivx/devforge".into()) + ); + assert_eq!( + r("https://github.com/bobdivx/devforge.git"), + Some("bobdivx/devforge".into()) + ); + assert_eq!( + r("git@github.com:bobdivx/devforge.git"), + Some("bobdivx/devforge".into()) + ); + assert_eq!(r("bobdivx/devforge"), Some("bobdivx/devforge".into())); + assert_eq!(r("https://gitlab.com/a/b"), None); + assert_eq!(r("a/b/c"), None); + assert_eq!(r("../x"), None); + } + + #[test] + fn clip_is_char_safe() { + assert_eq!(clip("éé", 3), "é…"); + assert_eq!(clip("abc", 5), "abc"); + } +} diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index f1cbac9f5..3e0e59951 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -5,6 +5,7 @@ use serde_json::Value; use std::sync::{Arc, RwLock}; pub mod app_auth; +pub mod checks; pub mod http; pub use http::HttpGitHubClient;