139 lines
4.5 KiB
Python
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",
|
|
}
|