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'Zatím není uložený žádný výsledek skriptu {html.escape(script_name)}.' + if job: + job_id = html.escape(str(job.get("id", ""))) + return_code = summary.get("return_code") + return_code_label = "" if return_code is None else html.escape(str(return_code)) + job_rows = f""" + Úloha#{job_id} + Status{render_job_status(job.get("status"))} + Return code{return_code_label} + Started at{html.escape(job.get("started_at", "") or "")} + Finished at{html.escape(job.get("finished_at", "") or "")} + """ + + stdout = html.escape(summary.get("stdout") or "Zatím není uložený žádný stdout log.") + stderr = html.escape(summary.get("stderr") or "Zatím není uložený žádný stderr log.") + + return f""" +
+

{html.escape(title)}

+

Poslední výsledek maintenance skriptu {html.escape(script_name)} uložený v databázi.

+ + {job_rows} +
+
+ +
+
+

Raw stdout log

+
{stdout}
+
+
+

Raw stderr log

+
{stderr}
+
+
+ """ + + @router.get("/migration-readiness", response_class=HTMLResponse) def migration_readiness_page(request: Request, user=Depends(require_user)): latest_job = get_latest_preflight_job() summary = summarize_readiness(latest_job) + bootstrap_summary = summarize_script_run(get_latest_bootstrap_job()) job = summary["job"] log_audit_event( @@ -55,9 +103,25 @@ def migration_readiness_page(request: Request, user=Depends(require_user)):

Migration Readiness

Poslední výsledek maintenance skriptu {html.escape(PREFLIGHT_SCRIPT)} uložený v databázi. Stav vychází pouze z výstupu skriptu.

-
- -
+
+
+ +
+
+ +
+
+
+ +
+ Bootstrap v2 provádí zásahy do systému. + +

Portál bootstrap nespouští přímo přes shell. Pouze založí maintenance job pro {html.escape(BOOTSTRAP_SCRIPT)}.

@@ -68,16 +132,18 @@ def migration_readiness_page(request: Request, user=Depends(require_user)):
-

Poslední úloha

+

Poslední preflight úloha

{job_rows}
-

Raw log výstup

+

Raw preflight log výstup

{raw_output}
+ + {render_script_result("Poslední výsledek Bootstrap v2", BOOTSTRAP_SCRIPT, bootstrap_summary)} """, user=user, ) @@ -94,3 +160,16 @@ def run_migration_readiness(user=Depends(require_user)): source="portal_manual", ) return RedirectResponse(url=f"/portal/jobs/{job_id}", status_code=303) + + +@router.post("/migration-readiness/bootstrap") +def run_bootstrap_v2(user=Depends(require_user)): + job_id = create_job( + job_type="run_script", + target_type="maintenance_script", + target_id=BOOTSTRAP_SCRIPT, + payload={"script_name": BOOTSTRAP_SCRIPT}, + user=user, + source="portal_manual", + ) + return RedirectResponse(url=f"/portal/jobs/{job_id}", status_code=303)