Mini-app Flask (admin/) accessible à https://portail.alpinux.org/admin/ : - Authentification AlpID OIDC, accès restreint au groupe « admins » Keycloak - Bouton « Lancer mkdocs build » avec confirmation - Exécution de deploy-wiki.sh en arrière-plan (thread), log capturé en direct - Polling JS toutes les 2s pendant le build (status + log + historique) - Affichage du journal en terminal sombre avec suivi automatique - Historique des 20 derniers builds (date, déclencheur, résultat, durée) - ProxyFix pour X-Forwarded-Proto / X-Script-Name (Apache reverse proxy) Infrastructure : - scripts/portail.alpinux.org.admin.conf : bloc ProxyPass pour ISPConfig - scripts/alpinux-admin.service : systemd Gunicorn port 5002 - admin/.env.example, admin/requirements.txt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
102 lines
3.1 KiB
Python
102 lines
3.1 KiB
Python
"""
|
|
Gestion des builds : exécution, état, historique.
|
|
Un seul build peut tourner à la fois.
|
|
"""
|
|
import json
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
STATE_FILE = Path("/var/lib/alpinux-admin/builds.json")
|
|
LOG_FILE = Path("/var/lib/alpinux-admin/current.log")
|
|
DEPLOY_SCRIPT = Path("/home/alpinux/site/scripts") / "deploy-wiki.sh"
|
|
|
|
_lock = threading.Lock()
|
|
|
|
|
|
def _load_state() -> dict:
|
|
if STATE_FILE.exists():
|
|
try:
|
|
return json.loads(STATE_FILE.read_text())
|
|
except Exception:
|
|
pass
|
|
return {"running": False, "history": []}
|
|
|
|
|
|
def _save_state(state: dict):
|
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
|
|
|
|
def get_state() -> dict:
|
|
return _load_state()
|
|
|
|
|
|
def get_log(lines: int = 200) -> str:
|
|
if LOG_FILE.exists():
|
|
all_lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
return "\n".join(all_lines[-lines:])
|
|
return ""
|
|
|
|
|
|
def is_running() -> bool:
|
|
return _load_state().get("running", False)
|
|
|
|
|
|
def start_build(triggered_by: str) -> bool:
|
|
"""Lance le build dans un thread. Retourne False si déjà en cours."""
|
|
with _lock:
|
|
state = _load_state()
|
|
if state.get("running"):
|
|
return False
|
|
state["running"] = True
|
|
state["started_at"] = datetime.now().isoformat(timespec="seconds")
|
|
state["triggered_by"] = triggered_by
|
|
_save_state(state)
|
|
|
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
LOG_FILE.write_text("")
|
|
|
|
threading.Thread(target=_run_build, args=(triggered_by,), daemon=True).start()
|
|
return True
|
|
|
|
|
|
def _run_build(triggered_by: str):
|
|
started = time.time()
|
|
success = False
|
|
try:
|
|
with open(LOG_FILE, "w", buffering=1) as log:
|
|
log.write(f"=== Build démarré le {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} par {triggered_by} ===\n\n")
|
|
proc = subprocess.Popen(
|
|
["bash", str(DEPLOY_SCRIPT)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
)
|
|
for line in proc.stdout:
|
|
log.write(line)
|
|
log.flush()
|
|
proc.wait()
|
|
success = proc.returncode == 0
|
|
log.write(f"\n=== Terminé (code {proc.returncode}) en {int(time.time()-started)}s ===\n")
|
|
except Exception as exc:
|
|
with open(LOG_FILE, "a") as log:
|
|
log.write(f"\n=== ERREUR : {exc} ===\n")
|
|
|
|
with _lock:
|
|
state = _load_state()
|
|
state["running"] = False
|
|
state["last_success"] = success
|
|
state["last_duration_s"] = int(time.time() - started)
|
|
entry = {
|
|
"at": datetime.now().isoformat(timespec="seconds"),
|
|
"triggered_by": triggered_by,
|
|
"success": success,
|
|
"duration_s": int(time.time() - started),
|
|
}
|
|
state.setdefault("history", []).insert(0, entry)
|
|
state["history"] = state["history"][:20]
|
|
_save_state(state)
|