ligbox-ops-platform/api/app/vm112_purge_jobs.py
Ligbox Spec Hub 3a2c64834b Initial import: ligbox-ops-platform + specs + LAPTOP + obsidian merge (CT130)
Source: VM122 /opt + obsidian-infra + LAPTOP
Hub: CT130 spec-hub 10.10.10.130
2026-06-19 17:26:41 +00:00

385 lines
12 KiB
Python

"""Purge assíncrono com polling + persistência SQLite (Spec 017 Fase 2b/3)."""
from __future__ import annotations
import json
import threading
import traceback
import uuid
from datetime import datetime, timezone
from typing import Any, Callable
from app import auth, vm112_domains
_lock = threading.Lock()
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_purge_jobs_schema(conn) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS vm112_purge_jobs (
id TEXT PRIMARY KEY,
domain TEXT NOT NULL,
status TEXT NOT NULL,
timeline_json TEXT NOT NULL DEFAULT '[]',
elapsed_vm112 INTEGER NOT NULL DEFAULT 0,
desk_json TEXT NOT NULL DEFAULT '{}',
vm112_json TEXT NOT NULL DEFAULT '{}',
error TEXT,
by_user TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.commit()
def _ensure_schema() -> None:
conn = auth.db()
try:
init_purge_jobs_schema(conn)
finally:
conn.close()
def _row_to_job(row) -> dict[str, Any]:
return {
"id": row["id"],
"job_id": row["id"],
"domain": row["domain"],
"status": row["status"],
"timeline": json.loads(row["timeline_json"] or "[]"),
"elapsed_vm112": int(row["elapsed_vm112"] or 0),
"desk": json.loads(row["desk_json"] or "{}"),
"vm112": json.loads(row["vm112_json"] or "{}"),
"error": row["error"],
"by": row["by_user"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _persist_job(job: dict[str, Any]) -> None:
_ensure_schema()
conn = auth.db()
try:
job["updated_at"] = _now()
conn.execute(
"""
INSERT INTO vm112_purge_jobs (
id, domain, status, timeline_json, elapsed_vm112,
desk_json, vm112_json, error, by_user, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
timeline_json = excluded.timeline_json,
elapsed_vm112 = excluded.elapsed_vm112,
desk_json = excluded.desk_json,
vm112_json = excluded.vm112_json,
error = excluded.error,
by_user = excluded.by_user,
updated_at = excluded.updated_at
""",
(
job["id"],
job["domain"],
job["status"],
json.dumps(job.get("timeline") or [], ensure_ascii=False),
int(job.get("elapsed_vm112") or 0),
json.dumps(job.get("desk") or {}, ensure_ascii=False),
json.dumps(job.get("vm112") or {}, ensure_ascii=False),
job.get("error"),
job.get("by"),
job.get("created_at") or _now(),
job["updated_at"],
),
)
conn.commit()
finally:
conn.close()
def _load_job(job_id: str) -> dict[str, Any] | None:
_ensure_schema()
conn = auth.db()
try:
row = conn.execute(
"SELECT * FROM vm112_purge_jobs WHERE id = ?", (job_id,)
).fetchone()
return _row_to_job(row) if row else None
finally:
conn.close()
def _mutate_job(job_id: str, fn: Callable[[dict[str, Any]], None]) -> dict[str, Any] | None:
with _lock:
job = _load_job(job_id)
if not job:
return None
fn(job)
_persist_job(job)
return dict(job)
def _upsert_step(job_id: str, step: dict[str, str]) -> None:
def _apply(job: dict[str, Any]) -> None:
timeline: list[dict[str, str]] = job.setdefault("timeline", [])
for i, existing in enumerate(timeline):
if existing.get("label") == step.get("label"):
timeline[i] = step
return
timeline.append(step)
_mutate_job(job_id, _apply)
def _set_job(job_id: str, **fields: Any) -> None:
_mutate_job(job_id, lambda job: job.update(fields))
def create_job(domain: str, username: str) -> str:
job_id = uuid.uuid4().hex[:16]
now = _now()
job = {
"id": job_id,
"job_id": job_id,
"domain": domain.lower().strip(),
"status": "queued",
"timeline": [],
"elapsed_vm112": 0,
"desk": {},
"vm112": {},
"error": None,
"by": username,
"created_at": now,
"updated_at": now,
}
with _lock:
_persist_job(job)
return job_id
def start_job(domain: str, root_password: str, username: str) -> str:
job_id = create_job(domain, username)
thread = threading.Thread(
target=_execute_job,
args=(job_id, domain, root_password, username),
daemon=True,
)
thread.start()
return job_id
def _desk_already_done(job: dict[str, Any]) -> bool:
for step in job.get("timeline") or []:
if str(step.get("label") or "") == "Purge concluído" and step.get("status") == "ok":
return True
return False
def _finish_desk_phase(job_id: str) -> dict[str, Any] | None:
job = _load_job(job_id)
if not job:
return None
if _desk_already_done(job):
if job["status"] != "done":
_set_job(job_id, status="done")
return _load_job(job_id)
domain = job["domain"]
conn = auth.db()
try:
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(conn, domain)
finally:
conn.close()
for step in desk_timeline:
_upsert_step(job_id, step)
total_desk = sum(desk_counts.values())
_upsert_step(
job_id,
vm112_domains._timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)"),
)
_set_job(job_id, status="done", desk=desk_counts)
return _load_job(job_id)
def recover_job(job_id: str, domain: str | None = None) -> dict[str, Any] | None:
"""Finaliza job quando VM112 já removeu o domínio (ex.: API reiniciada)."""
job = _load_job(job_id)
if not job:
if not domain:
return None
domain = domain.lower().strip()
if vm112_domains.domain_exists_on_vm112(domain):
return None
conn = auth.db()
try:
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(conn, domain)
finally:
conn.close()
total_desk = sum(desk_counts.values())
timeline = [
vm112_domains._timeline_entry(
"Purge recuperado",
"ok",
"Domínio já ausente na VM112",
),
*desk_timeline,
vm112_domains._timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)"),
]
return {
"id": job_id,
"job_id": job_id,
"domain": domain,
"status": "done",
"timeline": timeline,
"elapsed_vm112": 0,
"desk": desk_counts,
"vm112": {"ok": True, "recovered": True},
"error": None,
"by": None,
}
if job["status"] in ("done", "error"):
return job
domain = (domain or job["domain"]).lower().strip()
if vm112_domains.domain_exists_on_vm112(domain):
return job
_upsert_step(
job_id,
vm112_domains._timeline_entry(
"Purge VM112",
"ok",
"Domínio já removido na VM112 (recuperação)",
),
)
return _finish_desk_phase(job_id)
def _execute_job(job_id: str, domain: str, root_password: str, username: str) -> None:
domain = domain.lower().strip()
try:
_set_job(job_id, status="running")
conn = auth.db()
try:
if not vm112_domains.verify_root_password(conn, root_password):
step = vm112_domains._timeline_entry("Validação Root", "fail", "Senha Root incorrecta")
_upsert_step(job_id, step)
_set_job(job_id, status="error", error="Senha Root incorrecta")
return
finally:
conn.close()
_upsert_step(job_id, vm112_domains._timeline_entry("Validação Root + confirmação", "ok"))
_upsert_step(
job_id,
vm112_domains._timeline_entry(
"Purge VM112 — em execução",
"running",
"Carbonio, site, portal, Cloudflare, Traefik…",
),
)
vm112_result: dict[str, Any] = {"ok": False}
vm112_banner_marked = False
for kind, payload in vm112_domains.purge_vm112_with_poll(domain):
if kind == "step":
if not vm112_banner_marked:
_upsert_step(
job_id,
vm112_domains._timeline_entry(
"Purge VM112 — em execução", "ok", "Passos abaixo",
),
)
vm112_banner_marked = True
_upsert_step(job_id, payload)
elif kind == "heartbeat":
_set_job(job_id, elapsed_vm112=int(payload.get("elapsed") or 0))
elif kind == "final":
vm112_result = payload
break
if not vm112_result.get("ok", False):
step = vm112_domains._timeline_entry(
"Purge VM112",
"fail",
str(vm112_result.get("error") or "falhou"),
)
_upsert_step(job_id, step)
_set_job(job_id, status="error", error=str(vm112_result.get("error") or "falhou"))
return
_set_job(job_id, vm112=vm112_result)
_finish_desk_phase(job_id)
except Exception as exc:
err = str(exc) or "erro inesperado"
_upsert_step(
job_id,
vm112_domains._timeline_entry("Purge VM112", "fail", err),
)
_set_job(job_id, status="error", error=err)
traceback.print_exc()
def get_job_public(job_id: str) -> dict[str, Any] | None:
job = _load_job(job_id)
if not job:
return None
if job["status"] == "running":
try:
if not vm112_domains.domain_exists_on_vm112(job["domain"]):
job = recover_job(job_id) or job
except Exception:
pass
return job
def list_jobs(limit: int = 100, offset: int = 0) -> dict[str, Any]:
_ensure_schema()
limit = max(1, min(int(limit), 500))
offset = max(0, int(offset))
conn = auth.db()
try:
total = conn.execute("SELECT COUNT(*) FROM vm112_purge_jobs").fetchone()[0]
rows = conn.execute(
"""
SELECT id, domain, status, by_user, created_at, updated_at,
elapsed_vm112, desk_json, error
FROM vm112_purge_jobs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
jobs = []
for row in rows:
desk = json.loads(row["desk_json"] or "{}")
desk_total = sum(int(v or 0) for v in desk.values())
jobs.append(
{
"id": row["id"],
"job_id": row["id"],
"domain": row["domain"],
"status": row["status"],
"by": row["by_user"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"elapsed_vm112": int(row["elapsed_vm112"] or 0),
"desk": desk,
"desk_removed_total": desk_total,
"error": row["error"],
}
)
return {"jobs": jobs, "total": int(total), "limit": limit, "offset": offset}
finally:
conn.close()