Sécuriser Gitea et alerter sur les échecs CI
This commit is contained in:
@@ -18,10 +18,12 @@ services:
|
||||
- ADMIN_PASSWORD=Itinova69!
|
||||
- GITEA_URL=https://git.recette.santinova-soft.org
|
||||
- GITEA_USERNAME=manus-admin
|
||||
- GITEA_PASSWORD=ManusGitea2026!
|
||||
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur}
|
||||
- GITEA_RECETTE_URL=http://gitea:3000
|
||||
- GITEA_PASSWORD_PROD=ManusGitea2026!
|
||||
- GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-}
|
||||
- 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:-}
|
||||
- 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.
|
||||
|
||||
9
ops/gitea-token-rotation-apply.path
Normal file
9
ops/gitea-token-rotation-apply.path
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Surveillance de la validation manuelle du jeton Gitea Santinova
|
||||
|
||||
[Path]
|
||||
PathExists=/opt/manus-deploy/secrets/gitea-token-rotation/approval.request
|
||||
Unit=gitea-token-rotation-apply.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
11
ops/gitea-token-rotation-apply.service
Normal file
11
ops/gitea-token-rotation-apply.service
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Application manuelle validée du jeton Gitea Santinova
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh apply
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
12
ops/gitea-token-rotation.service
Normal file
12
ops/gitea-token-rotation.service
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Préparation contrôlée de la rotation du jeton Gitea Santinova
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/gitea-token-rotation.sh prepare
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
Nice=10
|
||||
148
ops/gitea-token-rotation.sh
Normal file
148
ops/gitea-token-rotation.sh
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly COMMAND="${1:-status}"
|
||||
readonly FORCE="${2:-}"
|
||||
readonly GITEA_CONTAINER="${GITEA_CONTAINER:-gitea}"
|
||||
readonly GITEA_URL="${GITEA_URL:-https://git.recette.santinova-soft.org}"
|
||||
readonly GITEA_USER="${GITEA_USERNAME:-manus-admin}"
|
||||
readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
|
||||
readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
|
||||
readonly STATE_DIR="${GITEA_ROTATION_STATE_DIR:-/opt/manus-deploy/secrets/gitea-token-rotation}"
|
||||
readonly STATUS_FILE="${GITEA_ROTATION_STATUS_FILE:-/var/log/manus-deploy/gitea-token-rotation.json}"
|
||||
readonly PENDING_TOKEN_FILE="${STATE_DIR}/pending.token"
|
||||
readonly PENDING_NAME_FILE="${STATE_DIR}/pending.name"
|
||||
readonly CURRENT_NAME_FILE="${STATE_DIR}/current.name"
|
||||
readonly LAST_ROTATION_FILE="${STATE_DIR}/last-rotation"
|
||||
readonly LOCK_FILE="${STATE_DIR}/rotation.lock"
|
||||
readonly APPROVAL_FILE="${STATE_DIR}/approval.request"
|
||||
readonly TOKEN_PREFIX="santinova-recette"
|
||||
readonly MAX_AGE_DAYS="90"
|
||||
|
||||
install -d -m 0700 "${STATE_DIR}"
|
||||
install -d -m 0750 "$(dirname "${STATUS_FILE}")"
|
||||
exec 9>"${LOCK_FILE}"
|
||||
flock -n 9 || { echo "Une opération de rotation Gitea est déjà en cours." >&2; exit 1; }
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/[[:cntrl:]]/ /g'
|
||||
}
|
||||
|
||||
write_status() {
|
||||
local state="$1"
|
||||
local message="$2"
|
||||
local token_name="${3:-}"
|
||||
local temporary
|
||||
temporary="$(mktemp "${STATUS_FILE}.XXXXXX")"
|
||||
chmod 0640 "${temporary}"
|
||||
printf '{"state":"%s","message":"%s","tokenName":"%s","updatedAt":"%s"}\n' \
|
||||
"$(json_escape "${state}")" \
|
||||
"$(json_escape "${message}")" \
|
||||
"$(json_escape "${token_name}")" \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "${temporary}"
|
||||
mv "${temporary}" "${STATUS_FILE}"
|
||||
}
|
||||
|
||||
read_env_token() {
|
||||
sed -n -E 's/^GITEA_TOKEN=(.*)$/\1/p' "${DASHBOARD_ENV_FILE}" | tail -n 1
|
||||
}
|
||||
|
||||
validate_token() {
|
||||
local token="$1"
|
||||
local response
|
||||
response="$(mktemp /tmp/gitea-token-validation.XXXXXX)"
|
||||
trap 'rm -f "${response}"' RETURN
|
||||
local status
|
||||
status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/user")"
|
||||
[[ "${status}" == "200" ]] || return 1
|
||||
grep -q '"login":"manus-admin"' "${response}"
|
||||
status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/manus-dashboard")"
|
||||
[[ "${status}" == "200" ]] || return 1
|
||||
status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/referentiel-ia")"
|
||||
[[ "${status}" == "200" ]]
|
||||
}
|
||||
|
||||
rotation_due() {
|
||||
[[ "${FORCE}" == "--force" ]] && return 0
|
||||
[[ -f "${LAST_ROTATION_FILE}" ]] || return 0
|
||||
local last_epoch now_epoch max_age_seconds
|
||||
last_epoch="$(date -d "$(<"${LAST_ROTATION_FILE}")" +%s 2>/dev/null || echo 0)"
|
||||
now_epoch="$(date +%s)"
|
||||
max_age_seconds="$((MAX_AGE_DAYS * 86400))"
|
||||
(( now_epoch - last_epoch >= max_age_seconds ))
|
||||
}
|
||||
|
||||
prepare_rotation() {
|
||||
if [[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]]; then
|
||||
local existing_pending
|
||||
existing_pending="$(<"${PENDING_TOKEN_FILE}")"
|
||||
validate_token "${existing_pending}"
|
||||
write_status "pending_approval" "Un jeton préparé et validé attend déjà une approbation manuelle." "$(<"${PENDING_NAME_FILE}")"
|
||||
echo "GITEA_ROTATION_PENDING"
|
||||
return 0
|
||||
fi
|
||||
if ! rotation_due; then
|
||||
write_status "current" "La rotation trimestrielle n’est pas encore requise." "$(cat "${CURRENT_NAME_FILE}" 2>/dev/null || true)"
|
||||
echo "GITEA_ROTATION_NOT_DUE"
|
||||
return 0
|
||||
fi
|
||||
local current_token token_name pending_token
|
||||
current_token="$(read_env_token)"
|
||||
[[ -n "${current_token}" ]] || { write_status "failed" "Jeton actif absent du fichier .env serveur."; exit 1; }
|
||||
validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe pas les contrôles Gitea."; exit 1; }
|
||||
token_name="${TOKEN_PREFIX}-$(date -u +%Y%m%d-%H%M%S)"
|
||||
pending_token="$(docker exec --user git "${GITEA_CONTAINER}" gitea admin user generate-access-token \
|
||||
--username "${GITEA_USER}" --token-name "${token_name}" --scopes all --raw)"
|
||||
[[ -n "${pending_token}" ]] || { write_status "failed" "Gitea n’a pas généré de nouveau jeton." "${token_name}"; exit 1; }
|
||||
printf '%s' "${pending_token}" > "${PENDING_TOKEN_FILE}"
|
||||
printf '%s' "${token_name}" > "${PENDING_NAME_FILE}"
|
||||
chmod 0600 "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
if ! validate_token "${pending_token}"; then
|
||||
rm -f "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
write_status "failed" "Le nouveau jeton a été créé mais n’a pas passé les contrôles." "${token_name}"
|
||||
exit 1
|
||||
fi
|
||||
write_status "pending_approval" "Nouveau jeton créé et validé. Approbation manuelle requise avant basculement." "${token_name}"
|
||||
echo "GITEA_ROTATION_PREPARED"
|
||||
}
|
||||
|
||||
apply_rotation() {
|
||||
trap 'rm -f "${APPROVAL_FILE}"' EXIT
|
||||
[[ -s "${PENDING_TOKEN_FILE}" && -s "${PENDING_NAME_FILE}" ]] || { write_status "failed" "Aucun jeton préparé à appliquer."; exit 1; }
|
||||
local pending_token pending_name current_token backup
|
||||
pending_token="$(<"${PENDING_TOKEN_FILE}")"
|
||||
pending_name="$(<"${PENDING_NAME_FILE}")"
|
||||
current_token="$(read_env_token)"
|
||||
validate_token "${pending_token}" || { write_status "failed" "Le jeton en attente n’est plus valide." "${pending_name}"; exit 1; }
|
||||
validate_token "${current_token}" || { write_status "failed" "Le jeton actif ne passe plus les contrôles préalables." "${pending_name}"; exit 1; }
|
||||
backup="$(mktemp "${STATE_DIR}/dashboard.env.backup.XXXXXX")"
|
||||
chmod 0600 "${backup}"
|
||||
cp "${DASHBOARD_ENV_FILE}" "${backup}"
|
||||
sed -i -E "s/^GITEA_TOKEN=.*/GITEA_TOKEN=${pending_token}/" "${DASHBOARD_ENV_FILE}"
|
||||
if ! grep -q '^GITEA_TOKEN=' "${DASHBOARD_ENV_FILE}"; then
|
||||
printf '\nGITEA_TOKEN=%s\n' "${pending_token}" >> "${DASHBOARD_ENV_FILE}"
|
||||
fi
|
||||
write_status "applying" "Basculement du dashboard vers le jeton validé." "${pending_name}"
|
||||
if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null && sleep 8 && validate_token "${pending_token}"); then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
(cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
|
||||
rm -f "${backup}"
|
||||
write_status "rollback" "Le basculement a échoué ; l’ancien jeton a été restauré." "${pending_name}"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "${backup}" "${PENDING_TOKEN_FILE}" "${PENDING_NAME_FILE}"
|
||||
printf '%s' "${pending_name}" > "${CURRENT_NAME_FILE}"
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "${LAST_ROTATION_FILE}"
|
||||
chmod 0600 "${CURRENT_NAME_FILE}" "${LAST_ROTATION_FILE}"
|
||||
write_status "active_revoke_previous" "Basculement validé. Révoquer manuellement l’ancien jeton dans Gitea après le contrôle final." "${pending_name}"
|
||||
echo "GITEA_ROTATION_APPLIED"
|
||||
}
|
||||
|
||||
case "${COMMAND}" in
|
||||
prepare) prepare_rotation ;;
|
||||
apply) apply_rotation ;;
|
||||
status)
|
||||
if [[ -f "${STATUS_FILE}" ]]; then cat "${STATUS_FILE}"; else write_status "not_configured" "Aucune rotation n’a encore été préparée."; cat "${STATUS_FILE}"; fi
|
||||
;;
|
||||
*) echo "Usage: $0 {prepare [--force]|apply|status}" >&2; exit 2 ;;
|
||||
esac
|
||||
11
ops/gitea-token-rotation.timer
Normal file
11
ops/gitea-token-rotation.timer
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Préparation trimestrielle du jeton Gitea Santinova
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-01,04,07,10-01 07:00:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=30m
|
||||
Unit=gitea-token-rotation.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
18
ops/install-gitea-token-rotation.sh
Normal file
18
ops/install-gitea-token-rotation.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
install -D -m 0700 "${SCRIPT_DIR}/gitea-token-rotation.sh" /opt/manus-deploy/scripts/gitea-token-rotation.sh
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.service" /etc/systemd/system/gitea-token-rotation.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation.timer" /etc/systemd/system/gitea-token-rotation.timer
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.service" /etc/systemd/system/gitea-token-rotation-apply.service
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-token-rotation-apply.path" /etc/systemd/system/gitea-token-rotation-apply.path
|
||||
install -d -m 0700 /opt/manus-deploy/secrets/gitea-token-rotation
|
||||
install -d -m 0750 /var/log/manus-deploy
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitea-token-rotation.timer
|
||||
systemctl enable --now gitea-token-rotation-apply.path
|
||||
systemctl is-enabled gitea-token-rotation.timer
|
||||
systemctl is-enabled gitea-token-rotation-apply.path
|
||||
systemctl list-timers gitea-token-rotation.timer --no-pager
|
||||
48
ops/provision-dashboard-alert-token.sh
Normal file
48
ops/provision-dashboard-alert-token.sh
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly DASHBOARD_APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
|
||||
readonly DASHBOARD_ENV_FILE="${DASHBOARD_ENV_FILE:-${DASHBOARD_APP_DIR}/.env}"
|
||||
readonly REFERENTIEL_CONTAINER="${REFERENTIEL_CONTAINER:-referentiel-ia}"
|
||||
readonly REFERENTIEL_URL="${IA_REFERENTIEL_URL:-https://referentiel-ia.recette.santinova-soft.org}"
|
||||
|
||||
backup="$(mktemp /tmp/manus-dashboard-env-backup.XXXXXX)"
|
||||
temporary="$(mktemp /tmp/manus-dashboard-env-next.XXXXXX)"
|
||||
chmod 0600 "${backup}" "${temporary}"
|
||||
cp "${DASHBOARD_ENV_FILE}" "${backup}"
|
||||
|
||||
cleanup() {
|
||||
rm -f "${backup}" "${temporary}"
|
||||
unset token result
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
result="$(docker exec "${REFERENTIEL_CONTAINER}" sh -lc 'cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts prepare')"
|
||||
token="${result%%$'\t'*}"
|
||||
token_id="${result##*$'\t'}"
|
||||
[[ -n "${token}" && "${token_id}" =~ ^[0-9]+$ ]] || { echo "Le Référentiel IA n’a pas retourné un jeton exploitable." >&2; exit 1; }
|
||||
|
||||
awk -v value="${token}" '
|
||||
BEGIN { replaced = 0 }
|
||||
/^IA_REFERENTIEL_TOKEN=/ { print "IA_REFERENTIEL_TOKEN=" value; replaced = 1; next }
|
||||
{ print }
|
||||
END { if (!replaced) print "IA_REFERENTIEL_TOKEN=" value }
|
||||
' "${DASHBOARD_ENV_FILE}" > "${temporary}"
|
||||
mv "${temporary}" "${DASHBOARD_ENV_FILE}"
|
||||
chmod 0600 "${DASHBOARD_ENV_FILE}"
|
||||
|
||||
if ! (cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null); then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 8
|
||||
health="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${token}" "${REFERENTIEL_URL}/api/integration/v1/alerts/ci/health")"
|
||||
if [[ "${health}" != "204" ]]; then
|
||||
cp "${backup}" "${DASHBOARD_ENV_FILE}"
|
||||
(cd "${DASHBOARD_APP_DIR}" && docker compose up -d --force-recreate dashboard >/dev/null) || true
|
||||
echo "Le nouveau jeton d’alerte a échoué au contrôle HTTP (${health}); rollback effectué." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec "${REFERENTIEL_CONTAINER}" sh -lc "cd /app && corepack pnpm exec tsx scripts/dashboard-alert-token.ts finalize ${token_id}" >/dev/null
|
||||
echo "DASHBOARD_ALERT_TOKEN_PROVISIONED"
|
||||
116
ops/setup-gitea-webhooks.py
Normal file
116
ops/setup-gitea-webhooks.py
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://git.recette.santinova-soft.org").rstrip("/")
|
||||
GITEA_USER = os.environ.get("GITEA_USERNAME", "manus-admin")
|
||||
DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "https://dashboard.recette.santinova-soft.org").rstrip("/")
|
||||
DASHBOARD_APP_DIR = Path(os.environ.get("DASHBOARD_APP_DIR", "/opt/manus-deploy/apps/manus-dashboard"))
|
||||
ENV_FILE = DASHBOARD_APP_DIR / ".env"
|
||||
WEBHOOK_ENDPOINT = f"{DASHBOARD_URL}/api/webhook/gitea"
|
||||
|
||||
|
||||
def read_env() -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if ENV_FILE.exists():
|
||||
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
||||
if line and not line.lstrip().startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def update_env(key: str, value: str) -> None:
|
||||
lines = ENV_FILE.read_text(encoding="utf-8").splitlines() if ENV_FILE.exists() else []
|
||||
updated: list[str] = []
|
||||
found = False
|
||||
for line in lines:
|
||||
if line.startswith(f"{key}="):
|
||||
updated.append(f"{key}={value}")
|
||||
found = True
|
||||
else:
|
||||
updated.append(line)
|
||||
if not found:
|
||||
updated.append(f"{key}={value}")
|
||||
temporary = ENV_FILE.with_suffix(".env.next")
|
||||
temporary.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(ENV_FILE)
|
||||
|
||||
|
||||
def api(token: str, method: str, path: str, payload: dict | None = None):
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{GITEA_URL}/api/v1{path}",
|
||||
method=method,
|
||||
data=data,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
body = response.read()
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = error.read().decode(errors="replace")
|
||||
raise RuntimeError(f"Gitea {method} {path} : HTTP {error.code} {detail[:300]}") from error
|
||||
|
||||
|
||||
def ensure_dashboard_secret(values: dict[str, str]) -> tuple[dict[str, str], bool]:
|
||||
if values.get("WEBHOOK_SECRET"):
|
||||
return values, False
|
||||
update_env("WEBHOOK_SECRET", secrets.token_hex(32))
|
||||
return read_env(), True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
values, generated = ensure_dashboard_secret(read_env())
|
||||
token = values.get("GITEA_TOKEN", "")
|
||||
secret = values.get("WEBHOOK_SECRET", "")
|
||||
if not token or not secret:
|
||||
raise RuntimeError("GITEA_TOKEN et WEBHOOK_SECRET sont requis dans le .env serveur.")
|
||||
if generated:
|
||||
subprocess.run(["docker", "compose", "up", "-d", "--force-recreate", "dashboard"], cwd=DASHBOARD_APP_DIR, check=True)
|
||||
time.sleep(8)
|
||||
identity = api(token, "GET", "/user")
|
||||
if identity.get("login") != GITEA_USER:
|
||||
raise RuntimeError("Le jeton Gitea n’appartient pas au compte attendu.")
|
||||
repositories = api(token, "GET", "/user/repos?limit=100&page=1") or []
|
||||
configured = 0
|
||||
for repository in repositories:
|
||||
owner = repository.get("owner", {}).get("login")
|
||||
name = repository.get("name")
|
||||
if owner != GITEA_USER or not name:
|
||||
continue
|
||||
hooks = api(token, "GET", f"/repos/{owner}/{name}/hooks") or []
|
||||
existing = next((hook for hook in hooks if hook.get("config", {}).get("url") == WEBHOOK_ENDPOINT), None)
|
||||
payload = {
|
||||
"active": True,
|
||||
"branch_filter": "main",
|
||||
"config": {"url": WEBHOOK_ENDPOINT, "content_type": "json", "secret": secret},
|
||||
"events": ["push", "workflow_run"],
|
||||
}
|
||||
if existing:
|
||||
api(token, "PATCH", f"/repos/{owner}/{name}/hooks/{existing['id']}", payload)
|
||||
else:
|
||||
api(token, "POST", f"/repos/{owner}/{name}/hooks", {"type": "gitea", **payload})
|
||||
configured += 1
|
||||
print(f"WEBHOOK_OK {owner}/{name}")
|
||||
if configured == 0:
|
||||
raise RuntimeError("Aucun dépôt Gitea n’a été configuré.")
|
||||
print(f"GITEA_WEBHOOKS_CONFIGURED {configured}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
print(f"GITEA_WEBHOOK_SETUP_FAILED: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { exec, execSync } = require('child_process');
|
||||
const config = require('./config');
|
||||
const { createRepo, giteaClient } = require('./gitea');
|
||||
const { refreshApps, writeAppManifest } = require('./app-registry');
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
async function createGiteaGitAuth() {
|
||||
if (!config.gitea.token) throw new Error('GITEA_TOKEN est requis pour les opérations Git.');
|
||||
const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'dashboard-gitea-auth-'));
|
||||
const tokenPath = path.join(directory, 'token');
|
||||
const askpassPath = path.join(directory, 'askpass.sh');
|
||||
await fsp.writeFile(tokenPath, config.gitea.token, { mode: 0o600 });
|
||||
await fsp.writeFile(askpassPath, `#!/bin/sh\ncase "$1" in\n *Username*|*username*) printf '%s\\n' '${config.gitea.username}' ;;\n *) cat '${tokenPath}' ;;\nesac\n`, { mode: 0o700 });
|
||||
return {
|
||||
env: { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: '0' },
|
||||
cleanup: () => fsp.rm(directory, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGiteaRepoUrl(value) {
|
||||
const url = new URL(value);
|
||||
const configured = new URL(config.gitea.url);
|
||||
if (url.protocol !== 'https:' || url.hostname !== configured.hostname) {
|
||||
throw new Error('Le dépôt doit utiliser le serveur Gitea Santinova configuré en HTTPS.');
|
||||
}
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// ============ TEMPLATES DOCKERFILE ============
|
||||
|
||||
const DOCKERFILE_TEMPLATES = {
|
||||
@@ -436,6 +465,7 @@ async function createApplication(params, logCallback) {
|
||||
const domain = `${subdomain}.recette.santinova-soft.org`;
|
||||
const appDir = path.join(config.appsBasePath, appId);
|
||||
const srcDir = path.join(appDir, 'src');
|
||||
let gitAuth = null;
|
||||
|
||||
const log = (msg) => {
|
||||
const line = `[${new Date().toISOString()}] ${msg}`;
|
||||
@@ -444,6 +474,7 @@ async function createApplication(params, logCallback) {
|
||||
};
|
||||
|
||||
try {
|
||||
gitAuth = await createGiteaGitAuth();
|
||||
// 1. Créer le répertoire de l'application
|
||||
log(`Création du répertoire ${appDir}...`);
|
||||
await fsp.mkdir(appDir, { recursive: true });
|
||||
@@ -455,11 +486,8 @@ async function createApplication(params, logCallback) {
|
||||
if (sourceType === 'gitea') {
|
||||
// 2a. Cloner depuis un dépôt Gitea existant
|
||||
log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`);
|
||||
const cloneUrl = giteaRepoUrl.replace(
|
||||
'https://',
|
||||
`https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@`
|
||||
);
|
||||
await execCommand(`cd ${appDir} && rm -rf src && git clone ${cloneUrl} src`, 120000);
|
||||
const cloneUrl = normalizeGiteaRepoUrl(giteaRepoUrl);
|
||||
await execCommand(`cd ${shellQuote(appDir)} && rm -rf src && git clone ${shellQuote(cloneUrl)} src`, 120000, gitAuth.env);
|
||||
log('Clonage terminé.');
|
||||
|
||||
// Extraire le nom du repo depuis l'URL
|
||||
@@ -503,10 +531,11 @@ async function createApplication(params, logCallback) {
|
||||
|
||||
// Initialiser git et pousser le code
|
||||
log('Initialisation Git et push vers Gitea...');
|
||||
const gitUrl = `https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@${config.gitea.url.replace('https://', '')}/${config.gitea.username}/${appId}.git`;
|
||||
const gitUrl = `${config.gitea.url.replace(/\/$/, '')}/${config.gitea.username}/${appId}.git`;
|
||||
await execCommand(
|
||||
`cd ${srcDir} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m "Initial commit: ${name}" && git remote add origin ${gitUrl} && git push -u origin main --force`,
|
||||
120000
|
||||
`cd ${shellQuote(srcDir)} && git init && git checkout -b main && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add -A && git commit -m ${shellQuote(`Initial commit: ${name}`)} && git remote add origin ${shellQuote(gitUrl)} && git push -u origin main --force`,
|
||||
120000,
|
||||
gitAuth.env
|
||||
);
|
||||
log('Code poussé sur Gitea.');
|
||||
}
|
||||
@@ -548,7 +577,8 @@ async function createApplication(params, logCallback) {
|
||||
if (sourceType === 'zip') {
|
||||
await execCommand(
|
||||
`cd ${srcDir} && git config user.email "admin@santinova-soft.org" && git config user.name "Manus Dashboard" && git add Dockerfile && git commit -m "Add auto-generated Dockerfile" && git push origin main`,
|
||||
60000
|
||||
60000,
|
||||
gitAuth.env
|
||||
);
|
||||
log('Dockerfile commité et poussé sur Gitea.');
|
||||
}
|
||||
@@ -642,12 +672,14 @@ async function createApplication(params, logCallback) {
|
||||
} catch (err) {
|
||||
log(`ERREUR: ${err.message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
await gitAuth?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function execCommand(cmd, timeout = 60000) {
|
||||
function execCommand(cmd, timeout = 60000, env = process.env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout }, (error, stdout, stderr) => {
|
||||
exec(cmd, { maxBuffer: 1024 * 1024 * 50, timeout, env }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`));
|
||||
} else {
|
||||
|
||||
82
src/backend/src/ci-alerts.js
Normal file
82
src/backend/src/ci-alerts.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const LOG_DIR = '/var/log/manus-deploy';
|
||||
const ALERTS_FILE = path.join(LOG_DIR, 'ci-alerts.json');
|
||||
const MAX_ALERTS = 100;
|
||||
|
||||
function normalizeWorkflowFailure(payload, deliveryId = null) {
|
||||
if (payload?.action !== 'completed') return null;
|
||||
const run = payload?.workflow_run || {};
|
||||
const conclusion = String(run.conclusion || '').toLowerCase();
|
||||
if (!conclusion || conclusion === 'success') return null;
|
||||
const repository = payload?.repository?.full_name || payload?.repository?.name || 'dépôt inconnu';
|
||||
const commit = run.head_sha || run.head_commit?.id || 'commit-inconnu';
|
||||
return {
|
||||
id: deliveryId || `${repository}-${run.id || commit}-${run.completed_at || Date.now()}`,
|
||||
repository,
|
||||
workflow: payload?.workflow?.name || run.name || run.workflow_id || 'Validation CI',
|
||||
commit,
|
||||
conclusion,
|
||||
url: run.html_url || null,
|
||||
occurredAt: run.completed_at || new Date().toISOString(),
|
||||
receivedAt: new Date().toISOString(),
|
||||
dsiNotificationSent: false,
|
||||
};
|
||||
}
|
||||
|
||||
function readCiAlerts() {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'));
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveCiAlert(alert) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
const alerts = readCiAlerts().filter((candidate) => candidate.id !== alert.id);
|
||||
alerts.unshift(alert);
|
||||
const temporary = `${ALERTS_FILE}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(alerts.slice(0, MAX_ALERTS), null, 2), { mode: 0o640 });
|
||||
fs.renameSync(temporary, ALERTS_FILE);
|
||||
return alert;
|
||||
}
|
||||
|
||||
async function notifyDsi(alert) {
|
||||
if (!config.dsiAlerts.token) {
|
||||
console.error('[CI Alert] IA_REFERENTIEL_TOKEN absent : notification DSI non envoyée.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${config.dsiAlerts.url.replace(/\/$/, '')}/api/integration/v1/alerts/ci`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${config.dsiAlerts.token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: alert.repository,
|
||||
workflow: alert.workflow,
|
||||
commit: alert.commit,
|
||||
conclusion: alert.conclusion,
|
||||
url: alert.url,
|
||||
occurredAt: alert.occurredAt,
|
||||
deliveryId: alert.id,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`[CI Alert] Notification DSI refusée (${response.status}).`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[CI Alert] Notification DSI impossible:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { normalizeWorkflowFailure, readCiAlerts, saveCiAlert, notifyDsi };
|
||||
@@ -25,8 +25,14 @@ module.exports = {
|
||||
gitea: {
|
||||
url: process.env.GITEA_URL || 'http://gitea:3000',
|
||||
username: process.env.GITEA_USERNAME || 'manus-admin',
|
||||
password: process.env.GITEA_PASSWORD || 'Itinova69!',
|
||||
token: process.env.GITEA_TOKEN || null,
|
||||
productionUrl: process.env.GITEA_PROD_URL || 'https://git.santinova-soft.org',
|
||||
productionToken: process.env.GITEA_TOKEN_PROD || null,
|
||||
},
|
||||
|
||||
dsiAlerts: {
|
||||
url: process.env.IA_REFERENTIEL_URL || 'https://referentiel-ia.recette.santinova-soft.org',
|
||||
token: process.env.IA_REFERENTIEL_TOKEN || null,
|
||||
},
|
||||
|
||||
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
|
||||
|
||||
@@ -2,18 +2,9 @@ const axios = require('axios');
|
||||
const https = require('https');
|
||||
const config = require('./config');
|
||||
|
||||
// Créer un client axios pour Gitea (ignorer les certificats auto-signés si nécessaire)
|
||||
// Utiliser le token API si disponible, sinon auth basique
|
||||
const giteaClientHeaders = config.gitea.token
|
||||
? { 'Authorization': `token ${config.gitea.token}` }
|
||||
: {};
|
||||
|
||||
const giteaClient = axios.create({
|
||||
baseURL: `${config.gitea.url}/api/v1`,
|
||||
...(config.gitea.token
|
||||
? { headers: giteaClientHeaders }
|
||||
: { auth: { username: config.gitea.username, password: config.gitea.password } }
|
||||
),
|
||||
headers: config.gitea.token ? { Authorization: `token ${config.gitea.token}` } : {},
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -155,6 +146,35 @@ async function getWorkflowRunSummary(owner, repo) {
|
||||
return calculateCiSummary(await getWorkflowRuns(owner, repo, 30));
|
||||
}
|
||||
|
||||
async function getRepoSnapshot(baseUrl, owner, repo, token, publicBaseUrl = baseUrl) {
|
||||
if (!token) return { present: false, url: null, version: null };
|
||||
const client = axios.create({
|
||||
baseURL: `${baseUrl}/api/v1`,
|
||||
headers: { Authorization: `token ${token}` },
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 7000,
|
||||
});
|
||||
try {
|
||||
const [repoResponse, commitsResponse] = await Promise.all([
|
||||
client.get(`/repos/${owner}/${repo}`),
|
||||
client.get(`/repos/${owner}/${repo}/commits`, { params: { limit: 1 } }),
|
||||
]);
|
||||
const commit = commitsResponse.data?.[0];
|
||||
const sha = commit?.sha?.substring(0, 7) || null;
|
||||
const date = commit?.commit?.author?.date
|
||||
? new Date(commit.commit.author.date).toLocaleDateString('fr-FR')
|
||||
: null;
|
||||
return {
|
||||
present: Boolean(repoResponse.data?.id),
|
||||
url: `${publicBaseUrl}/${owner}/${repo}`,
|
||||
version: sha && date ? `${sha} (${date})` : sha,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Erreur Gitea getRepoSnapshot ${owner}/${repo}:`, error.message);
|
||||
return { present: false, url: null, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
||||
*/
|
||||
@@ -201,6 +221,7 @@ module.exports = {
|
||||
getWorkflowRuns,
|
||||
getWorkflowRunSummary,
|
||||
getWorkflowRunForCommit,
|
||||
getRepoSnapshot,
|
||||
normalizeWorkflowRun,
|
||||
calculateCiSummary,
|
||||
listRepos,
|
||||
|
||||
@@ -99,6 +99,9 @@ async function runHealthChecks() {
|
||||
|
||||
// Démarrage
|
||||
async function start() {
|
||||
if (!config.gitea.token) {
|
||||
throw new Error('GITEA_TOKEN est requis : aucun retour à une authentification par mot de passe n’est autorisé.');
|
||||
}
|
||||
await initAuth();
|
||||
|
||||
// Charger les applications dynamiques
|
||||
|
||||
@@ -2,11 +2,13 @@ const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execFile } = require('child_process');
|
||||
const { authenticate, authMiddleware } = require('./auth');
|
||||
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
||||
const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, listRepos } = require('./gitea');
|
||||
const { getRepo, getCommits, getBranches, getWorkflowRuns, getWorkflowRunSummary, getRepoSnapshot, listRepos } = require('./gitea');
|
||||
const { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
||||
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
||||
const { readCiAlerts } = require('./ci-alerts');
|
||||
const config = require('./config');
|
||||
|
||||
// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent)
|
||||
@@ -541,17 +543,63 @@ router.get('/system/metrics', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/ci-alerts', authMiddleware, (req, res) => {
|
||||
res.json(readCiAlerts());
|
||||
});
|
||||
|
||||
const GITEA_ROTATION_SCRIPT = '/opt/manus-deploy/scripts/gitea-token-rotation.sh';
|
||||
const GITEA_ROTATION_STATUS_FILE = '/var/log/manus-deploy/gitea-token-rotation.json';
|
||||
const GITEA_ROTATION_APPROVAL_FILE = '/opt/manus-deploy/secrets/gitea-token-rotation/approval.request';
|
||||
|
||||
function readGiteaRotationStatus() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(GITEA_ROTATION_STATUS_FILE, 'utf8'));
|
||||
} catch {
|
||||
return { state: 'not_configured', message: 'Aucune rotation n’a encore été préparée.', tokenName: '', updatedAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
function runGiteaRotation(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(GITEA_ROTATION_SCRIPT, args, { timeout: 120000 }, (error, stdout, stderr) => {
|
||||
if (error) return reject(new Error((stderr || stdout || error.message).trim()));
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/security/gitea-token-rotation', authMiddleware, (req, res) => {
|
||||
res.json(readGiteaRotationStatus());
|
||||
});
|
||||
|
||||
router.post('/security/gitea-token-rotation/prepare', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await runGiteaRotation(['prepare', '--force']);
|
||||
res.json(readGiteaRotationStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message, status: readGiteaRotationStatus() });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/security/gitea-token-rotation/approve', authMiddleware, (req, res) => {
|
||||
const status = readGiteaRotationStatus();
|
||||
if (status.state !== 'pending_approval') {
|
||||
return res.status(409).json({ error: 'Aucun jeton validé n’attend une approbation.', status });
|
||||
}
|
||||
fs.mkdirSync(path.dirname(GITEA_ROTATION_APPROVAL_FILE), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(GITEA_ROTATION_APPROVAL_FILE, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
||||
res.status(202).json({ ...status, state: 'approval_requested', message: 'Basculement demandé à l’hôte. Le dashboard sera brièvement redémarré après validation.' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// ===== INVENTAIRE DES APPLICATIONS =====
|
||||
const { exec } = require('child_process');
|
||||
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
|
||||
const GITEA_RECETTE_PUBLIC = process.env.GITEA_RECETTE_PUBLIC_URL || 'https://git.recette.santinova-soft.org';
|
||||
const GITEA_PROD_EXTERNAL = 'https://git.santinova-soft.org';
|
||||
const GITEA_PROD_EXTERNAL = config.gitea.productionUrl;
|
||||
const GITEA_USER_INV = process.env.GITEA_USERNAME || 'manus-admin';
|
||||
const GITEA_PASS_REC = process.env.GITEA_PASSWORD || 'Itinova69!';
|
||||
const GITEA_PASS_PRD = process.env.GITEA_PASSWORD_PROD || 'ManusGitea2026!';
|
||||
const GITEA_TOKEN_REC = process.env.GITEA_RECETTE_TOKEN || process.env.GITEA_TOKEN || null;
|
||||
const GITEA_TOKEN_PRD = config.gitea.productionToken;
|
||||
|
||||
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
|
||||
function getInventoryApps() {
|
||||
@@ -562,41 +610,6 @@ function getInventoryApps() {
|
||||
}));
|
||||
}
|
||||
|
||||
function curlGitea(baseUrl, owner, repo, pass, publicBaseUrl, token) {
|
||||
return new Promise((resolve) => {
|
||||
const authHeader = token
|
||||
? `-H "Authorization: token ${token}"`
|
||||
: `-u "${GITEA_USER_INV}:${pass}"`;
|
||||
const cmd = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}"`;
|
||||
exec(cmd, { timeout: 7000 }, (err, stdout) => {
|
||||
if (err || !stdout) return resolve({ present: false, url: null, version: null });
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
if (!data.id) return resolve({ present: false, url: null, version: null });
|
||||
// Récupérer le dernier commit
|
||||
const cmd2 = `curl -sk --max-time 6 ${authHeader} "${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=1"`;
|
||||
exec(cmd2, { timeout: 7000 }, (err2, stdout2) => {
|
||||
let version = null;
|
||||
try {
|
||||
const commits = JSON.parse(stdout2 || '[]');
|
||||
if (commits.length > 0) {
|
||||
const c = commits[0];
|
||||
const sha = c.sha ? c.sha.substring(0, 7) : null;
|
||||
const date = c.commit && c.commit.author && c.commit.author.date
|
||||
? new Date(c.commit.author.date).toLocaleDateString('fr-FR') : null;
|
||||
version = sha && date ? `${sha} (${date})` : sha;
|
||||
}
|
||||
} catch(e) {}
|
||||
const displayUrl = publicBaseUrl || baseUrl;
|
||||
resolve({ present: true, url: `${displayUrl}/${owner}/${repo}`, version });
|
||||
});
|
||||
} catch(e) {
|
||||
resolve({ present: false, url: null, version: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/inventory', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const owner = GITEA_USER_INV;
|
||||
@@ -604,8 +617,8 @@ router.get('/inventory', authMiddleware, async (req, res) => {
|
||||
const results = await Promise.all(
|
||||
INVENTORY_APPS.map(async (app) => {
|
||||
const [repoRecette, repoProd] = await Promise.all([
|
||||
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
|
||||
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
|
||||
getRepoSnapshot(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_TOKEN_REC, GITEA_RECETTE_PUBLIC),
|
||||
getRepoSnapshot(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_TOKEN_PRD),
|
||||
]);
|
||||
return { ...app, repoRecette, repoProd };
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const fs = require('fs');
|
||||
const config = require('./config');
|
||||
const { findAppByRepo } = require('./app-registry');
|
||||
const { getWorkflowRunForCommit } = require('./gitea');
|
||||
const { normalizeWorkflowFailure, notifyDsi, saveCiAlert } = require('./ci-alerts');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -160,6 +161,22 @@ router.post('/gitea', (req, res) => {
|
||||
return res.status(401).json({ error: 'Signature invalide' });
|
||||
}
|
||||
const event = req.headers['x-gitea-event'] || 'unknown';
|
||||
if (event === 'workflow_run') {
|
||||
const alert = normalizeWorkflowFailure(req.body, req.headers['x-gitea-delivery'] || null);
|
||||
if (!alert) return res.status(200).json({ message: 'Workflow sans échec ignoré' });
|
||||
saveCiAlert(alert);
|
||||
global.wsBroadcast?.({ type: 'ci_alert', data: alert });
|
||||
res.status(202).json({ message: 'Alerte CI enregistrée', id: alert.id });
|
||||
setImmediate(async () => {
|
||||
const sent = await notifyDsi(alert);
|
||||
if (sent) {
|
||||
const notifiedAlert = { ...alert, dsiNotificationSent: true };
|
||||
saveCiAlert(notifiedAlert);
|
||||
global.wsBroadcast?.({ type: 'ci_alert', data: notifiedAlert });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event !== 'push') {
|
||||
return res.status(200).json({ message: `Événement ${event} ignoré` });
|
||||
}
|
||||
|
||||
22
src/backend/test/ci-alerts.test.js
Normal file
22
src/backend/test/ci-alerts.test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const assert = require('assert');
|
||||
const { normalizeWorkflowFailure } = require('../src/ci-alerts');
|
||||
|
||||
const failure = normalizeWorkflowFailure({
|
||||
action: 'completed',
|
||||
repository: { full_name: 'manus-admin/referentiel-ia' },
|
||||
workflow: { name: 'Validation CI' },
|
||||
workflow_run: {
|
||||
id: 42,
|
||||
conclusion: 'failure',
|
||||
head_sha: '1234567890abcdef',
|
||||
completed_at: '2026-08-21T12:00:00.000Z',
|
||||
html_url: 'https://git.recette.santinova-soft.org/actions/runs/42',
|
||||
},
|
||||
}, 'delivery-42');
|
||||
|
||||
assert.equal(failure.id, 'delivery-42');
|
||||
assert.equal(failure.repository, 'manus-admin/referentiel-ia');
|
||||
assert.equal(failure.conclusion, 'failure');
|
||||
assert.equal(normalizeWorkflowFailure({ action: 'completed', workflow_run: { conclusion: 'success' } }), null);
|
||||
assert.equal(normalizeWorkflowFailure({ action: 'in_progress', workflow_run: { conclusion: 'failure' } }), null);
|
||||
console.log('OK ci alerts');
|
||||
13
src/backend/test/dashboard-alert-token.test.js
Normal file
13
src/backend/test/dashboard-alert-token.test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const source = fs.readFileSync(path.join(root, 'ops/provision-dashboard-alert-token.sh'), 'utf8');
|
||||
|
||||
assert.match(source, /dashboard-alert-token\.ts prepare/);
|
||||
assert.match(source, /alerts\/ci\/health/);
|
||||
assert.match(source, /dashboard-alert-token\.ts finalize/);
|
||||
assert.match(source, /rollback effectué/);
|
||||
assert.doesNotMatch(source, /GITEA_PASSWORD/);
|
||||
console.log('OK dashboard alert token');
|
||||
23
src/backend/test/gitea-auth.test.js
Normal file
23
src/backend/test/gitea-auth.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const files = [
|
||||
'backend/src/config.js',
|
||||
'backend/src/gitea.js',
|
||||
'backend/src/app-creator.js',
|
||||
'backend/src/routes.js',
|
||||
'docker-compose.yml',
|
||||
];
|
||||
|
||||
for (const relativePath of files) {
|
||||
const content = fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||
assert.equal(content.includes('GITEA_PASSWORD'), false, `${relativePath} contient encore GITEA_PASSWORD`);
|
||||
assert.equal(content.includes('config.gitea.password'), false, `${relativePath} utilise encore le mot de passe Gitea`);
|
||||
}
|
||||
|
||||
const compose = fs.readFileSync(path.resolve(root, '../docker-compose.yml'), 'utf8');
|
||||
assert.match(compose, /GITEA_TOKEN=\$\{GITEA_TOKEN:\?/);
|
||||
assert.equal(/GITEA_TOKEN=[a-f0-9]{20,}/.test(compose), false);
|
||||
console.log('OK gitea auth');
|
||||
19
src/backend/test/token-rotation.test.js
Normal file
19
src/backend/test/token-rotation.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const rotation = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.sh'), 'utf8');
|
||||
const timer = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation.timer'), 'utf8');
|
||||
const approvalPath = fs.readFileSync(path.join(root, 'ops/gitea-token-rotation-apply.path'), 'utf8');
|
||||
|
||||
assert.match(rotation, /generate-access-token/);
|
||||
assert.match(rotation, /pending_approval/);
|
||||
assert.match(rotation, /GITEA_ROTATION_APPLIED/);
|
||||
assert.match(rotation, /chmod 0600/);
|
||||
assert.match(rotation, /rollback/);
|
||||
assert.doesNotMatch(rotation, /GITEA_PASSWORD/);
|
||||
assert.match(timer, /OnCalendar=\*-01,04,07,10-01 07:00:00/);
|
||||
assert.match(timer, /Persistent=true/);
|
||||
assert.match(approvalPath, /PathExists=.*approval\.request/);
|
||||
console.log('OK token rotation');
|
||||
14
src/backend/test/webhook-setup.test.js
Normal file
14
src/backend/test/webhook-setup.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
const setup = fs.readFileSync(path.join(root, 'ops/setup-gitea-webhooks.py'), 'utf8');
|
||||
const compose = fs.readFileSync(path.join(root, 'docker-compose.yml'), 'utf8');
|
||||
|
||||
assert.match(setup, /"events": \["push", "workflow_run"\]/);
|
||||
assert.match(setup, /secrets\.token_hex\(32\)/);
|
||||
assert.match(setup, /method=method/);
|
||||
assert.doesNotMatch(setup, /GITEA_PASSWORD/);
|
||||
assert.match(compose, /WEBHOOK_SECRET=\$\{WEBHOOK_SECRET:\?/);
|
||||
console.log('OK webhook setup');
|
||||
@@ -13,10 +13,12 @@ services:
|
||||
- ADMIN_PASSWORD=Itinova69!
|
||||
- GITEA_URL=https://git.recette.santinova-soft.org
|
||||
- GITEA_USERNAME=manus-admin
|
||||
- GITEA_PASSWORD=ManusGitea2026!
|
||||
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur}
|
||||
- GITEA_RECETTE_URL=http://gitea:3000
|
||||
- GITEA_PASSWORD_PROD=ManusGitea2026!
|
||||
- GITEA_TOKEN_PROD=${GITEA_TOKEN_PROD:-}
|
||||
- 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:-}
|
||||
- APPS_BASE_PATH=/opt/manus-deploy/apps
|
||||
- INFRA_BASE_PATH=/opt/manus-deploy/infrastructure
|
||||
- HEALTH_CHECK_INTERVAL=30000
|
||||
|
||||
@@ -10,13 +10,15 @@ import DocumentationPage from './pages/DocumentationPage';
|
||||
import InventairePage from './pages/InventairePage';
|
||||
import TerminalPage from './pages/TerminalPage';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import CiAlertBanner from './components/CiAlertBanner';
|
||||
import useWebSocket from './hooks/useWebSocket';
|
||||
import { getMe, getApps, logout as apiLogout } from './utils/api';
|
||||
import { getMe, getApps, getCiAlerts, logout as apiLogout } from './utils/api';
|
||||
export default function App() {
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||
const [apps, setApps] = useState([]);
|
||||
const [ciAlerts, setCiAlerts] = useState([]);
|
||||
// Vérifier l'authentification au chargement
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('dashboard_token');
|
||||
@@ -25,6 +27,7 @@ export default function App() {
|
||||
.then(() => {
|
||||
setAuthenticated(true);
|
||||
fetchApps();
|
||||
fetchCiAlerts();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('dashboard_token');
|
||||
@@ -43,11 +46,22 @@ export default function App() {
|
||||
console.error('Erreur chargement apps:', err);
|
||||
}
|
||||
};
|
||||
const fetchCiAlerts = async () => {
|
||||
try {
|
||||
const response = await getCiAlerts();
|
||||
setCiAlerts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement alertes CI:', error);
|
||||
}
|
||||
};
|
||||
// WebSocket handler
|
||||
const handleWsMessage = useCallback((data) => {
|
||||
if (data.type === 'health_update') {
|
||||
setApps(data.data);
|
||||
}
|
||||
if (data.type === 'ci_alert') {
|
||||
setCiAlerts((current) => [data.data, ...current.filter((alert) => alert.id !== data.data.id)]);
|
||||
}
|
||||
}, []);
|
||||
const { connected: wsConnected } = useWebSocket(
|
||||
authenticated ? handleWsMessage : null
|
||||
@@ -55,6 +69,7 @@ export default function App() {
|
||||
const handleLogin = (data) => {
|
||||
setAuthenticated(true);
|
||||
fetchApps();
|
||||
fetchCiAlerts();
|
||||
};
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@@ -65,6 +80,7 @@ export default function App() {
|
||||
localStorage.removeItem('dashboard_token');
|
||||
setAuthenticated(false);
|
||||
setApps([]);
|
||||
setCiAlerts([]);
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -126,7 +142,10 @@ export default function App() {
|
||||
onLogout={handleLogout}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
<main className="ml-64 p-8">{renderPage()}</main>
|
||||
<main className="ml-64 p-8">
|
||||
<CiAlertBanner alert={ciAlerts[0]} />
|
||||
{renderPage()}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
24
src/frontend/src/components/CiAlertBanner.jsx
Normal file
24
src/frontend/src/components/CiAlertBanner.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
|
||||
export default function CiAlertBanner({ alert }) {
|
||||
if (!alert) return null;
|
||||
return (
|
||||
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-red-100 shadow-lg shadow-red-950/20" role="alert">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-300" />
|
||||
<div>
|
||||
<p className="font-semibold">Échec CI — {alert.repository}</p>
|
||||
<p className="mt-1 text-sm text-red-100/80">{alert.workflow} · {alert.commit?.slice(0, 12)} · {alert.conclusion}</p>
|
||||
<p className="mt-1 text-xs text-red-100/60">Notification DSI : {alert.dsiNotificationSent ? 'envoyée' : 'en attente ou indisponible'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{alert.url && (
|
||||
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="btn-secondary flex shrink-0 items-center gap-2">
|
||||
Voir le workflow <ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Minus,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { getGiteaRepos, getGiteaCommits, getGiteaWorkflowRuns, getGiteaWorkflowSummary } from '../utils/api';
|
||||
import { approveGiteaTokenRotation, getGiteaRepos, getGiteaCommits, getGiteaTokenRotation, getGiteaWorkflowRuns, getGiteaWorkflowSummary, prepareGiteaTokenRotation } from '../utils/api';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
@@ -54,6 +56,34 @@ export default function GiteaPage() {
|
||||
const [workflowRuns, setWorkflowRuns] = useState([]);
|
||||
const [workflowSummary, setWorkflowSummary] = useState(null);
|
||||
const [loadingCommits, setLoadingCommits] = useState(false);
|
||||
const [rotation, setRotation] = useState(null);
|
||||
const [rotationBusy, setRotationBusy] = useState(false);
|
||||
const [rotationError, setRotationError] = useState('');
|
||||
|
||||
const fetchRotation = async () => {
|
||||
try {
|
||||
const response = await getGiteaTokenRotation();
|
||||
setRotation(response.data);
|
||||
} catch (error) {
|
||||
setRotationError(error.response?.data?.error || 'Statut de rotation indisponible.');
|
||||
}
|
||||
};
|
||||
|
||||
const runRotationAction = async (action) => {
|
||||
setRotationBusy(true);
|
||||
setRotationError('');
|
||||
try {
|
||||
const response = action === 'prepare'
|
||||
? await prepareGiteaTokenRotation()
|
||||
: await approveGiteaTokenRotation();
|
||||
setRotation(response.data);
|
||||
} catch (error) {
|
||||
setRotationError(error.response?.data?.error || 'Opération de rotation impossible.');
|
||||
if (error.response?.data?.status) setRotation(error.response.data.status);
|
||||
} finally {
|
||||
setRotationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRepos = async () => {
|
||||
setLoading(true);
|
||||
@@ -92,6 +122,7 @@ export default function GiteaPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchRepos();
|
||||
fetchRotation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -124,6 +155,31 @@ export default function GiteaPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-5 border border-primary-500/20 bg-primary-500/5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-primary-500/10 p-2"><KeyRound className="h-5 w-5 text-primary-300" /></div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Rotation contrôlée du jeton Gitea</h3>
|
||||
<p className="mt-1 text-sm text-gray-400">{rotation?.message || 'Chargement du statut…'}</p>
|
||||
{rotation?.tokenName && <p className="mt-1 text-xs font-mono text-gray-500">{rotation.tokenName}</p>}
|
||||
{rotation?.updatedAt && <p className="mt-1 text-xs text-gray-500">Mis à jour : {formatDate(rotation.updatedAt)}</p>}
|
||||
{rotationError && <p className="mt-2 text-sm text-red-300">{rotationError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button disabled={rotationBusy} onClick={() => runRotationAction('prepare')} className="btn-secondary flex items-center gap-2 disabled:opacity-50">
|
||||
<RefreshCw className={`h-4 w-4 ${rotationBusy ? 'animate-spin' : ''}`} /> Préparer maintenant
|
||||
</button>
|
||||
{rotation?.state === 'pending_approval' && (
|
||||
<button disabled={rotationBusy} onClick={() => runRotationAction('approve')} className="btn-primary flex items-center gap-2 disabled:opacity-50">
|
||||
<ShieldCheck className="h-4 w-4" /> Approuver le basculement
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Repos List */}
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -54,6 +54,7 @@ export const createApp = (formData) =>
|
||||
// Deployments
|
||||
export const getDeployments = (appId) =>
|
||||
api.get('/deployments', { params: { appId } });
|
||||
export const getCiAlerts = () => api.get('/ci-alerts');
|
||||
|
||||
// Gitea
|
||||
export const getGiteaRepos = () => api.get('/gitea/repos');
|
||||
@@ -63,6 +64,9 @@ export const getGiteaWorkflowRuns = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
|
||||
export const getGiteaWorkflowSummary = (owner, repo) =>
|
||||
api.get(`/gitea/repos/${owner}/${repo}/actions/summary`);
|
||||
export const getGiteaTokenRotation = () => api.get('/security/gitea-token-rotation');
|
||||
export const prepareGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/prepare');
|
||||
export const approveGiteaTokenRotation = () => api.post('/security/gitea-token-rotation/approve');
|
||||
|
||||
// Docker
|
||||
export const getContainers = () => api.get('/docker/containers');
|
||||
|
||||
Reference in New Issue
Block a user