feat: aligner le dashboard production sur recette
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# Image de jobs locale : Node 22, pnpm, Bash, Git et GNU tar sont prêts avant le workflow.
|
||||
# Image locale de CI : Node 22, Bash, Git, GNU tar et le pnpm exact du workspace Contacts.
|
||||
# Les jobs n'ont donc plus à télécharger ou installer ces outils à chaque exécution.
|
||||
FROM node:22-alpine
|
||||
|
||||
# actions/cache s’appuie sur GNU tar pour archiver le store pnpm.
|
||||
# actions/cache requiert GNU tar et l’invoque via /bin/tar.
|
||||
RUN apk add --no-cache bash git tar \
|
||||
&& tar --version | grep -q 'GNU tar' \
|
||||
&& corepack enable \
|
||||
|
||||
@@ -4,14 +4,14 @@ Le script `healthcheck-apps.sh` est exécuté par le timer systemd toutes les ci
|
||||
|
||||
Le dernier rapport est écrit dans `/var/lib/manus-apps-health/latest.txt`. Les anomalies sont aussi consignées dans le journal système avec le tag `manus-apps-health`.
|
||||
|
||||
Les pushes Gitea du dépôt `manus-dashboard` ne recréent jamais le conteneur depuis lui-même. Le webhook dépose une demande atomique, surveillée par `manus-dashboard-self-deploy.path`, puis le service hôte exécute le redéploiement. Cette séparation évite l’arrêt du client Docker avant le démarrage du conteneur de remplacement.
|
||||
Le runner de recette est installé par `install-gitea-act-runner.sh`. Il construit une image locale Node 22 avec Bash, Git, GNU tar et pnpm 10.4.1 préactivé, puis s’enregistre auprès de Gitea avec un jeton éphémère. Les workflows s’exécutent ensuite dans cette image isolée sur le réseau Docker `web`. GNU tar est requis par `actions/cache` pour créer et restaurer les archives de dépendances.
|
||||
|
||||
Le cache Gitea Actions est actif sur le port hôte `18088` et stocke ses archives sous `/opt/manus-deploy/gitea-runner/cache`. Les workflows doivent utiliser une clé dérivée de leur fichier de verrouillage, par exemple `pnpm-lock.yaml`, afin de ne jamais réutiliser des dépendances incompatibles.
|
||||
|
||||
Les pushes du dépôt `manus-dashboard` créent une demande atomique prise en charge par `manus-dashboard-self-deploy.path`. Le conteneur ne se redéploie donc jamais lui-même : le service systemd hôte effectue le build et le démarrage du remplacement.
|
||||
|
||||
Après mise à jour Git, installer ou actualiser le service avec :
|
||||
|
||||
```bash
|
||||
sudo ./ops/install-healthcheck.sh
|
||||
```
|
||||
|
||||
## Runner CI de production
|
||||
|
||||
`install-gitea-act-runner.sh` installe le runner Gitea de production depuis ces fichiers versionnés. Il utilise l’image locale `gitea-runner-node:22`, le label `ci-node22`, le réseau Docker `web` et un cache persistant dans `/opt/manus-deploy/gitea-runner/cache`.
|
||||
|
||||
9
ops/ci-alert-email-dispatch.path
Normal file
9
ops/ci-alert-email-dispatch.path
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Surveille la file des alertes CI Santinova
|
||||
|
||||
[Path]
|
||||
PathChanged=/opt/manus-deploy/secrets/ci-alerts/outbox
|
||||
Unit=ci-alert-email-dispatch.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
49
ops/ci-alert-email-dispatch.py
Normal file
49
ops/ci-alert-email-dispatch.py
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expédie les alertes CI mises en file par le dashboard depuis l’hôte de recette."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
OUTBOX = Path("/opt/manus-deploy/secrets/ci-alerts/outbox")
|
||||
ARCHIVE = Path("/var/log/manus-deploy/ci-alerts-email-sent.json")
|
||||
|
||||
|
||||
def send_alert(file_path: Path) -> None:
|
||||
payload = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
recipient = str(payload.get("to", "")).strip()
|
||||
subject = str(payload.get("subject", "")).strip()
|
||||
text = str(payload.get("text", "")).strip()
|
||||
if not recipient or "@" not in recipient or not subject or not text:
|
||||
raise ValueError(f"Alerte CI invalide : {file_path.name}")
|
||||
message = EmailMessage()
|
||||
message["To"] = recipient
|
||||
message["From"] = "alertes-ci@itinova.org"
|
||||
message["Subject"] = subject
|
||||
message.set_content(text)
|
||||
subprocess.run(["/usr/sbin/sendmail", "-t", "-oi"], input=message.as_bytes(), check=True, timeout=30)
|
||||
ARCHIVE.parent.mkdir(parents=True, exist_ok=True)
|
||||
ARCHIVE.write_text(json.dumps({"id": payload.get("id"), "sentAt": payload.get("createdAt")}) + "\n", encoding="utf-8")
|
||||
file_path.unlink()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
failures = []
|
||||
for file_path in sorted(OUTBOX.glob("*.json")):
|
||||
try:
|
||||
send_alert(file_path)
|
||||
except Exception as error: # Le fichier reste en file pour réessai et investigation.
|
||||
print(f"CI_EMAIL_DISPATCH_FAILED {file_path.name}: {error}", file=sys.stderr)
|
||||
failures.append(file_path.name)
|
||||
if failures:
|
||||
return 1
|
||||
print("CI_EMAIL_DISPATCH_OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
8
ops/ci-alert-email-dispatch.service
Normal file
8
ops/ci-alert-email-dispatch.service
Normal file
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Envoi hôte des alertes CI Santinova vers le DSI
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/manus-deploy/scripts/ci-alert-email-dispatch.py
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Déploie le dashboard depuis l’hôte systemd, jamais depuis le conteneur lui-même.
|
||||
# Le dashboard est redéployé par l’hôte, jamais par le conteneur qu’il remplace.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly APP_DIR="/opt/manus-deploy/apps/manus-dashboard"
|
||||
readonly REQUEST_FILE="${APP_DIR}/.deployment-request"
|
||||
readonly PROCESSING_FILE="${APP_DIR}/.deployment-request.processing"
|
||||
@@ -11,12 +10,8 @@ exec 9>"${LOCK_FILE}"
|
||||
flock -n 9 || exit 0
|
||||
[[ -f "${REQUEST_FILE}" ]] || exit 0
|
||||
mv "${REQUEST_FILE}" "${PROCESSING_FILE}"
|
||||
|
||||
restore_request() {
|
||||
[[ -f "${PROCESSING_FILE}" ]] && mv "${PROCESSING_FILE}" "${REQUEST_FILE}"
|
||||
}
|
||||
restore_request() { [[ -f "${PROCESSING_FILE}" ]] && mv "${PROCESSING_FILE}" "${REQUEST_FILE}"; }
|
||||
trap restore_request ERR
|
||||
|
||||
cd "${APP_DIR}"
|
||||
git pull --ff-only origin main
|
||||
docker compose up -d --build
|
||||
|
||||
@@ -6,7 +6,8 @@ runner:
|
||||
envs:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
|
||||
# Cache Gitea Actions persistant, accessible depuis les jobs du réseau Docker web.
|
||||
# Le cache intégré est servi par le runner hôte et accessible par les jobs
|
||||
# depuis le réseau Docker web. Les clés restent définies par chaque workflow.
|
||||
cache:
|
||||
enabled: true
|
||||
dir: /opt/manus-deploy/gitea-runner/cache
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Runner Gitea Actions de production
|
||||
Description=Runner Gitea Actions de recette
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
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
|
||||
150
ops/gitea-token-rotation.sh
Normal file
150
ops/gitea-token-rotation.sh
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/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 status result=1
|
||||
response="$(mktemp /tmp/gitea-token-validation.XXXXXX)"
|
||||
if status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/user")" \
|
||||
&& [[ "${status}" == "200" ]] \
|
||||
&& 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" ]] \
|
||||
&& status="$(curl -sS -o "${response}" -w '%{http_code}' -H "Authorization: token ${token}" "${GITEA_URL}/api/v1/repos/${GITEA_USER}/referentiel-ia")" \
|
||||
&& [[ "${status}" == "200" ]]; then
|
||||
result=0
|
||||
fi
|
||||
rm -f "${response}"
|
||||
return "${result}"
|
||||
}
|
||||
|
||||
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
|
||||
0
ops/healthcheck-apps.sh
Executable file → Normal file
0
ops/healthcheck-apps.sh
Executable file → Normal file
13
ops/install-ci-alert-email-dispatch.sh
Normal file
13
ops/install-ci-alert-email-dispatch.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly APP_DIR="${DASHBOARD_APP_DIR:-/opt/manus-deploy/apps/manus-dashboard}"
|
||||
readonly SCRIPT_DIR="/opt/manus-deploy/scripts"
|
||||
readonly OUTBOX_DIR="/opt/manus-deploy/secrets/ci-alerts/outbox"
|
||||
|
||||
install -d -m 0700 "${OUTBOX_DIR}"
|
||||
install -m 0700 "${APP_DIR}/ops/ci-alert-email-dispatch.py" "${SCRIPT_DIR}/ci-alert-email-dispatch.py"
|
||||
install -m 0644 "${APP_DIR}/ops/ci-alert-email-dispatch.service" /etc/systemd/system/ci-alert-email-dispatch.service
|
||||
install -m 0644 "${APP_DIR}/ops/ci-alert-email-dispatch.path" /etc/systemd/system/ci-alert-email-dispatch.path
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ci-alert-email-dispatch.path
|
||||
@@ -1,22 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installe et enregistre le runner CI production à partir des fichiers versionnés.
|
||||
# Installe et enregistre le runner de recette depuis les fichiers versionnés du dashboard.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly APP_DIR="$(dirname "${SCRIPT_DIR}")"
|
||||
readonly RUNNER_DIR="/opt/manus-deploy/gitea-runner"
|
||||
readonly GITEA_CONTAINER="gitea"
|
||||
readonly GITEA_URL="https://git.santinova-soft.org"
|
||||
readonly RUNNER_NAME="production-docker-runner"
|
||||
readonly GITEA_URL="https://git.recette.santinova-soft.org"
|
||||
readonly RUNNER_NAME="recette-docker-runner"
|
||||
readonly RUNNER_IMAGE="gitea-runner-node:22"
|
||||
readonly RUNNER_LABELS="ci-node22:docker://${RUNNER_IMAGE}"
|
||||
readonly LABEL_FILE="${RUNNER_DIR}/labels"
|
||||
|
||||
install -d -m 0750 "${RUNNER_DIR}" "${RUNNER_DIR}/cache"
|
||||
install -d -m 0750 "${RUNNER_DIR}"
|
||||
install -d -m 0750 "${RUNNER_DIR}/cache"
|
||||
install -m 0640 "${SCRIPT_DIR}/gitea-act-runner-config.yaml" "${RUNNER_DIR}/config.yaml"
|
||||
install -D -m 0644 "${SCRIPT_DIR}/gitea-act-runner.service" /etc/systemd/system/gitea-act-runner.service
|
||||
docker build --tag "${RUNNER_IMAGE}" --file "${SCRIPT_DIR}/Dockerfile.gitea-runner" "${APP_DIR}"
|
||||
|
||||
# Migration unique : les versions antérieures enregistraient le runner dans APP_DIR.
|
||||
if [[ ! -f "${RUNNER_DIR}/.runner" && -f "${APP_DIR}/.runner" ]]; then
|
||||
mv "${APP_DIR}/.runner" "${RUNNER_DIR}/.runner"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${RUNNER_DIR}/.runner" || ! -f "${LABEL_FILE}" || "$(<"${LABEL_FILE}")" != "${RUNNER_LABELS}" ]]; then
|
||||
systemctl stop gitea-act-runner.service 2>/dev/null || true
|
||||
rm -f "${RUNNER_DIR}/.runner"
|
||||
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Surveille les demandes de redéploiement du Manus Dashboard
|
||||
Description=Surveille les demandes de redéploiement du Manus Dashboard recette
|
||||
|
||||
[Path]
|
||||
PathExists=/opt/manus-deploy/apps/manus-dashboard/.deployment-request
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Redéploiement hôte du Manus Dashboard
|
||||
Description=Redéploiement hôte du Manus Dashboard recette
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user