Files
appfactory-portal/app/routes/runtime.py
T
JiriUhlir 23bbcad835 st
2026-06-16 10:47:14 +02:00

282 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import html
import json
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth import require_user
from app.db.audit import log_audit_event
from app.db.jobs import create_job, get_jobs_by_target_ids
from app.routes.jobs import render_job_status
from app.templates.layout import page
router = APIRouter()
# Portál NEMÁ vlastní deploy logiku. Všechny provozní akce deleguje do existujících shell skriptů
# v appfactory-tools přes existující job systém (job_type="run_script").
# Skripty leží v appfactory-tools/scripts/, proto je předáváme i s prefixem scripts/, jinak je
# worker hledá ve špatné složce a job selže s "skript nenalezen".
DEPLOY_CORE_SCRIPT = "scripts/deploy-core-service.sh"
CADDY_SCRIPT = "scripts/generate-caddyfile.sh"
ENABLED_APPS_SCRIPT = "scripts/redeploy-enabled-apps.sh"
# Skripty považované za "runtime operace" (filtr dashboardu).
RUNTIME_SCRIPTS = (DEPLOY_CORE_SCRIPT, CADDY_SCRIPT, ENABLED_APPS_SCRIPT)
ALL_CORE_KEY = "redeploy-all-core"
ALL_CORE_LABEL = "Redeploy All Core Services"
ALL_CORE_ICON = "fa-server"
# Core služby nasazované přes deploy-core-service.sh <service>. (key, name, icon, service_arg)
CORE_SERVICES = [
("redeploy-portal", "Portal", "fa-window-maximize", "appfactory-portal"),
("redeploy-worker", "Worker", "fa-gears", "appfactory-worker"),
("redeploy-webhook", "Webhook", "fa-bolt", "appfactory-webhook"),
("redeploy-monitor", "Monitor", "fa-heart-pulse", "appfactory-monitor"),
("redeploy-gateway", "Gateway (Caddy)", "fa-shield-halved", "appfactory-caddy"),
("redeploy-gitea", "Gitea", "fa-code-branch", "appfactory-gitea"),
("redeploy-registry", "Registry", "fa-box-archive", "appfactory-registry"),
]
# Spustitelné akce: key -> (label, icon, script_name, args). ALL_CORE_KEY je speciální (smyčka přes core).
ACTION_MAP: dict[str, tuple[str, str, str, list[str]]] = {}
for _key, _name, _icon, _arg in CORE_SERVICES:
ACTION_MAP[_key] = (f"Redeploy {_name}", _icon, DEPLOY_CORE_SCRIPT, [_arg])
ACTION_MAP["regenerate-caddy"] = ("Regenerate Caddy", "fa-shield-halved", CADDY_SCRIPT, [])
ACTION_MAP["redeploy-enabled-apps"] = ("Redeploy Enabled Apps", "fa-rocket", ENABLED_APPS_SCRIPT, [])
# Pořadí tlačítek po uložení .env (Environment stránka) dle zadání.
ENV_ACTION_KEYS = [
"regenerate-caddy",
"redeploy-portal",
"redeploy-worker",
"redeploy-webhook",
"redeploy-monitor",
"redeploy-gateway",
"redeploy-gitea",
"redeploy-registry",
ALL_CORE_KEY,
]
# Globální tlačítka na Runtime Management stránce (mimo tabulku core služeb).
RUNTIME_GLOBAL_ACTION_KEYS = ["regenerate-caddy", "redeploy-enabled-apps", ALL_CORE_KEY]
DASHBOARD_LIMIT = 20
def require_admin(user: dict) -> None:
if (user.get("role") or "").lower() != "admin":
raise HTTPException(status_code=403, detail="Runtime Management je dostupný pouze administrátorům")
def _is_valid_action(action_key: str) -> bool:
return action_key == ALL_CORE_KEY or action_key in ACTION_MAP
def _button_meta(action_key: str) -> tuple[str, str]:
if action_key == ALL_CORE_KEY:
return ALL_CORE_LABEL, ALL_CORE_ICON
label, icon, _script, _args = ACTION_MAP[action_key]
return label, icon
def enqueue_script_job(script_name: str, args: list[str], user: dict, target_label: str) -> int:
"""Vytvoří run_script job do existující fronty (worker spustí scripts/<script_name> s args)
a zaaudituje akci (bez hodnot proměnných). Vrací job_id."""
args = list(args or [])
payload = {"script_name": script_name}
if args:
payload["args"] = args
payload["arguments"] = args
job_id = create_job(
job_type="run_script",
target_type="maintenance_script",
target_id=script_name,
payload=payload,
user=user,
source="portal_runtime",
)
log_audit_event(
user,
action="runtime.action",
target_type="runtime",
target_id=target_label,
metadata={"script_name": script_name, "args": args, "job_id": job_id},
)
return job_id
def run_action(action_key: str, user: dict) -> str:
"""Spustí runtime akci podle klíče. Vrací zprávu pro uživatele."""
if action_key == ALL_CORE_KEY:
job_ids = [enqueue_script_job(DEPLOY_CORE_SCRIPT, [arg], user, arg) for _k, _n, _i, arg in CORE_SERVICES]
return f"Redeploy všech core služeb byl zařazen do fronty ({len(job_ids)} úloh)."
label, _icon, script, args = ACTION_MAP[action_key]
job_id = enqueue_script_job(script, args, user, label)
return f"Akce {label} byla zařazena do fronty (úloha #{job_id})."
def render_action_buttons(keys: list[str], next_url: str) -> str:
next_html = html.escape(next_url, quote=True)
buttons = ""
for key in keys:
label, icon = _button_meta(key)
buttons += f"""
<form method="post" action="/portal/admin/runtime/action/{key}" class="inline-form"
onsubmit="return confirm('Spustit akci {html.escape(label)}?');">
<input type="hidden" name="next" value="{next_html}">
<button type="submit"><i class="fa-solid {icon}" aria-hidden="true"></i> {html.escape(label)}</button>
</form>
"""
return buttons
def _job_args(job: dict) -> list[str]:
try:
payload = json.loads(job.get("payload_json") or "{}")
except (TypeError, ValueError):
return []
args = payload.get("args") or payload.get("arguments") or []
return [str(a) for a in args] if isinstance(args, list) else []
def _job_script_label(job: dict) -> str:
target = job.get("target_id") or job.get("type") or ""
args = _job_args(job)
return f"{target} {' '.join(args)}".strip()
def _core_last_runs() -> dict:
"""Najde poslední deploy-core-service.sh job pro každou core službu (podle argumentu v payloadu)."""
latest: dict[str, dict] = {}
for job in get_jobs_by_target_ids([DEPLOY_CORE_SCRIPT], limit=80):
args = _job_args(job)
service = args[0] if args else None
if service and service not in latest:
latest[service] = job
return latest
def _render_last_run(job: dict | None) -> str:
if not job:
return '<span class="muted">—</span>'
job_id = html.escape(str(job.get("id", "")))
created_at = html.escape(job.get("created_at", "") or "")
status = render_job_status(job.get("status"))
return f'{status}<br><a href="/portal/jobs/{job_id}"><small>{created_at} · #{job_id}</small></a>'
@router.get("/admin/runtime", response_class=HTMLResponse)
def runtime_page(request: Request, message: str = "", error: str = "", user=Depends(require_user)):
require_admin(user)
notice = ""
if message:
notice = f'<p class="alert">{html.escape(message)}</p>'
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
last_runs = _core_last_runs()
component_rows = ""
for key, name, icon, service_arg in CORE_SERVICES:
component_rows += f"""
<tr>
<td><strong><i class="fa-solid {icon}" aria-hidden="true"></i> {html.escape(name)}</strong></td>
<td><code>{html.escape(service_arg)}</code></td>
<td>{_render_last_run(last_runs.get(service_arg))}</td>
<td class="actions-cell service-actions">
<form method="post" action="/portal/admin/runtime/action/{key}" class="inline-form"
onsubmit="return confirm('Spustit redeploy služby {html.escape(name)}?');">
<input type="hidden" name="next" value="/portal/admin/runtime">
<button type="submit" class="icon-action" title="Redeploy" aria-label="Redeploy"><i class="fa-solid fa-rotate" aria-hidden="true"></i></button>
</form>
</td>
</tr>
"""
job_rows = ""
for job in get_jobs_by_target_ids(RUNTIME_SCRIPTS, limit=DASHBOARD_LIMIT):
job_id = html.escape(str(job.get("id", "")))
created_at = html.escape(job.get("created_at", "") or "")
script_label = html.escape(_job_script_label(job))
status = render_job_status(job.get("status"))
result_text = job.get("error_text") or job.get("result_json") or ""
result_preview = result_text if len(result_text) <= 160 else f"{result_text[:157]}..."
result_cell = f"<code>{html.escape(result_preview)}</code>" if result_preview else '<span class="muted">—</span>'
job_rows += f"""
<tr>
<td>{created_at}</td>
<td><a href="/portal/jobs/{job_id}">{script_label}</a></td>
<td>{status}</td>
<td>{result_cell}</td>
</tr>
"""
if not job_rows:
job_rows = '<tr><td colspan="4">Zatím nebyly spuštěné žádné runtime operace.</td></tr>'
return page(
"Runtime Management",
f"""
<div class="card">
<h2><i class="fa-solid fa-server" aria-hidden="true"></i> Runtime Management</h2>
<p class="muted">
Centrální správa klíčových AppFactory komponent. Akce nespouští docker přímo —
vytvoří úlohu do fronty, kterou zpracuje worker (existující shell skripty v appfactory-tools).
Dostupné pouze administrátorům.
</p>
{notice}
</div>
<div class="card">
<h2><i class="fa-solid fa-cubes" aria-hidden="true"></i> Core služby</h2>
<p class="muted">Redeploy přes <code>{html.escape(DEPLOY_CORE_SCRIPT)} &lt;service&gt;</code>.</p>
<table>
<thead>
<tr>
<th>Komponenta</th>
<th>Service</th>
<th>Poslední spuštění</th>
<th>Akce</th>
</tr>
</thead>
<tbody>{component_rows}</tbody>
</table>
</div>
<div class="card">
<h2><i class="fa-solid fa-bolt" aria-hidden="true"></i> Globální akce</h2>
<div class="inline-form">
{render_action_buttons(RUNTIME_GLOBAL_ACTION_KEYS, "/portal/admin/runtime")}
</div>
</div>
<div class="card">
<h2><i class="fa-solid fa-list-check" aria-hidden="true"></i> Posledních {DASHBOARD_LIMIT} runtime operací</h2>
<table>
<thead>
<tr>
<th>Čas</th>
<th>Skript</th>
<th>Stav</th>
<th>Výsledek</th>
</tr>
</thead>
<tbody>{job_rows}</tbody>
</table>
</div>
""",
user=user,
)
@router.post("/admin/runtime/action/{action_key}")
def runtime_action(action_key: str, next: str = Form("/portal/admin/runtime"), user=Depends(require_user)):
require_admin(user)
if not _is_valid_action(action_key):
raise HTTPException(status_code=404, detail="Neznámá akce")
message = run_action(action_key, user)
redirect_to = next if next.startswith("/portal/admin/") else "/portal/admin/runtime"
return RedirectResponse(url=f"{redirect_to}?message=" + quote(message), status_code=303)