Coffey Labs is now Coffey Labs LLC, an Arizona limited liability company. Copyright lines and SPDX-FileCopyrightText headers naming Coffey Labs or John Coffey now name Coffey Labs LLC. Upstream copyright notices are unchanged.
139 lines
4.7 KiB
Python
139 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
"""Shared helpers for the synthetic cutover rehearsal.
|
|
|
|
Two containers stand in for the two installs of docs/spec/cutover.md:
|
|
|
|
old upstream stalwart 0.16.22 (target/debug/stalwart) — the Enterprise
|
|
install that is running today
|
|
new the fork (target/debug/inbuxa) — what it is cut over to
|
|
|
|
Both run unprivileged with CAP_NET_BIND_SERVICE so the mail ports are bound
|
|
the way the systemd unit binds them, not as root. Nothing here touches
|
|
INBUXA's production server; the data is created here and thrown away.
|
|
"""
|
|
import base64, json, os, secrets, smtplib, ssl, subprocess, sys, time, urllib.error, urllib.request
|
|
|
|
ROOT = "/run/media/john/PROJECTS/inbuxa-server"
|
|
BASE = f"{ROOT}/target/cutover"
|
|
IMAGE = "stalwartlabs/stalwart:v0.16.22"
|
|
|
|
# Host ports. The two installs are side by side, as the plan requires, so
|
|
# they cannot share: only one holds the mail ports at a time.
|
|
HTTP = 12080
|
|
SMTP = 12025
|
|
SUBMISSIONS = 12465
|
|
IMAPS = 12993
|
|
|
|
DOMAIN = "cutover.test"
|
|
HOSTNAME = f"mail.{DOMAIN}"
|
|
|
|
INSTALLS = {
|
|
"old": {"container": "cutover-old", "binary": "stalwart", "dir": f"{BASE}/old"},
|
|
"new": {"container": "cutover-new", "binary": "inbuxa", "dir": f"{BASE}/new"},
|
|
}
|
|
|
|
|
|
def docker(*args, check_rc=True):
|
|
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check_rc)
|
|
|
|
|
|
def run(*args, check_rc=True):
|
|
return subprocess.run(args, capture_output=True, text=True, check=check_rc)
|
|
|
|
|
|
def start(which, hold_mail_ports=True, extra_env=None):
|
|
"""Start one install. Only one may hold the mail ports at a time."""
|
|
spec = INSTALLS[which]
|
|
env_file = f"{spec['dir']}/env"
|
|
args = [
|
|
"run", "-d", "--name", spec["container"],
|
|
"--user", f"{os.getuid()}:{os.getgid()}",
|
|
"--cap-add", "NET_BIND_SERVICE",
|
|
"--entrypoint", f"/usr/local/bin/{spec['binary']}",
|
|
"-v", f"{ROOT}/target/debug/{spec['binary']}:/usr/local/bin/{spec['binary']}:ro",
|
|
"-v", f"{spec['dir']}/etc:/etc/stalwart",
|
|
"-v", f"{spec['dir']}/data:/var/lib/stalwart",
|
|
"-p", f"127.0.0.1:{HTTP}:8080",
|
|
]
|
|
if hold_mail_ports:
|
|
args += ["-p", f"127.0.0.1:{SMTP}:25",
|
|
"-p", f"127.0.0.1:{SUBMISSIONS}:465",
|
|
"-p", f"127.0.0.1:{IMAPS}:993"]
|
|
if os.path.exists(env_file):
|
|
args += ["--env-file", env_file]
|
|
for k, v in (extra_env or {}).items():
|
|
args += ["-e", f"{k}={v}"]
|
|
args += [IMAGE, "--config", "/etc/stalwart/config.json"]
|
|
docker(*args)
|
|
return wait_http()
|
|
|
|
|
|
def wait_http(seconds=180):
|
|
for _ in range(seconds):
|
|
try:
|
|
urllib.request.urlopen(f"http://127.0.0.1:{HTTP}/.well-known/jmap", timeout=2)
|
|
except urllib.error.HTTPError:
|
|
return True
|
|
except Exception:
|
|
time.sleep(1)
|
|
continue
|
|
return True
|
|
return False
|
|
|
|
|
|
def stop(which):
|
|
docker("rm", "-f", INSTALLS[which]["container"], check_rc=False)
|
|
|
|
|
|
def logs(which, tail="40"):
|
|
r = docker("logs", "--tail", tail, INSTALLS[which]["container"], check_rc=False)
|
|
return (r.stdout or "") + (r.stderr or "")
|
|
|
|
|
|
def jmap(user, pw, calls, using=("urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail",
|
|
"urn:stalwart:jmap")):
|
|
body = json.dumps({"using": list(using), "methodCalls": calls}).encode()
|
|
req = urllib.request.Request(f"http://127.0.0.1:{HTTP}/jmap/", data=body, method="POST")
|
|
req.add_header("Content-Type", "application/json")
|
|
req.add_header("Authorization", "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode())
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
return json.load(resp)["methodResponses"]
|
|
|
|
|
|
def one(user, pw, method, args):
|
|
return jmap(user, pw, [[method, args, "0"]])[0]
|
|
|
|
|
|
def session(user, pw):
|
|
req = urllib.request.Request(f"http://127.0.0.1:{HTTP}/jmap/session")
|
|
req.add_header("Authorization", "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode())
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
return json.load(resp)
|
|
|
|
|
|
def tls_context():
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
return ctx
|
|
|
|
|
|
results = []
|
|
|
|
|
|
def check(cond, what, detail=""):
|
|
results.append((bool(cond), what, detail))
|
|
print((" ok " if cond else " FAIL ") + what + (f" [{detail}]" if detail else ""),
|
|
flush=True)
|
|
return bool(cond)
|
|
|
|
|
|
def summary(title):
|
|
bad = [w for ok, w, _ in results if not ok]
|
|
print(f"\n=== {title}: {len(results) - len(bad)}/{len(results)} passed", flush=True)
|
|
for w in bad:
|
|
print(" FAILED: " + w, flush=True)
|
|
return not bad
|