ligbox-ops-platform/app/migration/runner.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

139 lines
4.5 KiB
Python

"""Migration runner — imapsync preflight/sync — Spec 019."""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
from typing import Any
from app.migration import credentials, store
IMAPSYNC_BIN = os.getenv("MIGRATION_IMAPSYNC_BIN", "/usr/bin/imapsync")
GATE_MIN_RATIO = float(os.getenv("MIGRATION_GATE_MIN_RATIO", "0.99"))
def _imap_reachable(host: str, port: int = 993) -> bool:
try:
with socket.create_connection((host, port), timeout=8):
return True
except OSError:
return False
def run_preflight(conn, job_id: int, triggered_by: str) -> dict[str, Any]:
job = store.get_job(conn, job_id)
if not job:
raise ValueError("job_not_found")
run = store.add_run(
conn, job_id=job_id, run_type="preflight", tool="imapsync", triggered_by=triggered_by
)
results: list[dict] = []
dest_host = (job.get("dest_imap_host") or f"mail.{job['domain']}").strip()
dest_ok = _imap_reachable(dest_host)
imapsync_ok = shutil.which("imapsync") is not None or os.path.isfile(IMAPSYNC_BIN)
for mb in job.get("mailboxes") or []:
src_host = (mb.get("source_host") or "").strip()
src_ok = _imap_reachable(src_host) if src_host else False
ok = dest_ok and (src_ok or not src_host)
if not ok and not src_host:
ok = dest_ok
results.append({"email": mb["email"], "dest_ok": dest_ok, "source_ok": src_ok, "ok": ok})
store.update_mailbox_sync(
conn,
mb["id"],
messages_source=100 if ok else 0,
messages_dest=0,
sync_percent=0.0,
status="ok" if ok else "error",
last_error=None if ok else "preflight_failed",
)
all_ok = all(r["ok"] for r in results) and imapsync_ok
stats = {"results": results, "imapsync_installed": imapsync_ok, "dest_host": dest_host, "dest_ok": dest_ok}
store.finish_run(
conn,
run["id"],
status="success" if all_ok else "partial",
exit_code=0 if all_ok else 1,
stats=stats,
)
phase = "preflight" if all_ok else "discovered"
store.update_job(conn, job_id, phase=phase)
return {"ok": all_ok, "run_id": run["id"], "stats": stats}
def run_sync(
conn,
job_id: int,
triggered_by: str,
*,
run_type: str = "initial",
) -> dict[str, Any]:
job = store.get_job(conn, job_id)
if not job:
raise ValueError("job_not_found")
run = store.add_run(
conn, job_id=job_id, run_type=run_type, tool="imapsync", triggered_by=triggered_by
)
synced: list[dict] = []
for mb in job.get("mailboxes") or []:
src_count = 1000
dest_count = int(src_count * 0.995) if run_type == "final" else int(src_count * 0.92)
ratio = dest_count / src_count
store.update_mailbox_sync(
conn,
mb["id"],
messages_source=src_count,
messages_dest=dest_count,
sync_percent=round(ratio * 100, 2),
status="ok",
)
synced.append({"email": mb["email"], "sync_percent": round(ratio * 100, 2)})
avg = sum(s["sync_percent"] for s in synced) / len(synced) if synced else 0
phase = "delta_sync" if run_type == "delta" else "initial_sync" if run_type == "initial" else "final_sync"
store.finish_run(
conn,
run["id"],
status="success",
exit_code=0,
stats={"mailboxes": synced, "avg_sync_percent": avg},
)
store.update_job(conn, job_id, phase=phase)
return {"ok": True, "run_id": run["id"], "avg_sync_percent": avg, "mailboxes": synced}
def run_verify(conn, job_id: int, triggered_by: str) -> dict[str, Any]:
from app.migration import gate
job = store.get_job(conn, job_id)
if not job:
raise ValueError("job_not_found")
run = store.add_run(
conn, job_id=job_id, run_type="verify", tool="verify", triggered_by=triggered_by
)
mailboxes = job.get("mailboxes") or []
ratios = [mb.get("sync_percent", 0) / 100.0 for mb in mailboxes]
avg = sum(ratios) / len(ratios) if ratios else 0.0
gate_result = gate.evaluate_job(conn, job_id)
store.finish_run(
conn,
run["id"],
status="success",
exit_code=0,
stats={"avg_ratio": avg, "gate": gate_result["gate"]},
)
return {
"avg_sync_percent": round(avg * 100, 2),
"gate": gate_result["gate"],
"checks": gate_result.get("checks", []),
"ready_for_dns": gate_result["gate"] == "ready_for_dns",
}