From f4b42cbdf319e8d0de67e5b16aad34d4c3c64393 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:23:10 +0200 Subject: [PATCH] docker logy --- app/docker_logs.py | 234 +++++++++++++++++++++++++++++++++++++++++ app/main.py | 3 +- app/routes/app_logs.py | 190 +++++++++++++++++++++++++++++++++ app/routes/apps.py | 5 +- app/static/styles.css | 38 +++++++ 5 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 app/docker_logs.py create mode 100644 app/routes/app_logs.py diff --git a/app/docker_logs.py b/app/docker_logs.py new file mode 100644 index 0000000..aae6dd5 --- /dev/null +++ b/app/docker_logs.py @@ -0,0 +1,234 @@ +"""Diagnostické čtení Docker logů kontejneru služby. + +Portál si logy nikam nekopíruje ani nezakládá vlastní úložiště - jen čte to, co už +Docker drží pro daný kontejner (`docker logs`). Slouží k dohledání příčiny HTTP 500 +bez SSH přístupu na server. + +Bezpečnost: +* docker se volá vždy přes argument array (subprocess bez ``shell=True``), takže se + uživatelský vstup nemůže dostat do příkazové řádky, +* název kontejneru se odvozuje z ``app_id`` služby existující v databázi, nikdy se + nebere přímo z URL, +* core služby AppFactory jsou z tohoto čtení vyloučené (CORE_CONTAINERS), +* ``since`` a ``tail`` mají allowlist, cokoli jiného spadne na bezpečný default. +""" + +import re +import subprocess + +# Docker odpovídá lokálně přes socket; delší čekání by jen blokovalo request portálu. +DOCKER_TIMEOUT_SECONDS = 10 + +# Povolené hodnoty parametru --since. Klíč je hodnota z URL, hodnota jde do dockeru. +ALLOWED_LOG_PERIODS = { + "15m": "15m", + "30m": "30m", + "1h": "1h", + "6h": "6h", + "24h": "24h", +} + +# Popisky pro UI v pořadí, v jakém se mají nabídnout. +LOG_PERIOD_LABELS = ( + ("15m", "posledních 15 minut"), + ("30m", "posledních 30 minut"), + ("1h", "poslední hodina"), + ("6h", "posledních 6 hodin"), + ("24h", "posledních 24 hodin"), +) + +# Povolené hodnoty parametru --tail. +ALLOWED_LOG_TAILS = (100, 300, 500, 1000) + +DEFAULT_LOG_PERIOD = "30m" +DEFAULT_LOG_TAIL = 500 + +# Core služby AppFactory. Diagnostika uživatelských aplikací k nim nemá přístup - pro +# jejich logy by muselo vzniknout samostatné administrační oprávnění. +CORE_CONTAINERS = frozenset( + { + "appfactory-gitea", + "appfactory-portal", + "appfactory-worker", + "appfactory-monitor", + "appfactory-webhook", + "appfactory-caddy", + "appfactory-registry", + } +) + +# Docker povoluje v názvu kontejneru [a-zA-Z0-9][a-zA-Z0-9_.-]*. Držíme se toho i tady, +# ať se do argumentu nikdy nedostane nic jiného (obrana navíc, název stejně vzniká z app_id). +CONTAINER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$") + +# Typické chybové indikátory - slouží jen ke zvýraznění řádku, obsah logu se nemění. +ERROR_LINE_RE = re.compile(r"error|fail|exception|fatal|\b50[0234]\b", re.IGNORECASE) + +# Stavy kontejneru, jak je vrací `docker inspect --format {{.State.Status}}`. +CONTAINER_STATE_LABELS = { + "created": "Created", + "restarting": "Restarting", + "running": "Running", + "removing": "Removing", + "paused": "Paused", + "exited": "Exited", + "dead": "Dead", +} + + +class DockerLogsError(Exception): + """Chyba čtení Docker logů. Text je určený k zobrazení uživateli.""" + + +def normalize_period(value: str | None) -> str: + """Vrátí povolenou hodnotu --since, jinak bezpečný default.""" + return ALLOWED_LOG_PERIODS.get((value or "").strip(), DEFAULT_LOG_PERIOD) + + +def normalize_tail(value) -> int: + """Vrátí povolený počet řádků --tail, jinak bezpečný default.""" + try: + number = int(str(value).strip()) + except (TypeError, ValueError): + return DEFAULT_LOG_TAIL + return number if number in ALLOWED_LOG_TAILS else DEFAULT_LOG_TAIL + + +def state_label(status: str | None) -> str: + if not status: + return "" + return CONTAINER_STATE_LABELS.get(status, status) + + +def container_candidates(app_id: str) -> list[str]: + """Názvy kontejnerů, pod kterými může služba běžet (stejné schéma jako u restartu .env). + + Core kontejnery a syntakticky nemožné názvy se z kandidátů rovnou vyhazují. + """ + base = (app_id or "").strip() + normalized = base.replace("_", "-") + candidates: list[str] = [] + for candidate in (base, normalized, f"appfactory-{normalized}"): + if not candidate or candidate in candidates: + continue + if candidate in CORE_CONTAINERS: + continue + if not CONTAINER_NAME_RE.match(candidate): + continue + candidates.append(candidate) + return candidates + + +def _ensure_container_allowed(container_name: str) -> str: + name = (container_name or "").strip() + if not CONTAINER_NAME_RE.match(name): + raise DockerLogsError("Neplatný název kontejneru.") + if name in CORE_CONTAINERS: + raise DockerLogsError("Logy core služeb AppFactory nejsou přes tuto stránku dostupné.") + return name + + +def _run_docker(args: list[str], merge_stderr: bool = False) -> subprocess.CompletedProcess: + """Spustí docker přes argument array. Nikdy shell=True, nikdy skládání příkazu z textu.""" + try: + return subprocess.run( + ["docker", *args], + stdout=subprocess.PIPE, + # U `docker logs` teče stdout kontejneru do stdout a stderr do stderr. Sloučením + # do jednoho proudu zůstane zachované původní pořadí řádků, což je pro hledání + # příčiny chyby podstatné. + stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=DOCKER_TIMEOUT_SECONDS, + ) + except FileNotFoundError as exc: + raise DockerLogsError("Docker klient není v portálu dostupný.") from exc + except subprocess.TimeoutExpired as exc: + raise DockerLogsError(f"Docker neodpověděl do {DOCKER_TIMEOUT_SECONDS} sekund (timeout).") from exc + except OSError as exc: + raise DockerLogsError(f"Docker se nepodařilo spustit: {exc}") from exc + + +def docker_daemon_available() -> bool: + """True, pokud portál dosáhne na Docker daemon (volá se jen při chybě, kvůli hlášce).""" + try: + result = _run_docker(["version", "--format", "{{.Server.Version}}"]) + except DockerLogsError: + return False + return result.returncode == 0 + + +def inspect_container(container_name: str) -> str | None: + """Stav kontejneru (running/exited/paused/...), nebo None když v Dockeru není.""" + name = _ensure_container_allowed(container_name) + result = _run_docker(["inspect", "--format", "{{.State.Status}}", name]) + if result.returncode != 0: + return None + return (result.stdout or "").strip().lower() or None + + +def resolve_app_container(app_id: str) -> tuple[str, str | None]: + """Najde kontejner služby. Vrací (název, stav); stav je None, když kontejner neexistuje.""" + candidates = container_candidates(app_id) + if not candidates: + raise DockerLogsError("Ze služby nelze odvodit platný název kontejneru.") + + for candidate in candidates: + status = inspect_container(candidate) + if status: + return candidate, status + + return candidates[0], None + + +def get_container_logs( + container_name: str, + since: str = DEFAULT_LOG_PERIOD, + tail: int = DEFAULT_LOG_TAIL, +) -> str: + """Vrátí `docker logs --since --tail ` jako text (stdout i stderr).""" + name = _ensure_container_allowed(container_name) + since_value = normalize_period(since) + tail_value = normalize_tail(tail) + + result = _run_docker( + ["logs", "--since", since_value, "--tail", str(tail_value), name], + merge_stderr=True, + ) + output = result.stdout or "" + if result.returncode != 0: + detail = short_detail(output) + raise DockerLogsError(detail or f"Příkaz docker logs skončil s návratovým kódem {result.returncode}.") + return output + + +def short_detail(value: str) -> str: + """Zkrácený technický detail chyby pro zobrazení (escapuje se až v šabloně).""" + text = " ".join((value or "").split()) + if len(text) > 500: + return f"{text[:497]}..." + return text + + +def split_log_lines(text: str) -> list[str]: + if not text: + return [] + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + lines = normalized.split("\n") + while lines and not lines[-1]: + lines.pop() + return lines + + +def filter_log_lines(lines: list[str], query: str) -> list[str]: + """Filtruje už načtené řádky v Pythonu - žádný grep, žádný shell.""" + needle = (query or "").strip().lower() + if not needle: + return list(lines) + return [line for line in lines if needle in line.lower()] + + +def is_error_line(line: str) -> bool: + return bool(ERROR_LINE_RE.search(line or "")) diff --git a/app/main.py b/app/main.py index dee8463..37a21d2 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from starlette.middleware.sessions import SessionMiddleware from .config import read_env_bool, read_env_value from .logging_config import configure_logging -from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, ip_access, jobs, logs, operations, runtime, scheduled_scripts, users, workers +from .routes import alerting, app_logs, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, ip_access, jobs, logs, operations, runtime, scheduled_scripts, users, workers def create_app() -> FastAPI: @@ -28,6 +28,7 @@ def create_app() -> FastAPI: app.include_router(catalog.router) app.include_router(auth.router) app.include_router(apps.router) + app.include_router(app_logs.router) app.include_router(developers.router) app.include_router(backups.router) app.include_router(deployments.router) diff --git a/app/routes/app_logs.py b/app/routes/app_logs.py new file mode 100644 index 0000000..4f9aaa0 --- /dev/null +++ b/app/routes/app_logs.py @@ -0,0 +1,190 @@ +"""Stránka "Logy" v detailu služby - diagnostické čtení Docker logů kontejneru. + +Účel: administrátor uvidí konkrétní exception (Kestrel / Uvicorn / Node.js) bez SSH +přístupu na server. Portál logy nikam neukládá, jen je přečte z Dockeru a zobrazí. + +Container se odvozuje z ``app_id`` služby načtené z databáze AppFactory - z URL se bere +pouze ID služby, nikdy název kontejneru. Vlastní volání dockeru řeší ``app.docker_logs``. +""" + +import html +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import HTMLResponse + +from ..auth import require_developer +from ..db.apps import get_app +from ..db.audit import log_audit_event +from ..docker_logs import ( + ALLOWED_LOG_TAILS, + LOG_PERIOD_LABELS, + DockerLogsError, + docker_daemon_available, + filter_log_lines, + get_container_logs, + is_error_line, + normalize_period, + normalize_tail, + resolve_app_container, + split_log_lines, + state_label, +) +from ..templates.layout import page + +router = APIRouter() + +# Stav kontejneru -> třída pilulky (stejné barvy jako u ostatních stavů v portálu). +STATE_PILL_CLASSES = { + "running": "pill pill-success", + "restarting": "pill pill-warning", + "paused": "pill pill-warning", + "created": "pill pill-muted", + "removing": "pill pill-warning", + "exited": "pill pill-danger", + "dead": "pill pill-danger", +} + + +def render_state_pill(status: str | None) -> str: + if not status: + return 'Neexistuje' + css = STATE_PILL_CLASSES.get(status, "pill pill-muted") + return f'{html.escape(state_label(status))}' + + +def render_period_options(selected: str) -> str: + options = "" + for value, label in LOG_PERIOD_LABELS: + selected_attr = " selected" if value == selected else "" + options += f'' + return options + + +def render_tail_options(selected: int) -> str: + options = "" + for value in ALLOWED_LOG_TAILS: + selected_attr = " selected" if value == selected else "" + options += f'' + return options + + +def render_log_lines(lines: list[str]) -> str: + """Vykreslí řádky logu. Obsah se nemění, chybové řádky se jen vizuálně zvýrazní.""" + if not lines: + return '

