From 60eb8bc952a9b2f7af932ed3e8eed38154b1a6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Sun, 3 May 2026 12:34:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20interface=20admin=20portail.alpinux.org?= =?UTF-8?q?/admin/=20=E2=80=94=20d=C3=A9clencher=20mkdocs=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + admin/.env.example | 11 ++ admin/app.py | 120 ++++++++++++++ admin/builds.py | 102 ++++++++++++ admin/requirements.txt | 4 + admin/static/admin.css | 71 ++++++++ admin/templates/index.html | 215 +++++++++++++++++++++++++ scripts/alpinux-admin.service | 24 +++ scripts/portail.alpinux.org.admin.conf | 10 ++ 9 files changed, 560 insertions(+) create mode 100644 admin/.env.example create mode 100644 admin/app.py create mode 100644 admin/builds.py create mode 100644 admin/requirements.txt create mode 100644 admin/static/admin.css create mode 100644 admin/templates/index.html create mode 100644 scripts/alpinux-admin.service create mode 100644 scripts/portail.alpinux.org.admin.conf diff --git a/.gitignore b/.gitignore index 626515a..a879380 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ dynamic/__pycache__/ dynamic/routes/__pycache__/ dynamic/venv/ dynamic/.env +admin/__pycache__/ +admin/venv/ +admin/.env # Assets binaires — générés par scripts/build-assets.py # Hébergés sur https://static.alpinux.org/logo/ diff --git a/admin/.env.example b/admin/.env.example new file mode 100644 index 0000000..92331fe --- /dev/null +++ b/admin/.env.example @@ -0,0 +1,11 @@ +SECRET_KEY=changez-moi-avec-une-valeur-aleatoire-longue + +ALPID_CLIENT_ID=alpinux-admin +ALPID_CLIENT_SECRET= +ALPID_DISCOVERY_URL=https://alpid.alpinux.org/realms/alpinux/.well-known/openid-configuration + +# Noms des groupes Keycloak autorisés (séparés par virgule) +ADMIN_GROUPS=admins + +# Chemin du script de déploiement (défaut dans builds.py si non défini) +# DEPLOY_SCRIPT=/home/alpinux/site/scripts/deploy-wiki.sh diff --git a/admin/app.py b/admin/app.py new file mode 100644 index 0000000..947ccec --- /dev/null +++ b/admin/app.py @@ -0,0 +1,120 @@ +import os +from flask import Flask, redirect, url_for, session, request, jsonify, render_template, abort +from authlib.integrations.flask_client import OAuth +from werkzeug.middleware.proxy_fix import ProxyFix + +import builds + +app = Flask(__name__) +app.secret_key = os.environ["SECRET_KEY"] + +# Gère X-Forwarded-Proto et X-Script-Name envoyés par Apache +app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_prefix=1) + +# ── OIDC AlpID ──────────────────────────────────────────────────── +oauth = OAuth(app) +oauth.register( + name="alpid", + server_metadata_url=os.environ["ALPID_DISCOVERY_URL"], + client_id=os.environ["ALPID_CLIENT_ID"], + client_secret=os.environ["ALPID_CLIENT_SECRET"], + client_kwargs={"scope": "openid profile email groups"}, +) + +ADMIN_GROUPS = set(os.environ.get("ADMIN_GROUPS", "admins").split(",")) + + +def _user(): + return session.get("user") + + +def _require_admin(): + user = _user() + if not user: + session["next_url"] = request.url + return redirect(url_for("login")) + if not user.get("is_admin"): + abort(403) + return None + + +# ── Auth ────────────────────────────────────────────────────────── +@app.route("/auth/login") +def login(): + return oauth.alpid.authorize_redirect(url_for("callback", _external=True)) + + +@app.route("/auth/callback") +def callback(): + token = oauth.alpid.authorize_access_token() + info = token.get("userinfo") or oauth.alpid.userinfo(token=token) + + # Keycloak expose les groupes dans le claim "groups" (à activer dans le mapper) + groups = set(info.get("groups", [])) + is_admin = bool(groups & ADMIN_GROUPS) + + session["user"] = { + "sub": info["sub"], + "name": info.get("name") or info.get("preferred_username", "Admin"), + "email": info.get("email", ""), + "is_admin": is_admin, + } + return redirect(session.pop("next_url", url_for("index"))) + + +@app.route("/auth/logout") +def logout(): + session.clear() + return redirect(url_for("index")) + + +# ── Pages ───────────────────────────────────────────────────────── +@app.route("/") +def index(): + redir = _require_admin() + if redir: + return redir + state = builds.get_state() + log = builds.get_log() + return render_template("index.html", user=_user(), state=state, log=log) + + +# ── API JSON ────────────────────────────────────────────────────── +@app.route("/api/build", methods=["POST"]) +def api_build(): + redir = _require_admin() + if redir: + return jsonify({"error": "non authentifié"}), 401 + user = _user() + ok = builds.start_build(triggered_by=user["name"]) + if ok: + return jsonify({"started": True}) + return jsonify({"started": False, "reason": "Un build est déjà en cours"}), 409 + + +@app.route("/api/status") +def api_status(): + redir = _require_admin() + if redir: + return jsonify({"error": "non authentifié"}), 401 + state = builds.get_state() + return jsonify({ + "running": state.get("running", False), + "last_success": state.get("last_success"), + "last_duration": state.get("last_duration_s"), + "started_at": state.get("started_at"), + "triggered_by": state.get("triggered_by"), + "history": state.get("history", [])[:5], + }) + + +@app.route("/api/log") +def api_log(): + redir = _require_admin() + if redir: + return jsonify({"error": "non authentifié"}), 401 + return jsonify({"log": builds.get_log()}) + + +if __name__ == "__main__": + app.run(debug=False, port=5002) diff --git a/admin/builds.py b/admin/builds.py new file mode 100644 index 0000000..4b93bf9 --- /dev/null +++ b/admin/builds.py @@ -0,0 +1,102 @@ +""" +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) diff --git a/admin/requirements.txt b/admin/requirements.txt new file mode 100644 index 0000000..c436512 --- /dev/null +++ b/admin/requirements.txt @@ -0,0 +1,4 @@ +flask>=3.0 +authlib>=1.3 +requests>=2.31 +gunicorn>=21.0 diff --git a/admin/static/admin.css b/admin/static/admin.css new file mode 100644 index 0000000..d70a7fa --- /dev/null +++ b/admin/static/admin.css @@ -0,0 +1,71 @@ +:root { + --blue: #1a6bbf; + --blue-dark: #0f4e8f; + --blue-light:#e8f1fb; + --bg: #f3f6fb; + --text: #1a1a2e; + --muted: #666; + --radius: 10px; + --shadow: 0 2px 12px rgba(26,107,191,.1); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; } +code { background: #e8eef7; padding: .1rem .4rem; border-radius: 4px; font-size: .88em; } +a { color: var(--blue); } + +/* ── Header ── */ +header { background: var(--blue-dark); color: #fff; } +.header-inner { max-width: 900px; margin: 0 auto; padding: .8rem 1.5rem; display: flex; align-items: center; gap: 1rem; } +.brand { display: flex; align-items: center; gap: .6rem; color: #fff; font-size: 1.1rem; text-decoration: none; } +.brand strong { font-weight: 800; } +.brand-sub { opacity: .7; font-weight: 300; } +.brand img { border-radius: 5px; } +.header-user { margin-left: auto; display: flex; align-items: center; gap: 1rem; color: rgba(255,255,255,.85); font-size: .9rem; } +.btn-logout { color: rgba(255,255,255,.75); font-size: .85rem; border: 1px solid rgba(255,255,255,.4); border-radius: 4px; padding: .3rem .7rem; text-decoration: none; } +.btn-logout:hover { background: rgba(255,255,255,.1); color: #fff; } + +/* ── Main ── */ +main { max-width: 900px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: flex; flex-direction: column; gap: 1.5rem; } + +/* ── Build card ── */ +.build-card { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.8rem 2rem; } +.build-card-header { display: flex; align-items: flex-start; gap: 1rem; justify-content: space-between; flex-wrap: wrap; margin-bottom: .8rem; } +.build-card-header h2 { font-size: 1.1rem; margin-bottom: .2rem; } +.subtitle { font-size: .85rem; color: var(--muted); } +.build-meta { display: flex; gap: 1.5rem; font-size: .85rem; color: var(--muted); margin-bottom: 1.2rem; flex-wrap: wrap; } + +.btn { display: inline-flex; align-items: center; gap: .5rem; padding: .65rem 1.6rem; border-radius: 6px; font-size: .95rem; font-weight: 600; cursor: pointer; border: none; transition: all .15s; } +.btn-build { background: var(--blue); color: #fff; font-size: 1rem; } +.btn-build:hover:not(:disabled) { background: var(--blue-dark); } +.btn-build:disabled { opacity: .55; cursor: not-allowed; } + +/* ── Status badges ── */ +.status-badge { display: inline-block; padding: .25rem .7rem; border-radius: 20px; font-size: .82rem; font-weight: 700; white-space: nowrap; } +.status-badge.running { background: #fff3d4; color: #8a5c00; } +.status-badge.success { background: #d4f0dc; color: #1a6b35; } +.status-badge.failure { background: #fde8e8; color: #8a0000; } +.status-badge.idle { background: var(--blue-light); color: var(--blue-dark); } + +/* ── Log ── */ +.log-section { background: #111827; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); } +.log-header { display: flex; align-items: center; justify-content: space-between; padding: .7rem 1.2rem; background: #1f2937; color: #d1d5db; } +.log-header h3 { font-size: .9rem; font-weight: 600; } +.scroll-toggle { display: flex; align-items: center; gap: .4rem; font-size: .82rem; color: #9ca3af; cursor: pointer; } +.scroll-toggle input { cursor: pointer; accent-color: var(--blue); } +.log-output { color: #a3e635; font-family: 'Courier New', monospace; font-size: .82rem; line-height: 1.55; padding: 1rem 1.2rem; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; } + +/* ── History ── */ +.history-section { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.5rem 1.8rem; } +.history-section h3 { font-size: 1rem; margin-bottom: 1rem; color: var(--blue-dark); } +.history-table { width: 100%; border-collapse: collapse; font-size: .88rem; } +.history-table th { text-align: left; padding: .5rem .8rem; background: var(--blue-light); color: var(--blue-dark); font-weight: 600; } +.history-table td { padding: .5rem .8rem; border-bottom: 1px solid var(--blue-light); } +.history-table tr:last-child td { border-bottom: none; } +.empty { text-align: center; color: var(--muted); padding: 1.2rem; } + +@media (max-width: 600px) { + .build-card-header { flex-direction: column; } + .header-inner { flex-wrap: wrap; } + .history-table th:nth-child(4), .history-table td:nth-child(4) { display: none; } +} diff --git a/admin/templates/index.html b/admin/templates/index.html new file mode 100644 index 0000000..bc02585 --- /dev/null +++ b/admin/templates/index.html @@ -0,0 +1,215 @@ + + + + + + Admin — Alpinux + + + + + +
+
+ + Alpinux + Alpinux Admin + +
+ {{ user.name }} + Déconnexion +
+
+
+ +
+ + +
+
+
+

