Sécuriser Gitea et alerter sur les échecs CI
This commit is contained in:
@@ -18,10 +18,12 @@ services:
|
|||||||
- ADMIN_PASSWORD=Itinova69!
|
- ADMIN_PASSWORD=Itinova69!
|
||||||
- GITEA_URL=https://git.recette.santinova-soft.org
|
- GITEA_URL=https://git.recette.santinova-soft.org
|
||||||
- GITEA_USERNAME=manus-admin
|
- GITEA_USERNAME=manus-admin
|
||||||
- GITEA_PASSWORD=ManusGitea2026!
|
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur}
|
||||||
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
|
|
||||||
- GITEA_RECETTE_URL=http://gitea:3000
|
- 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
|
- 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/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": {
|
"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"
|
"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": {
|
"dependencies": {
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const fsp = require('fs/promises');
|
const fsp = require('fs/promises');
|
||||||
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { exec, execSync } = require('child_process');
|
const { exec, execSync } = require('child_process');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const { createRepo, giteaClient } = require('./gitea');
|
const { createRepo, giteaClient } = require('./gitea');
|
||||||
const { refreshApps, writeAppManifest } = require('./app-registry');
|
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 ============
|
// ============ TEMPLATES DOCKERFILE ============
|
||||||
|
|
||||||
const DOCKERFILE_TEMPLATES = {
|
const DOCKERFILE_TEMPLATES = {
|
||||||
@@ -436,6 +465,7 @@ async function createApplication(params, logCallback) {
|
|||||||
const domain = `${subdomain}.recette.santinova-soft.org`;
|
const domain = `${subdomain}.recette.santinova-soft.org`;
|
||||||
const appDir = path.join(config.appsBasePath, appId);
|
const appDir = path.join(config.appsBasePath, appId);
|
||||||
const srcDir = path.join(appDir, 'src');
|
const srcDir = path.join(appDir, 'src');
|
||||||
|
let gitAuth = null;
|
||||||
|
|
||||||
const log = (msg) => {
|
const log = (msg) => {
|
||||||
const line = `[${new Date().toISOString()}] ${msg}`;
|
const line = `[${new Date().toISOString()}] ${msg}`;
|
||||||
@@ -444,6 +474,7 @@ async function createApplication(params, logCallback) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
gitAuth = await createGiteaGitAuth();
|
||||||
// 1. Créer le répertoire de l'application
|
// 1. Créer le répertoire de l'application
|
||||||
log(`Création du répertoire ${appDir}...`);
|
log(`Création du répertoire ${appDir}...`);
|
||||||
await fsp.mkdir(appDir, { recursive: true });
|
await fsp.mkdir(appDir, { recursive: true });
|
||||||
@@ -455,11 +486,8 @@ async function createApplication(params, logCallback) {
|
|||||||
if (sourceType === 'gitea') {
|
if (sourceType === 'gitea') {
|
||||||
// 2a. Cloner depuis un dépôt Gitea existant
|
// 2a. Cloner depuis un dépôt Gitea existant
|
||||||
log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`);
|
log(`Clonage du dépôt Gitea: ${giteaRepoUrl}...`);
|
||||||
const cloneUrl = giteaRepoUrl.replace(
|
const cloneUrl = normalizeGiteaRepoUrl(giteaRepoUrl);
|
||||||
'https://',
|
await execCommand(`cd ${shellQuote(appDir)} && rm -rf src && git clone ${shellQuote(cloneUrl)} src`, 120000, gitAuth.env);
|
||||||
`https://${config.gitea.username}:${encodeURIComponent(config.gitea.password)}@`
|
|
||||||
);
|
|
||||||
await execCommand(`cd ${appDir} && rm -rf src && git clone ${cloneUrl} src`, 120000);
|
|
||||||
log('Clonage terminé.');
|
log('Clonage terminé.');
|
||||||
|
|
||||||
// Extraire le nom du repo depuis l'URL
|
// Extraire le nom du repo depuis l'URL
|
||||||
@@ -503,10 +531,11 @@ async function createApplication(params, logCallback) {
|
|||||||
|
|
||||||
// Initialiser git et pousser le code
|
// Initialiser git et pousser le code
|
||||||
log('Initialisation Git et push vers Gitea...');
|
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(
|
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`,
|
`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
|
120000,
|
||||||
|
gitAuth.env
|
||||||
);
|
);
|
||||||
log('Code poussé sur Gitea.');
|
log('Code poussé sur Gitea.');
|
||||||
}
|
}
|
||||||
@@ -548,7 +577,8 @@ async function createApplication(params, logCallback) {
|
|||||||
if (sourceType === 'zip') {
|
if (sourceType === 'zip') {
|
||||||
await execCommand(
|
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`,
|
`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.');
|
log('Dockerfile commité et poussé sur Gitea.');
|
||||||
}
|
}
|
||||||
@@ -642,12 +672,14 @@ async function createApplication(params, logCallback) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`ERREUR: ${err.message}`);
|
log(`ERREUR: ${err.message}`);
|
||||||
throw err;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await gitAuth?.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function execCommand(cmd, timeout = 60000) {
|
function execCommand(cmd, timeout = 60000, env = process.env) {
|
||||||
return new Promise((resolve, reject) => {
|
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) {
|
if (error) {
|
||||||
reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`));
|
reject(new Error(`Commande échouée: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`));
|
||||||
} else {
|
} 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: {
|
gitea: {
|
||||||
url: process.env.GITEA_URL || 'http://gitea:3000',
|
url: process.env.GITEA_URL || 'http://gitea:3000',
|
||||||
username: process.env.GITEA_USERNAME || 'manus-admin',
|
username: process.env.GITEA_USERNAME || 'manus-admin',
|
||||||
password: process.env.GITEA_PASSWORD || 'Itinova69!',
|
|
||||||
token: process.env.GITEA_TOKEN || null,
|
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',
|
appsBasePath: process.env.APPS_BASE_PATH || '/opt/manus-deploy/apps',
|
||||||
|
|||||||
@@ -2,18 +2,9 @@ const axios = require('axios');
|
|||||||
const https = require('https');
|
const https = require('https');
|
||||||
const config = require('./config');
|
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({
|
const giteaClient = axios.create({
|
||||||
baseURL: `${config.gitea.url}/api/v1`,
|
baseURL: `${config.gitea.url}/api/v1`,
|
||||||
...(config.gitea.token
|
headers: config.gitea.token ? { Authorization: `token ${config.gitea.token}` } : {},
|
||||||
? { headers: giteaClientHeaders }
|
|
||||||
: { auth: { username: config.gitea.username, password: config.gitea.password } }
|
|
||||||
),
|
|
||||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
@@ -155,6 +146,35 @@ async function getWorkflowRunSummary(owner, repo) {
|
|||||||
return calculateCiSummary(await getWorkflowRuns(owner, repo, 30));
|
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)
|
* Récupérer tous les dépôts (avec retry en cas d'erreur DNS)
|
||||||
*/
|
*/
|
||||||
@@ -201,6 +221,7 @@ module.exports = {
|
|||||||
getWorkflowRuns,
|
getWorkflowRuns,
|
||||||
getWorkflowRunSummary,
|
getWorkflowRunSummary,
|
||||||
getWorkflowRunForCommit,
|
getWorkflowRunForCommit,
|
||||||
|
getRepoSnapshot,
|
||||||
normalizeWorkflowRun,
|
normalizeWorkflowRun,
|
||||||
calculateCiSummary,
|
calculateCiSummary,
|
||||||
listRepos,
|
listRepos,
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ async function runHealthChecks() {
|
|||||||
|
|
||||||
// Démarrage
|
// Démarrage
|
||||||
async function start() {
|
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();
|
await initAuth();
|
||||||
|
|
||||||
// Charger les applications dynamiques
|
// Charger les applications dynamiques
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ const express = require('express');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
const { authenticate, authMiddleware } = require('./auth');
|
const { authenticate, authMiddleware } = require('./auth');
|
||||||
const { docker, getContainerInfo, getContainerLogs, listContainers, redeployApp, gitPull, startContainer, stopContainer, restartContainer, getServerMetrics } = require('./docker');
|
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 { checkAllApps, addDeploymentLog, updateDeploymentLog, getDeploymentLogs, getAllStatuses, getAppStatus, cleanupOrphanedDeployments } = require('./healthcheck');
|
||||||
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
const { createApplication, getAvailableStacks, initDynamicApps } = require('./app-creator');
|
||||||
|
const { readCiAlerts } = require('./ci-alerts');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
|
|
||||||
// Nettoyer les déploiements orphelins au démarrage (entrées "running" restantes d'un crash précédent)
|
// 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;
|
module.exports = router;
|
||||||
|
|
||||||
// ===== INVENTAIRE DES APPLICATIONS =====
|
// ===== INVENTAIRE DES APPLICATIONS =====
|
||||||
const { exec } = require('child_process');
|
|
||||||
const GITEA_RECETTE_INTERNAL = process.env.GITEA_RECETTE_URL || 'http://gitea:3000';
|
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_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_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_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)
|
// INVENTORY_APPS est généré dynamiquement depuis config.apps (lui-même alimenté par la découverte des app.json)
|
||||||
function getInventoryApps() {
|
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) => {
|
router.get('/inventory', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const owner = GITEA_USER_INV;
|
const owner = GITEA_USER_INV;
|
||||||
@@ -604,8 +617,8 @@ router.get('/inventory', authMiddleware, async (req, res) => {
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
INVENTORY_APPS.map(async (app) => {
|
INVENTORY_APPS.map(async (app) => {
|
||||||
const [repoRecette, repoProd] = await Promise.all([
|
const [repoRecette, repoProd] = await Promise.all([
|
||||||
curlGitea(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_PASS_REC, GITEA_RECETTE_PUBLIC, GITEA_TOKEN_REC),
|
getRepoSnapshot(GITEA_RECETTE_INTERNAL, owner, app.repoName, GITEA_TOKEN_REC, GITEA_RECETTE_PUBLIC),
|
||||||
curlGitea(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_PASS_PRD),
|
getRepoSnapshot(GITEA_PROD_EXTERNAL, owner, app.repoName, GITEA_TOKEN_PRD),
|
||||||
]);
|
]);
|
||||||
return { ...app, repoRecette, repoProd };
|
return { ...app, repoRecette, repoProd };
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const fs = require('fs');
|
|||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const { findAppByRepo } = require('./app-registry');
|
const { findAppByRepo } = require('./app-registry');
|
||||||
const { getWorkflowRunForCommit } = require('./gitea');
|
const { getWorkflowRunForCommit } = require('./gitea');
|
||||||
|
const { normalizeWorkflowFailure, notifyDsi, saveCiAlert } = require('./ci-alerts');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -160,6 +161,22 @@ router.post('/gitea', (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Signature invalide' });
|
return res.status(401).json({ error: 'Signature invalide' });
|
||||||
}
|
}
|
||||||
const event = req.headers['x-gitea-event'] || 'unknown';
|
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') {
|
if (event !== 'push') {
|
||||||
return res.status(200).json({ message: `Événement ${event} ignoré` });
|
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!
|
- ADMIN_PASSWORD=Itinova69!
|
||||||
- GITEA_URL=https://git.recette.santinova-soft.org
|
- GITEA_URL=https://git.recette.santinova-soft.org
|
||||||
- GITEA_USERNAME=manus-admin
|
- GITEA_USERNAME=manus-admin
|
||||||
- GITEA_PASSWORD=ManusGitea2026!
|
- GITEA_TOKEN=${GITEA_TOKEN:?GITEA_TOKEN requis dans le fichier .env serveur}
|
||||||
- GITEA_TOKEN=1e0b01c361a91d5512e13c78053ac82a66e19427
|
|
||||||
- GITEA_RECETTE_URL=http://gitea:3000
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import DocumentationPage from './pages/DocumentationPage';
|
|||||||
import InventairePage from './pages/InventairePage';
|
import InventairePage from './pages/InventairePage';
|
||||||
import TerminalPage from './pages/TerminalPage';
|
import TerminalPage from './pages/TerminalPage';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
|
import CiAlertBanner from './components/CiAlertBanner';
|
||||||
import useWebSocket from './hooks/useWebSocket';
|
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() {
|
export default function App() {
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||||
const [apps, setApps] = useState([]);
|
const [apps, setApps] = useState([]);
|
||||||
|
const [ciAlerts, setCiAlerts] = useState([]);
|
||||||
// Vérifier l'authentification au chargement
|
// Vérifier l'authentification au chargement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('dashboard_token');
|
const token = localStorage.getItem('dashboard_token');
|
||||||
@@ -25,6 +27,7 @@ export default function App() {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
fetchApps();
|
fetchApps();
|
||||||
|
fetchCiAlerts();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem('dashboard_token');
|
localStorage.removeItem('dashboard_token');
|
||||||
@@ -43,11 +46,22 @@ export default function App() {
|
|||||||
console.error('Erreur chargement apps:', err);
|
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
|
// WebSocket handler
|
||||||
const handleWsMessage = useCallback((data) => {
|
const handleWsMessage = useCallback((data) => {
|
||||||
if (data.type === 'health_update') {
|
if (data.type === 'health_update') {
|
||||||
setApps(data.data);
|
setApps(data.data);
|
||||||
}
|
}
|
||||||
|
if (data.type === 'ci_alert') {
|
||||||
|
setCiAlerts((current) => [data.data, ...current.filter((alert) => alert.id !== data.data.id)]);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const { connected: wsConnected } = useWebSocket(
|
const { connected: wsConnected } = useWebSocket(
|
||||||
authenticated ? handleWsMessage : null
|
authenticated ? handleWsMessage : null
|
||||||
@@ -55,6 +69,7 @@ export default function App() {
|
|||||||
const handleLogin = (data) => {
|
const handleLogin = (data) => {
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
fetchApps();
|
fetchApps();
|
||||||
|
fetchCiAlerts();
|
||||||
};
|
};
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -65,6 +80,7 @@ export default function App() {
|
|||||||
localStorage.removeItem('dashboard_token');
|
localStorage.removeItem('dashboard_token');
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
setApps([]);
|
setApps([]);
|
||||||
|
setCiAlerts([]);
|
||||||
};
|
};
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -126,7 +142,10 @@ export default function App() {
|
|||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
/>
|
/>
|
||||||
<main className="ml-64 p-8">{renderPage()}</main>
|
<main className="ml-64 p-8">
|
||||||
|
<CiAlertBanner alert={ciAlerts[0]} />
|
||||||
|
{renderPage()}
|
||||||
|
</main>
|
||||||
</div>
|
</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,
|
TrendingDown,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Minus,
|
Minus,
|
||||||
|
KeyRound,
|
||||||
|
ShieldCheck,
|
||||||
} from 'lucide-react';
|
} 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) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return 'N/A';
|
if (!dateStr) return 'N/A';
|
||||||
@@ -54,6 +56,34 @@ export default function GiteaPage() {
|
|||||||
const [workflowRuns, setWorkflowRuns] = useState([]);
|
const [workflowRuns, setWorkflowRuns] = useState([]);
|
||||||
const [workflowSummary, setWorkflowSummary] = useState(null);
|
const [workflowSummary, setWorkflowSummary] = useState(null);
|
||||||
const [loadingCommits, setLoadingCommits] = useState(false);
|
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 () => {
|
const fetchRepos = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -92,6 +122,7 @@ export default function GiteaPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRepos();
|
fetchRepos();
|
||||||
|
fetchRotation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,6 +155,31 @@ export default function GiteaPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Repos List */}
|
{/* Repos List */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const createApp = (formData) =>
|
|||||||
// Deployments
|
// Deployments
|
||||||
export const getDeployments = (appId) =>
|
export const getDeployments = (appId) =>
|
||||||
api.get('/deployments', { params: { appId } });
|
api.get('/deployments', { params: { appId } });
|
||||||
|
export const getCiAlerts = () => api.get('/ci-alerts');
|
||||||
|
|
||||||
// Gitea
|
// Gitea
|
||||||
export const getGiteaRepos = () => api.get('/gitea/repos');
|
export const getGiteaRepos = () => api.get('/gitea/repos');
|
||||||
@@ -63,6 +64,9 @@ export const getGiteaWorkflowRuns = (owner, repo) =>
|
|||||||
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
|
api.get(`/gitea/repos/${owner}/${repo}/actions/runs`);
|
||||||
export const getGiteaWorkflowSummary = (owner, repo) =>
|
export const getGiteaWorkflowSummary = (owner, repo) =>
|
||||||
api.get(`/gitea/repos/${owner}/${repo}/actions/summary`);
|
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
|
// Docker
|
||||||
export const getContainers = () => api.get('/docker/containers');
|
export const getContainers = () => api.get('/docker/containers');
|
||||||
|
|||||||
Reference in New Issue
Block a user