Ve zvoleném období nejsou žádné řádky logu.

' + + rendered = [] + for line in lines: + css = "log-line log-line-error" if is_error_line(line) else "log-line" + # Prázdný řádek by se bez pevné mezery smrskl na nulovou výšku. + rendered.append(f'{html.escape(line) or " "}') + return f'
{"".join(rendered)}
' + + +def render_docker_error(container_name: str, detail: str) -> str: + daemon = "dostupný" if docker_daemon_available() else "nedostupný" + return f""" +

Logy se nepodařilo načíst.

+ + + + +
Docker daemon{daemon}
Container{html.escape(container_name) or "neznámý"}
Detail{html.escape(detail)}
+ """ + + +@router.get("/apps/{app_id}/logs", response_class=HTMLResponse) +def app_logs_page( + app_id: str, + since: str = Query(""), + tail: str = Query(""), + q: str = Query(""), + user=Depends(require_developer), +): + app = get_app(app_id) + if not app: + raise HTTPException(status_code=404, detail="App not found") + + real_app_id = (app.get("id") or "").strip() + app_url_id = quote(real_app_id, safe="") + period = normalize_period(since) + tail_value = normalize_tail(tail) + query = (q or "").strip() + + log_audit_event( + user, + action="service.logs.view", + target_type="service", + target_id=real_app_id, + metadata={"since": period, "tail": tail_value}, + ) + + container_name = "" + container_state: str | None = None + error_detail = "" + all_lines: list[str] = [] + + try: + container_name, container_state = resolve_app_container(real_app_id) + if container_state: + all_lines = split_log_lines(get_container_logs(container_name, since=period, tail=tail_value)) + except DockerLogsError as exc: + error_detail = str(exc) + + visible_lines = filter_log_lines(all_lines, query) + + if error_detail: + log_body = render_docker_error(container_name, error_detail) + elif not container_state: + log_body = ( + '

Container není spuštěný nebo neexistuje.

' + '

Logy lze číst jen dokud kontejner v Dockeru existuje. ' + "Po smazání kontejneru (například při redeployi) Docker jeho logy zahodí.

" + ) + else: + summary = f"Načteno {len(all_lines)} řádků." + if query: + summary = f"Zobrazeno {len(visible_lines)} z {len(all_lines)} načtených řádků (filtr: {html.escape(query)})." + log_body = f'

{summary}

{render_log_lines(visible_lines)}' + + return page( + "Logy služby", + f""" +
+

