From 5c3d644ab83c43c0fa83188a6a083d2b0dc25ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manus=20D=C3=A9ploiement=20Santinova?= Date: Fri, 21 Aug 2026 20:28:13 +0000 Subject: [PATCH] Ajouter le dispatcher e-mail des alertes CI --- docker-compose.yml | 2 + ops/ci-alert-email-dispatch.path | 9 +++++ ops/ci-alert-email-dispatch.py | 49 +++++++++++++++++++++++++ ops/ci-alert-email-dispatch.service | 8 ++++ ops/install-ci-alert-email-dispatch.sh | 13 +++++++ src/backend/package.json | 2 +- src/backend/src/ci-alerts.js | 39 +++++++++++++++++++- src/backend/src/config.js | 2 + src/backend/test/ci-email-alert.test.js | 31 ++++++++++++++++ src/docker-compose.yml | 2 + 10 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 ops/ci-alert-email-dispatch.path create mode 100644 ops/ci-alert-email-dispatch.py create mode 100644 ops/ci-alert-email-dispatch.service create mode 100644 ops/install-ci-alert-email-dispatch.sh create mode 100644 src/backend/test/ci-email-alert.test.js diff --git a/docker-compose.yml b/docker-compose.yml index a0410d0..1de61c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,8 @@ services: - WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur} - IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org} - IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-} + - DSI_ALERT_EMAIL=${DSI_ALERT_EMAIL:-} + - CI_ALERT_EMAIL_QUEUE=/opt/manus-deploy/secrets/ci-alerts/outbox - APPS_BASE_PATH=/opt/manus-deploy/apps - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure # Contrôles bornés et dédoublonnés par healthcheck.js. diff --git a/ops/ci-alert-email-dispatch.path b/ops/ci-alert-email-dispatch.path new file mode 100644 index 0000000..78a9297 --- /dev/null +++ b/ops/ci-alert-email-dispatch.path @@ -0,0 +1,9 @@ +[Unit] +Description=Surveille la file des alertes CI Santinova + +[Path] +PathChanged=/opt/manus-deploy/secrets/ci-alerts/outbox +Unit=ci-alert-email-dispatch.service + +[Install] +WantedBy=multi-user.target diff --git a/ops/ci-alert-email-dispatch.py b/ops/ci-alert-email-dispatch.py new file mode 100644 index 0000000..bc20b95 --- /dev/null +++ b/ops/ci-alert-email-dispatch.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Expédie les alertes CI mises en file par le dashboard depuis l’hôte de recette.""" + +import json +import subprocess +import sys +from email.message import EmailMessage +from pathlib import Path + + +OUTBOX = Path("/opt/manus-deploy/secrets/ci-alerts/outbox") +ARCHIVE = Path("/var/log/manus-deploy/ci-alerts-email-sent.json") + + +def send_alert(file_path: Path) -> None: + payload = json.loads(file_path.read_text(encoding="utf-8")) + recipient = str(payload.get("to", "")).strip() + subject = str(payload.get("subject", "")).strip() + text = str(payload.get("text", "")).strip() + if not recipient or "@" not in recipient or not subject or not text: + raise ValueError(f"Alerte CI invalide : {file_path.name}") + message = EmailMessage() + message["To"] = recipient + message["From"] = "alertes-ci@itinova.org" + message["Subject"] = subject + message.set_content(text) + subprocess.run(["/usr/sbin/sendmail", "-t", "-oi"], input=message.as_bytes(), check=True, timeout=30) + ARCHIVE.parent.mkdir(parents=True, exist_ok=True) + ARCHIVE.write_text(json.dumps({"id": payload.get("id"), "sentAt": payload.get("createdAt")}) + "\n", encoding="utf-8") + file_path.unlink() + + +def main() -> int: + OUTBOX.mkdir(parents=True, exist_ok=True, mode=0o700) + failures = [] + for file_path in sorted(OUTBOX.glob("*.json")): + try: + send_alert(file_path) + except Exception as error: # Le fichier reste en file pour réessai et investigation. + print(f"CI_EMAIL_DISPATCH_FAILED {file_path.name}: {error}", file=sys.stderr) + failures.append(file_path.name) + if failures: + return 1 + print("CI_EMAIL_DISPATCH_OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ops/ci-alert-email-dispatch.service b/ops/ci-alert-email-dispatch.service new file mode 100644 index 0000000..03abdd4 --- /dev/null +++ b/ops/ci-alert-email-dispatch.service @@ -0,0 +1,8 @@ +[Unit] +Description=Envoi hôte des alertes CI Santinova vers le DSI +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/opt/manus-deploy/scripts/ci-alert-email-dispatch.py diff --git a/ops/install-ci-alert-email-dispatch.sh b/ops/install-ci-alert-email-dispatch.sh new file mode 100644 index 0000000..54270d2 --- /dev/null +++ b/ops/install-ci-alert-email-dispatch.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}" +readonly SCRIPT_DIR="/opt/manus-deploy/scripts" +readonly OUTBOX_DIR="/opt/manus-deploy/secrets/ci-alerts/outbox" + +install -d -m 0700 "${OUTBOX_DIR}" +install -m 0700 "${APP_DIR}/ops/ci-alert-email-dispatch.py" "${SCRIPT_DIR}/ci-alert-email-dispatch.py" +install -m 0644 "${APP_DIR}/ops/ci-alert-email-dispatch.service" /etc/systemd/system/ci-alert-email-dispatch.service +install -m 0644 "${APP_DIR}/ops/ci-alert-email-dispatch.path" /etc/systemd/system/ci-alert-email-dispatch.path +systemctl daemon-reload +systemctl enable --now ci-alert-email-dispatch.path diff --git a/src/backend/package.json b/src/backend/package.json index 1ead78e..05268d9 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", - "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js" + "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/ci-email-alert.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js" }, "dependencies": { "express": "^4.21.0", diff --git a/src/backend/src/ci-alerts.js b/src/backend/src/ci-alerts.js index a48b153..7c96884 100644 --- a/src/backend/src/ci-alerts.js +++ b/src/backend/src/ci-alerts.js @@ -6,6 +6,40 @@ const LOG_DIR = '/var/log/manus-deploy'; const ALERTS_FILE = path.join(LOG_DIR, 'ci-alerts.json'); const MAX_ALERTS = 100; +function formatEmailAlert(alert) { + return [ + 'Une validation CI a échoué sur l’infrastructure Santinova.', + '', + `Dépôt : ${alert.repository}`, + `Workflow : ${alert.workflow}`, + `Conclusion : ${alert.conclusion}`, + `Commit : ${alert.commit}`, + `Date : ${alert.occurredAt}`, + alert.url ? `Détail : ${alert.url}` : null, + ].filter(Boolean).join('\n'); +} + +function enqueueDsiEmail(alert) { + if (!config.dsiAlerts.email) return false; + const queueRoot = config.dsiAlerts.emailQueue; + const safeId = String(alert.id).replace(/[^a-zA-Z0-9._-]/g, '_'); + const queueFile = queueRoot.endsWith('.json') + ? queueRoot + : path.join(queueRoot, `${safeId}.json`); + const temporary = `${queueFile}.${process.pid}.tmp`; + const payload = { + id: alert.id, + to: config.dsiAlerts.email, + subject: `[Santinova] Échec CI — ${alert.repository}`, + text: formatEmailAlert(alert), + createdAt: new Date().toISOString(), + }; + fs.mkdirSync(path.dirname(queueFile), { recursive: true, mode: 0o700 }); + fs.writeFileSync(temporary, JSON.stringify(payload, null, 2), { mode: 0o600 }); + fs.renameSync(temporary, queueFile); + return true; +} + function normalizeWorkflowFailure(payload, deliveryId = null) { if (payload?.action !== 'completed') return null; const run = payload?.workflow_run || {}; @@ -46,8 +80,9 @@ function saveCiAlert(alert) { } async function notifyDsi(alert) { + if (enqueueDsiEmail(alert)) return true; if (!config.dsiAlerts.token) { - console.error('[CI Alert] IA_REFERENTIEL_TOKEN absent : notification DSI non envoyée.'); + console.error('[CI Alert] Aucun canal direct DSI configuré : e-mail et IA_REFERENTIEL_TOKEN absents.'); return false; } try { @@ -79,4 +114,4 @@ async function notifyDsi(alert) { } } -module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi }; +module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi, enqueueDsiEmail }; diff --git a/src/backend/src/config.js b/src/backend/src/config.js index 3725514..c3c1d2e 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -33,6 +33,8 @@ module.exports = { dsiAlerts: { url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia.recette.santinova-soft.org', token: process.env.IA_REFERENTIEL_TOKEN || null, + email: process.env.DSI_ALERT_EMAIL || null, + emailQueue: process.env.CI_ALERT_EMAIL_QUEUE || '/opt/manus-deploy/secrets/ci-alerts/outbox', }, appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps', diff --git a/src/backend/test/ci-email-alert.test.js b/src/backend/test/ci-email-alert.test.js new file mode 100644 index 0000000..8de2b66 --- /dev/null +++ b/src/backend/test/ci-email-alert.test.js @@ -0,0 +1,31 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-email-alert-')); +process.env.DSI_ALERT_EMAIL = 'dsi@example.invalid'; +process.env.CI_ALERT_EMAIL_QUEUE = path.join(temporaryDirectory, 'queued-alert.json'); + +const { enqueueDsiEmail } = require('../src/ci-alerts'); + +const queued = enqueueDsiEmail({ + id: 'ci-email-test', + repository: 'manus-admin/referentiel-ia', + workflow: 'Validation CI', + commit: 'abc1234', + conclusion: 'failure', + url: 'https://git.recette.santinova-soft.org/actions/runs/42', + occurredAt: '2026-08-21T18:00:00Z', +}); + +assert.equal(queued, true); +const payload = JSON.parse(fs.readFileSync(process.env.CI_ALERT_EMAIL_QUEUE, 'utf8')); +assert.equal(payload.to, 'dsi@example.invalid'); +assert.equal(payload.id, 'ci-email-test'); +assert.match(payload.subject, /Échec CI/); +assert.match(payload.text, /referentiel-ia/); +assert.equal(fs.statSync(process.env.CI_ALERT_EMAIL_QUEUE).mode & 0o777, 0o600); + +fs.rmSync(temporaryDirectory, { recursive: true, force: true }); +console.log('OK ci email alert'); diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 3bb23f7..c8c31ed 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -19,6 +19,8 @@ services: - WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur} - IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org} - IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-} + - DSI_ALERT_EMAIL=${DSI_ALERT_EMAIL:-} + - CI_ALERT_EMAIL_QUEUE=/opt/manus-deploy/secrets/ci-alerts/outbox - APPS_BASE_PATH=/opt/manus-deploy/apps - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure - HEALTH_CHECK_INTERVAL=30000