feat: interface admin portail.alpinux.org/admin/ — déclencher mkdocs build

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>
This commit is contained in:
Cédrix 2026-05-03 12:34:18 +02:00
parent 00bd942110
commit 60eb8bc952
9 changed files with 560 additions and 0 deletions

3
.gitignore vendored
View file

@ -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/

11
admin/.env.example Normal file
View file

@ -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

120
admin/app.py Normal file
View file

@ -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)

102
admin/builds.py Normal file
View file

@ -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)

4
admin/requirements.txt Normal file
View file

@ -0,0 +1,4 @@
flask>=3.0
authlib>=1.3
requests>=2.31
gunicorn>=21.0

71
admin/static/admin.css Normal file
View file

@ -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; }
}

215
admin/templates/index.html Normal file
View file

@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin — Alpinux</title>
<link rel="icon" type="image/x-icon" href="https://static.alpinux.org/logo/favicon.ico">
<link rel="stylesheet" href="{{ url_for('static', filename='admin.css') }}">
</head>
<body>
<header>
<div class="header-inner">
<a href="/" class="brand">
<img src="https://static.alpinux.org/logo/alpinux-logo.png" alt="Alpinux" width="36" height="36">
<span>A<strong>l</strong>p<strong>inux</strong> <span class="brand-sub">Admin</span></span>
</a>
<div class="header-user">
<span>{{ user.name }}</span>
<a href="{{ url_for('logout') }}" class="btn-logout">Déconnexion</a>
</div>
</div>
</header>
<main>
<!-- ── Carte de build ─────────────────────────────────────────── -->
<section class="build-card" id="build-card">
<div class="build-card-header">
<div>
<h2>📖 Wiki — <code>wiki.alpinux.org</code></h2>
<p class="subtitle">Lance <code>git pull</code> + <code>mkdocs build --strict</code> sur le serveur</p>
</div>
<div class="build-status" id="build-status">
{% if state.running %}
<span class="status-badge running">⏳ En cours…</span>
{% elif state.last_success is none %}
<span class="status-badge idle">En attente</span>
{% elif state.last_success %}
<span class="status-badge success">✓ Succès</span>
{% else %}
<span class="status-badge failure">✗ Échec</span>
{% endif %}
</div>
</div>
<div class="build-meta" id="build-meta">
{% if state.triggered_by %}
<span>Dernier déclencheur : <strong>{{ state.triggered_by }}</strong></span>
{% endif %}
{% if state.last_duration_s %}
<span>Durée : <strong>{{ state.last_duration_s }}s</strong></span>
{% endif %}
</div>
<button class="btn btn-build" id="btn-build"
{% if state.running %}disabled{% endif %}
onclick="triggerBuild()">
{% if state.running %}
⏳ Build en cours…
{% else %}
🚀 Lancer mkdocs build
{% endif %}
</button>
</section>
<!-- ── Log ───────────────────────────────────────────────────── -->
<section class="log-section">
<div class="log-header">
<h3>Journal du dernier build</h3>
<label class="scroll-toggle">
<input type="checkbox" id="auto-scroll" checked>
Suivi auto
</label>
</div>
<pre class="log-output" id="log-output">{{ log }}</pre>
</section>
<!-- ── Historique ────────────────────────────────────────────── -->
<section class="history-section">
<h3>Historique des builds</h3>
<table class="history-table" id="history-table">
<thead>
<tr><th>Date</th><th>Déclenché par</th><th>Résultat</th><th>Durée</th></tr>
</thead>
<tbody id="history-body">
{% for entry in state.history %}
<tr>
<td>{{ entry.at }}</td>
<td>{{ entry.triggered_by }}</td>
<td>
{% if entry.success %}
<span class="status-badge success">✓ Succès</span>
{% else %}
<span class="status-badge failure">✗ Échec</span>
{% endif %}
</td>
<td>{{ entry.duration_s }}s</td>
</tr>
{% else %}
<tr><td colspan="4" class="empty">Aucun build enregistré</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
<script>
const POLL_MS = 2000;
let polling = false;
function triggerBuild() {
if (!confirm('Lancer mkdocs build sur le serveur ?')) return;
document.getElementById('btn-build').disabled = true;
document.getElementById('btn-build').textContent = '⏳ Démarrage…';
fetch('/api/build', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.started) {
startPolling();
} else {
alert(data.reason || 'Impossible de démarrer le build.');
document.getElementById('btn-build').disabled = false;
document.getElementById('btn-build').textContent = '🚀 Lancer mkdocs build';
}
})
.catch(() => {
document.getElementById('btn-build').disabled = false;
document.getElementById('btn-build').textContent = '🚀 Lancer mkdocs build';
});
}
function startPolling() {
if (polling) return;
polling = true;
poll();
}
function poll() {
Promise.all([
fetch('/api/status').then(r => r.json()),
fetch('/api/log').then(r => r.json()),
]).then(([status, logData]) => {
updateStatus(status);
updateLog(logData.log || '');
if (status.running) {
setTimeout(poll, POLL_MS);
} else {
polling = false;
updateHistory(status.history || []);
}
}).catch(() => {
if (polling) setTimeout(poll, POLL_MS * 2);
});
}
function updateStatus(s) {
const badge = document.getElementById('build-status');
const btn = document.getElementById('btn-build');
const meta = document.getElementById('build-meta');
if (s.running) {
badge.innerHTML = '<span class="status-badge running">⏳ En cours…</span>';
btn.disabled = true;
btn.textContent = '⏳ Build en cours…';
} else if (s.last_success === true) {
badge.innerHTML = '<span class="status-badge success">✓ Succès</span>';
btn.disabled = false;
btn.textContent = '🚀 Lancer mkdocs build';
} else if (s.last_success === false) {
badge.innerHTML = '<span class="status-badge failure">✗ Échec</span>';
btn.disabled = false;
btn.textContent = '🚀 Lancer mkdocs build';
}
let metaHtml = '';
if (s.triggered_by) metaHtml += `<span>Dernier déclencheur : <strong>${s.triggered_by}</strong></span>`;
if (s.last_duration) metaHtml += `<span>Durée : <strong>${s.last_duration}s</strong></span>`;
meta.innerHTML = metaHtml;
}
function updateLog(text) {
const el = document.getElementById('log-output');
el.textContent = text || '(aucun log)';
if (document.getElementById('auto-scroll').checked) {
el.scrollTop = el.scrollHeight;
}
}
function updateHistory(entries) {
const tbody = document.getElementById('history-body');
if (!entries.length) return;
tbody.innerHTML = entries.map(e => `
<tr>
<td>${e.at}</td>
<td>${e.triggered_by}</td>
<td><span class="status-badge ${e.success ? 'success' : 'failure'}">${e.success ? '✓ Succès' : '✗ Échec'}</span></td>
<td>${e.duration_s}s</td>
</tr>`).join('');
}
/* Démarre le polling si un build tourne déjà au chargement de la page */
{% if state.running %}startPolling();{% endif %}
/* Scroll initial vers la fin du log */
window.addEventListener('load', function () {
const log = document.getElementById('log-output');
log.scrollTop = log.scrollHeight;
});
</script>
</body>
</html>

View file

@ -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

View file

@ -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"