diff --git a/app/db/migration_readiness.py b/app/db/migration_readiness.py index 4a7e343..f80891e 100644 --- a/app/db/migration_readiness.py +++ b/app/db/migration_readiness.py @@ -6,10 +6,15 @@ from app.db.database import get_connection from app.db.migrations import run_migrations PREFLIGHT_SCRIPT = "preflight-check.sh" +BOOTSTRAP_SCRIPT = "bootstrap-v2.sh" +SENSITIVE_KEY_RE = re.compile( + r"(SECRET|TOKEN|PASSWORD|PASS|KEY|CREDENTIAL|AUTH|API_KEY|PRIVATE)", + re.IGNORECASE, +) -def _job_matches_script(row: dict[str, Any]) -> bool: - if row.get("target_id") == PREFLIGHT_SCRIPT: +def _job_matches_script(row: dict[str, Any], script_name: str) -> bool: + if row.get("target_id") == script_name: return True try: @@ -17,10 +22,10 @@ def _job_matches_script(row: dict[str, Any]) -> bool: except (TypeError, ValueError): return False - return payload.get("script_name") == PREFLIGHT_SCRIPT + return payload.get("script_name") == script_name -def get_latest_preflight_job(): +def get_latest_script_job(script_name: str): run_migrations() con = get_connection() @@ -36,18 +41,26 @@ def get_latest_preflight_job(): ORDER BY id DESC LIMIT 20 """, - (PREFLIGHT_SCRIPT, f'%"{PREFLIGHT_SCRIPT}"%'), + (script_name, f'%"{script_name}"%'), ).fetchall() con.close() for row in rows: job = dict(row) - if _job_matches_script(job): + if _job_matches_script(job, script_name): return job return None -def get_job_log_output(job_id: int) -> str: +def get_latest_preflight_job(): + return get_latest_script_job(PREFLIGHT_SCRIPT) + + +def get_latest_bootstrap_job(): + return get_latest_script_job(BOOTSTRAP_SCRIPT) + + +def get_job_log_entries(job_id: int) -> list[dict[str, Any]]: run_migrations() con = get_connection() @@ -62,7 +75,34 @@ def get_job_log_output(job_id: int) -> str: ).fetchall() con.close() - return "\n".join(f"[{row['stream'] or 'log'}] {row['message'] or ''}" for row in rows) + return [dict(row) for row in rows] + + +def _redact_sensitive_text(value: str) -> str: + redacted_lines = [] + for line in value.splitlines(): + env_match = re.match(r"^(\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*)(.*)$", line) + if env_match and SENSITIVE_KEY_RE.search(env_match.group(2)): + redacted_lines.append(f"{env_match.group(1)}[redacted]") + continue + json_match = re.match(r'^(\s*["\']?([^"\':=]+)["\']?\s*[:=]\s*)(.*)$', line) + if json_match and SENSITIVE_KEY_RE.search(json_match.group(2)): + redacted_lines.append(f"{json_match.group(1)}[redacted]") + continue + redacted_lines.append(line) + return "\n".join(redacted_lines) + + +def get_job_log_output(job_id: int, stream: str | None = None, include_stream: bool = True) -> str: + rows = get_job_log_entries(job_id) + lines = [] + for row in rows: + row_stream = row.get("stream") or "log" + if stream and row_stream != stream: + continue + message = _redact_sensitive_text(row.get("message") or "") + lines.append(f"[{row_stream}] {message}" if include_stream else message) + return "\n".join(lines) def _count_token(text: str, token: str) -> int: @@ -77,6 +117,14 @@ def _result_data(job: dict[str, Any]) -> dict[str, Any]: return value if isinstance(value, dict) else {} +def _result_value(job: dict[str, Any], keys: tuple[str, ...]) -> Any: + data = _result_data(job) + for key in keys: + if key in data: + return data.get(key) + return None + + def _first_int(data: dict[str, Any], keys: tuple[str, ...]) -> int | None: for key in keys: value = data.get(key) @@ -157,3 +205,33 @@ def summarize_readiness(job: dict[str, Any] | None) -> dict[str, Any]: "raw_output": raw_output, "job": job, } + + +def summarize_script_run(job: dict[str, Any] | None) -> dict[str, Any]: + if not job: + return { + "job": None, + "return_code": None, + "stdout": "", + "stderr": "", + } + + return_code = _result_value(job, ("returncode", "return_code", "exit_code", "exitCode")) + stdout = _result_value(job, ("stdout", "out")) + stderr = _result_value(job, ("stderr", "err")) + + if stdout is None: + stdout = get_job_log_output(int(job["id"]), stream="stdout", include_stream=False) + if stderr is None: + stderr = get_job_log_output(int(job["id"]), stream="stderr", include_stream=False) + + error_text = job.get("error_text") or "" + if error_text: + stderr = f"{stderr}\n{error_text}" if stderr else error_text + + return { + "job": job, + "return_code": return_code, + "stdout": _redact_sensitive_text(str(stdout or "")), + "stderr": _redact_sensitive_text(str(stderr or "")), + } diff --git a/app/routes/migration_readiness.py b/app/routes/migration_readiness.py index 6aa7969..d118b4e 100644 --- a/app/routes/migration_readiness.py +++ b/app/routes/migration_readiness.py @@ -6,7 +6,14 @@ 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 -from app.db.migration_readiness import PREFLIGHT_SCRIPT, get_latest_preflight_job, summarize_readiness +from app.db.migration_readiness import ( + BOOTSTRAP_SCRIPT, + PREFLIGHT_SCRIPT, + get_latest_bootstrap_job, + get_latest_preflight_job, + summarize_readiness, + summarize_script_run, +) from app.routes.jobs import render_job_status from app.templates.layout import page @@ -19,10 +26,51 @@ def render_readiness_pill(ready: bool) -> str: return 'NOT READY' +def render_script_result(title: str, script_name: str, summary: dict) -> str: + job = summary.get("job") + job_rows = f'
Poslední výsledek maintenance skriptu {html.escape(script_name)} uložený v databázi.
+{stdout}
+ {stderr}
+ Poslední výsledek maintenance skriptu {html.escape(PREFLIGHT_SCRIPT)} uložený v databázi. Stav vychází pouze z výstupu skriptu.
- +Portál bootstrap nespouští přímo přes shell. Pouze založí maintenance job pro {html.escape(BOOTSTRAP_SCRIPT)}.
{raw_output}