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.
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",
|
|
}
|