Hotovo. Přidal jsem podporu bootstrap-v2.sh do stránky Migration Readiness.
Co se změnilo: migration_readiness.py (line 9) nově zná BOOTSTRAP_SCRIPT = "bootstrap-v2.sh". Helper umí najít poslední job pro bootstrap a připravit status, return code, started_at, finished_at, raw stdout/stderr z DB. routes/migration_readiness.py (line 111) přidává tlačítko Run Bootstrap v2, které pouze vytvoří run_script job s script_name=bootstrap-v2.sh. Přidal jsem výrazné varování k bootstrapu a blok “Poslední výsledek Bootstrap v2”. Zobrazení logů redaktuje env-like citlivé hodnoty typu TOKEN, PASSWORD, SECRET, API_KEY, PRIVATE, CREDENTIAL.
This commit is contained in:
@@ -6,10 +6,15 @@ from app.db.database import get_connection
|
|||||||
from app.db.migrations import run_migrations
|
from app.db.migrations import run_migrations
|
||||||
|
|
||||||
PREFLIGHT_SCRIPT = "preflight-check.sh"
|
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:
|
def _job_matches_script(row: dict[str, Any], script_name: str) -> bool:
|
||||||
if row.get("target_id") == PREFLIGHT_SCRIPT:
|
if row.get("target_id") == script_name:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -17,10 +22,10 @@ def _job_matches_script(row: dict[str, Any]) -> bool:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return False
|
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()
|
run_migrations()
|
||||||
con = get_connection()
|
con = get_connection()
|
||||||
|
|
||||||
@@ -36,18 +41,26 @@ def get_latest_preflight_job():
|
|||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
""",
|
""",
|
||||||
(PREFLIGHT_SCRIPT, f'%"{PREFLIGHT_SCRIPT}"%'),
|
(script_name, f'%"{script_name}"%'),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
con.close()
|
con.close()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
job = dict(row)
|
job = dict(row)
|
||||||
if _job_matches_script(job):
|
if _job_matches_script(job, script_name):
|
||||||
return job
|
return job
|
||||||
return None
|
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()
|
run_migrations()
|
||||||
con = get_connection()
|
con = get_connection()
|
||||||
|
|
||||||
@@ -62,7 +75,34 @@ def get_job_log_output(job_id: int) -> str:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
con.close()
|
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:
|
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 {}
|
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:
|
def _first_int(data: dict[str, Any], keys: tuple[str, ...]) -> int | None:
|
||||||
for key in keys:
|
for key in keys:
|
||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
@@ -157,3 +205,33 @@ def summarize_readiness(job: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
"raw_output": raw_output,
|
"raw_output": raw_output,
|
||||||
"job": job,
|
"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 "")),
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from app.auth import require_user
|
from app.auth import require_user
|
||||||
from app.db.audit import log_audit_event
|
from app.db.audit import log_audit_event
|
||||||
from app.db.jobs import create_job
|
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.routes.jobs import render_job_status
|
||||||
from app.templates.layout import page
|
from app.templates.layout import page
|
||||||
|
|
||||||
@@ -19,10 +26,51 @@ def render_readiness_pill(ready: bool) -> str:
|
|||||||
return '<span class="pill pill-danger">NOT READY</span>'
|
return '<span class="pill pill-danger">NOT READY</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def render_script_result(title: str, script_name: str, summary: dict) -> str:
|
||||||
|
job = summary.get("job")
|
||||||
|
job_rows = f'<tr><td colspan="2">Zatím není uložený žádný výsledek skriptu {html.escape(script_name)}.</td></tr>'
|
||||||
|
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"""
|
||||||
|
<tr><th>Úloha</th><td><a href="/portal/jobs/{job_id}">#{job_id}</a></td></tr>
|
||||||
|
<tr><th>Status</th><td>{render_job_status(job.get("status"))}</td></tr>
|
||||||
|
<tr><th>Return code</th><td>{return_code_label}</td></tr>
|
||||||
|
<tr><th>Started at</th><td>{html.escape(job.get("started_at", "") or "")}</td></tr>
|
||||||
|
<tr><th>Finished at</th><td>{html.escape(job.get("finished_at", "") or "")}</td></tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<div class="card">
|
||||||
|
<h2>{html.escape(title)}</h2>
|
||||||
|
<p class="muted">Poslední výsledek maintenance skriptu {html.escape(script_name)} uložený v databázi.</p>
|
||||||
|
<table>
|
||||||
|
{job_rows}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Raw stdout log</h2>
|
||||||
|
<pre class="log-viewer log-stdout">{stdout}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Raw stderr log</h2>
|
||||||
|
<pre class="log-viewer log-stderr">{stderr}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@router.get("/migration-readiness", response_class=HTMLResponse)
|
@router.get("/migration-readiness", response_class=HTMLResponse)
|
||||||
def migration_readiness_page(request: Request, user=Depends(require_user)):
|
def migration_readiness_page(request: Request, user=Depends(require_user)):
|
||||||
latest_job = get_latest_preflight_job()
|
latest_job = get_latest_preflight_job()
|
||||||
summary = summarize_readiness(latest_job)
|
summary = summarize_readiness(latest_job)
|
||||||
|
bootstrap_summary = summarize_script_run(get_latest_bootstrap_job())
|
||||||
job = summary["job"]
|
job = summary["job"]
|
||||||
|
|
||||||
log_audit_event(
|
log_audit_event(
|
||||||
@@ -55,9 +103,25 @@ def migration_readiness_page(request: Request, user=Depends(require_user)):
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Migration Readiness</h2>
|
<h2>Migration Readiness</h2>
|
||||||
<p class="muted">Poslední výsledek maintenance skriptu {html.escape(PREFLIGHT_SCRIPT)} uložený v databázi. Stav vychází pouze z výstupu skriptu.</p>
|
<p class="muted">Poslední výsledek maintenance skriptu {html.escape(PREFLIGHT_SCRIPT)} uložený v databázi. Stav vychází pouze z výstupu skriptu.</p>
|
||||||
|
<div class="inline-form">
|
||||||
<form method="post" action="/portal/migration-readiness/run">
|
<form method="post" action="/portal/migration-readiness/run">
|
||||||
<button type="submit">Spustit preflight kontrolu</button>
|
<button type="submit">Spustit preflight kontrolu</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form method="post" action="/portal/migration-readiness/bootstrap" onsubmit="return confirm('Spustit Bootstrap v2? Tato akce upravuje server přes maintenance job.');">
|
||||||
|
<button type="submit" class="danger">Run Bootstrap v2</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<strong>Bootstrap v2 provádí zásahy do systému.</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Opravuje ownership /opt/appfactory.</li>
|
||||||
|
<li>Nastavuje executable práva pro maintenance a alert skripty.</li>
|
||||||
|
<li>Deployuje core services.</li>
|
||||||
|
<li>Spouští preflight-check.sh.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Portál bootstrap nespouští přímo přes shell. Pouze založí maintenance job pro {html.escape(BOOTSTRAP_SCRIPT)}.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
@@ -68,16 +132,18 @@ def migration_readiness_page(request: Request, user=Depends(require_user)):
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Poslední úloha</h2>
|
<h2>Poslední preflight úloha</h2>
|
||||||
<table>
|
<table>
|
||||||
{job_rows}
|
{job_rows}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Raw log výstup</h2>
|
<h2>Raw preflight log výstup</h2>
|
||||||
<pre class="log-viewer log-stdout">{raw_output}</pre>
|
<pre class="log-viewer log-stdout">{raw_output}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{render_script_result("Poslední výsledek Bootstrap v2", BOOTSTRAP_SCRIPT, bootstrap_summary)}
|
||||||
""",
|
""",
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
@@ -94,3 +160,16 @@ def run_migration_readiness(user=Depends(require_user)):
|
|||||||
source="portal_manual",
|
source="portal_manual",
|
||||||
)
|
)
|
||||||
return RedirectResponse(url=f"/portal/jobs/{job_id}", status_code=303)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user