Ajouter le dispatcher e-mail des alertes CI
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
9
ops/ci-alert-email-dispatch.path
Normal file
9
ops/ci-alert-email-dispatch.path
Normal 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
|
||||||
49
ops/ci-alert-email-dispatch.py
Normal file
49
ops/ci-alert-email-dispatch.py
Normal 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())
|
||||||
8
ops/ci-alert-email-dispatch.service
Normal file
8
ops/ci-alert-email-dispatch.service
Normal 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
|
||||||
13
ops/install-ci-alert-email-dispatch.sh
Normal file
13
ops/install-ci-alert-email-dispatch.sh
Normal 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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
31
src/backend/test/ci-email-alert.test.js
Normal file
31
src/backend/test/ci-email-alert.test.js
Normal 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');
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user