The runbook said nothing in it had been rehearsed. Now the sequence has been, on data made up for the purpose: upstream 0.16.22 in a container as the install running today, the fork beside it, both unprivileged with CAP_NET_BIND_SERVICE. It rehearses the sequence, not the data, which is what the compat tests are for. 27 of 27 checks passed and the rollback took 1.5 seconds. The question §"Open" asked about carrying a store back is answered, and the answer is no. Upstream refuses to start on a store the fork has opened: "Column families not opened: _". The fork adds one RocksDB column family for masked email (SUBSPACE_INBUXA = b'_') and opens with create_missing_column_families, so it creates it on first open; upstream has no descriptor for it and RocksDB will not open a database holding one it was not told about. That makes step 3's "a copy, not a move" load-bearing in a way the step did not say. One open by the fork is enough: pointing it at the original even once, to check something, leaves the Enterprise install unable to start, and there is no rollback after that. It fails loudly and before reading anything, which is the good version of this failure, but it is not recoverable. Two things the rehearsal found that would have wasted time on the day: memberTenantId does not come down from the domain and is refused on create, so a tenant "admin" set up the obvious way is a server administrator and the check passes while proving nothing; and IMAP's INBOX is not JMAP's account, because mail from an unauthenticated sender is filed as spam, so the two counts differ before and after alike. What the rehearsal does not cover is in its README and in §"Open": systemd and `systemctl disable stalwart` above all, ACME renewal, load, the front ends, and INBUXA's own data.
139 lines
4.7 KiB
Python
139 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
# 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
|