Logy kontejneru

+

+ Diagnostické čtení Docker logů běžící služby (stdout i stderr dohromady). + Portál logy neukládá, jen je čte přímo z Dockeru. +

+

+ ← Zpět na detail služby + Úlohy služby +

+ + + + +
Aplikace{html.escape(real_app_id)}
Container{html.escape(container_name) or "neurčeno"}
Stav{render_state_pill(container_state)}
+
+ +
+

Výpis

+
+ + + + + Reset +
+ {log_body} +
+ """, + user=user, + ) diff --git a/app/routes/apps.py b/app/routes/apps.py index 82de950..8c8c0e2 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -838,11 +838,14 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = "" ) # Odkazy jen otevřou a najedou na sekci - všechny sekce jsou defaultně sbalené. + # Logy kontejneru mají vlastní stránku (čtou se živě z Dockeru), ostatní záložky + # jen otevřou sbalenou sekci na tomto detailu. manage_tabs = ( - """ + f""" Metadata Proměnné Security / IP Access + Logy """ if can_manage else "" diff --git a/app/static/styles.css b/app/static/styles.css index 32f3c23..6df30fc 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -1205,6 +1205,44 @@ pre { word-break: break-word; } +/* Diagnostické Docker logy kontejneru služby (detail služby -> Logy). + white-space: pre + vodorovný posuv: dlouhý řádek stack trace se nezalamuje ani + nerozbíjí layout stránky, jen se v bloku odscrolluje. */ +.docker-log { + max-height: 640px; + min-height: 200px; + margin: 0; + padding: 14px 12px; + white-space: pre; + overflow: auto; + font-family: Consolas, "Liberation Mono", Menlo, monospace; + font-size: 13px; + line-height: 1.5; +} + +.docker-log .log-line { + display: block; + min-width: max-content; + padding: 0 4px; + border-radius: 3px; +} + +.docker-log .log-line-error { + background: rgba(184, 34, 62, 0.45); + color: #ffe5ea; + font-weight: 700; +} + +/* Popisek nad ovládacím prvkem filtru (Období / Počet řádků / Hledat). */ +.filter-field { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 700; + color: var(--muted); +} + /* Zvýraznění řádku, na který se uživatel proklikl z globálního prohlížeče logů. */ .job-log-entry:target { outline: 3px solid var(--primary);