📖 Wiki — wiki.alpinux.org

+

Lance git pull + mkdocs build --strict sur le serveur

+
+
+ {% if state.running %} + ⏳ En cours… + {% elif state.last_success is none %} + En attente + {% elif state.last_success %} + ✓ Succès + {% else %} + ✗ Échec + {% endif %} +
+
+ +
+ {% if state.triggered_by %} + Dernier déclencheur : {{ state.triggered_by }} + {% endif %} + {% if state.last_duration_s %} + Durée : {{ state.last_duration_s }}s + {% endif %} +
+ + +
+ + +
+
+

Journal du dernier build

+ +
+
{{ log }}
+
+ + +
+

Historique des builds

+ + + + + + {% for entry in state.history %} + + + + + + + {% else %} + + {% endfor %} + +
DateDéclenché parRésultatDurée
{{ entry.at }}{{ entry.triggered_by }} + {% if entry.success %} + ✓ Succès + {% else %} + ✗ Échec + {% endif %} + {{ entry.duration_s }}s
Aucun build enregistré
+
+ +
+ + + + diff --git a/scripts/alpinux-admin.service b/scripts/alpinux-admin.service new file mode 100644 index 0000000..dc0a1ed --- /dev/null +++ b/scripts/alpinux-admin.service @@ -0,0 +1,24 @@ +# Systemd unit pour l'app d'administration Alpinux +# Copier dans /etc/systemd/system/alpinux-admin.service +# puis : sudo systemctl enable --now alpinux-admin + +[Unit] +Description=Alpinux Admin — interface de déploiement (Flask + Gunicorn) +After=network.target + +[Service] +User=alpinux +Group=alpinux +WorkingDirectory=/home/alpinux/site/admin +EnvironmentFile=/etc/alpinux-admin/config.env +ExecStart=/home/alpinux/site/admin/venv/bin/gunicorn \ + --workers 1 \ + --bind 127.0.0.1:5002 \ + --access-logfile /var/log/alpinux-admin/access.log \ + --error-logfile /var/log/alpinux-admin/error.log \ + app:app +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/portail.alpinux.org.admin.conf b/scripts/portail.alpinux.org.admin.conf new file mode 100644 index 0000000..cb0214d --- /dev/null +++ b/scripts/portail.alpinux.org.admin.conf @@ -0,0 +1,10 @@ +# Bloc à ajouter dans le VirtualHost HTTPS de portail.alpinux.org +# (dans ISPConfig : Sites > portail.alpinux.org > Directives Apache personnalisées) +# +# L'app admin Flask tourne sur Gunicorn à 127.0.0.1:5002 + + # ── Admin Alpinux : /admin/ → Gunicorn port 5002 ──────────────── + ProxyPass /admin/ http://127.0.0.1:5002/ + ProxyPassReverse /admin/ http://127.0.0.1:5002/ + RequestHeader set X-Forwarded-Proto "https" + RequestHeader set X-Script-Name "/admin"