Specs stay at repo root (cross-VM). Move deploy and code into logical projects with README per domain, updated manifest.yaml, and symlinks at legacy paths for VM122 backward compatibility.
385 lines
12 KiB
Python
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()
|