Files
jcoffey-dev fdf6d9df5d
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 11m8s
Migrations into this server: inbuxa:Migration
An administrator moves a domain in from inbuxa Admin. The server keeps
the guide's answers as an inbuxa:Migration and runs the bundled
inbuxa-migrate on them as a child process: preview, move, final pass,
stop, the new passwords once, close. Spec: docs/spec/features/migrations.md.

- sysMigrationRun, for administrators only; a tenant is refused.
- The child writes as the administrator who started the migration, with a
  run token held in memory, taken from loopback only and never cached, and
  audited as via: migration.
- The source's secret is held in memory and passed in the child's
  environment, which holds only it, the token and PATH.
- The image carries inbuxa-migrate 27.1-rc.8, pinned by checksum.
- Tests: system::migration with a stand-in engine, and on demand the real
  engine against a Dovecot (tests/resources/migrations/dovecot-source).
2026-10-30 19:48:31 -07:00

97 lines
3.5 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
# SPDX-License-Identifier: AGPL-3.0-only
#
# A stand-in for inbuxa-migrate in tests/src/system/migration.rs: it answers
# `plan` and `migrate` the way the real one does, says what it was given in
# seen-*.json, and signs in to the server with its run token. An account
# named slow@ makes a run wait, so it can be stopped.
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.request
args = sys.argv[1:]
if args[0] == "plan":
answers = json.load(open(args[2]))
json.dump({"args": args, "env": sorted(os.environ)}, open("seen-plan.json", "w"))
print(json.dumps({
"plan": "plan.json",
"preview": "preview-plan.json",
"passwords": "passwords-1.csv" if answers["create"] else None,
"accounts": len(answers["accounts"]),
"secrets": {"source": "MIGRATE_SOURCE_SECRET", "target": "MIGRATE_INBUXA_TOKEN"},
}))
sys.exit(0)
assert args[0] == "migrate", args
answers = json.load(open("answers.json"))
token = os.environ.get("MIGRATE_INBUXA_TOKEN", "")
url = answers["target"]["url"]
insecure = ssl.create_default_context()
insecure.check_hostname = False
insecure.verify_mode = ssl.CERT_NONE
signed_in = None
try:
request = urllib.request.Request(
url + "/jmap/session", headers={"Authorization": "Bearer " + token}
)
session = json.load(urllib.request.urlopen(request, context=insecure, timeout=10))
signed_in = session.get("username")
except (urllib.error.URLError, OSError, ValueError) as err:
signed_in = "failed: " + str(err)
json.dump({
"args": args,
"url": url,
"secret": os.environ.get("MIGRATE_SOURCE_SECRET"),
"token": token,
"signedIn": signed_in,
"env": sorted(os.environ),
}, open("seen-run.json", "w"))
def emit(event, **fields):
fields["event"] = event
print(json.dumps(fields), flush=True)
accounts = answers["accounts"]
dry_run = "--dry-run" in args
emit("start", accounts=len(accounts), parallel=1, dryRun=dry_run)
if "[email protected]" in accounts:
time.sleep(60)
rows = ["account,step,result,fetched,created,updated,deleted,skipped,failed,detail"]
unfinished = 0
for n, email in enumerate(accounts, 1):
emit("step", email=email, step="import")
counts = {"fetched": 3, "created": 3, "updated": 0, "deleted": 0, "skipped": 0, "failed": 0}
emit("step", email=email, step="import", result="done", counts=counts, detail="")
rows.append(f"{email},import,done,3,3,0,0,0,0,")
result = "done"
if not dry_run:
partial = email.startswith("b@")
counts = dict(counts, created=2 if partial else 3, failed=1 if partial else 0)
result = "partial" if partial else "done"
detail = "1 message over the size limit" if partial else ""
emit("step", email=email, step="export", result=result, counts=counts, detail=detail)
rows.append(f"{email},export,{result},3,{counts['created']},0,0,0,{counts['failed']},{detail}")
if result != "done":
unfinished += 1
emit("account", email=email, n=n, total=len(accounts), result=result)
os.makedirs("work", exist_ok=True)
report = "work/dry-run-report.csv" if dry_run else "work/report.csv"
open(report, "w").write("\n".join(rows) + "\n")
if answers["create"] and not dry_run and "--again" not in args:
open("passwords-1.csv", "w").write(
"email,password\n" + "".join(f"{a},generated-{n}\n" for n, a in enumerate(accounts))
)
emit("end", done=len(accounts) - unfinished, unfinished=unfinished, report=report)
sys.exit(5 if unfinished else 0)