Ajouter le dispatcher e-mail des alertes CI

This commit is contained in:
Manus Déploiement Santinova
2026-08-21 20:28:13 +00:00
parent c693da6273
commit 5c3d644ab8
10 changed files with 154 additions and 3 deletions

View File

@@ -24,6 +24,8 @@ services:
- WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur} - 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_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}
- IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-} - 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 - APPS_BASE_PATH=/opt/manus-deploy/apps
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
# Contrôles bornés et dédoublonnés par healthcheck.js. # Contrôles bornés et dédoublonnés par healthcheck.js.

View File

@@ -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

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "nodemon 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": { "dependencies": {
"express": "^4.21.0", "express": "^4.21.0",

View File

@@ -6,6 +6,40 @@ const LOG_DIR = '/var/log/manus-deploy';
const ALERTS_FILE = path.join(LOG_DIR, 'ci-alerts.json'); const ALERTS_FILE = path.join(LOG_DIR, 'ci-alerts.json');
const MAX_ALERTS = 100; 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) { function normalizeWorkflowFailure(payload, deliveryId = null) {
if (payload?.action !== 'completed') return null; if (payload?.action !== 'completed') return null;
const run = payload?.workflow_run || {}; const run = payload?.workflow_run || {};
@@ -46,8 +80,9 @@ function saveCiAlert(alert) {
} }
async function notifyDsi(alert) { async function notifyDsi(alert) {
if (enqueueDsiEmail(alert)) return true;
if (!config.dsiAlerts.token) { 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; return false;
} }
try { try {
@@ -79,4 +114,4 @@ async function notifyDsi(alert) {
} }
} }
module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi }; module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi, enqueueDsiEmail };

View File

@@ -33,6 +33,8 @@ module.exports = {
dsiAlerts: { dsiAlerts: {
url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia.recette.santinova-soft.org', url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia.recette.santinova-soft.org',
token: process.env.IA_REFERENTIEL_TOKEN || null, 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', appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',

View File

@@ -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');

View File

@@ -19,6 +19,8 @@ services:
- WEBHOOK_SECRET=${WEBHOOK_SECRET:?WEBHOOK_SECRET requis dans le fichier .env serveur} - 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_URL=${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}
- IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:-} - 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 - APPS_BASE_PATH=/opt/manus-deploy/apps
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure - INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
- HEALTH_CHECK_INTERVAL=30000 - HEALTH_CHECK_INTERVAL=30000