From 5dc9db0b1ccb9428ddb889e705a34884c95d73ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 2 Nov 2026 10:39:00 +0100 Subject: [PATCH] v0.2.11 --- scripts/migrate_roundcube.py | 146 ++++++++++++++++++++++++++++++++--- 1 file changed, 134 insertions(+), 12 deletions(-) diff --git a/scripts/migrate_roundcube.py b/scripts/migrate_roundcube.py index 684d7f3..49a342f 100755 --- a/scripts/migrate_roundcube.py +++ b/scripts/migrate_roundcube.py @@ -3,7 +3,8 @@ The tool does not import or start RustBox. By default it reads RustBox's database configuration from the same TOML file as the application, detects SQLite/MySQL/ -PostgreSQL, and writes directly to the already provisioned per-account tables. +PostgreSQL, and writes directly to RustBox per-account tables. With --provision-missing it can +create missing SQLite account tenants before importing data. """ from __future__ import annotations @@ -188,6 +189,11 @@ Dependencies: p.add_argument("--user-hash", help="single-user RustBox user hash override") p.add_argument("--target-prefix", help="single-user RustBox tenant prefix override") p.add_argument("--dry-run", action="store_true", help="read/map only; do not write") + p.add_argument( + "--provision-missing", + action="store_true", + help="create missing RustBox account tenants before import (SQLite target only)", + ) p.add_argument("--fail-fast", action="store_true", help="stop all-users migration after first user failure") return p @@ -414,6 +420,98 @@ def hash_user(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() +def tenant_prefix(user_hash: str) -> str: + """Match RustBox db::pool::tenant_prefix exactly.""" + if not user_hash: + raise ValueError("empty user hash for database isolation") + if len(user_hash) >= 24 and all(ch in "0123456789abcdefABCDEF" for ch in user_hash): + normalized = user_hash.lower() + else: + normalized = hash_user(user_hash) + return f"u_{normalized[:24]}__" + + +def migrations_dir() -> Path: + override = os.getenv("RUSTBOX_MIGRATIONS_DIR") + if override: + return Path(override) + return Path(__file__).resolve().parent.parent / "migrations" + + +def sqlite_tenant_schema() -> tuple[str, int]: + root = migrations_dir() / "sqlite" + schema_path = root / "schema.sql" + if not schema_path.is_file(): + raise FileNotFoundError( + f"RustBox SQLite schema not found: {schema_path}; " + "set RUSTBOX_MIGRATIONS_DIR to the migrations directory" + ) + schema = schema_path.read_text(encoding="utf-8") + begin = "-- RUSTBOX:TENANT-BEGIN" + end = "-- RUSTBOX:TENANT-END" + if begin not in schema or end not in schema: + raise ValueError(f"invalid RustBox tenant schema markers in {schema_path}") + tenant_sql = schema.split(begin, 1)[1].split(end, 1)[0] + + versions = [46] + for path in root.glob("V[0-9][0-9][0-9]__*.sql"): + try: + versions.append(int(path.name[1:4])) + except ValueError: + pass + return tenant_sql, max(versions) + + +def split_sql_statements(sql: str) -> list[str]: + # RustBox schema files contain ordinary DDL/DML statements and no stored + # procedures; splitting on semicolons keeps provisioning transactional in SQLite. + return [statement.strip() for statement in sql.split(";") if statement.strip()] + + +def provision_sqlite_account(target: Db, user_hash: str) -> str: + if target.backend != "sqlite": + raise ValueError("--provision-missing currently supports SQLite targets only") + + prefix = tenant_prefix(user_hash) + existing = target_prefix_for_hash(target, user_hash) + if existing is not None: + if existing != prefix: + raise ValueError("database account-prefix collision detected") + return existing + + ph = target.placeholder + collision = target.fetchone( + f"SELECT user_hash FROM rustbox_accounts WHERE table_prefix={ph}", [prefix] + ) + if collision is not None: + raise ValueError("database account-prefix collision detected") + + tenant_sql, schema_version = sqlite_tenant_schema() + rendered = tenant_sql.replace("{{p}}", prefix) + seed = ( + f"INSERT OR IGNORE INTO {prefix}user_meta (key, value) VALUES ('schema_version', '1');" + f"INSERT OR IGNORE INTO {prefix}notification_preferences (id) VALUES (1);" + f"INSERT OR IGNORE INTO {prefix}mail_preferences (id, messages_per_page) VALUES (1, 300);" + f"INSERT OR IGNORE INTO {prefix}pgp_preferences (id) VALUES (1);" + ) + + try: + target.execute("BEGIN IMMEDIATE").close() + for statement in split_sql_statements(rendered): + target.execute(statement).close() + for statement in split_sql_statements(seed): + target.execute(statement).close() + target.execute( + f"INSERT INTO rustbox_accounts(user_hash, table_prefix, schema_version) VALUES({ph}, {ph}, {ph})", + [user_hash, prefix, schema_version], + ).close() + target.commit() + except Exception: + target.rollback() + raise + return prefix + + def dedupe(values: Iterable[str]) -> list[str]: result: list[str] = [] seen: set[str] = set() @@ -795,6 +893,7 @@ def migrate_one( rustbox_user: str, *, dry_run: bool, + provision_missing: bool = False, user_hash_override: str | None = None, target_prefix_override: str | None = None, ) -> tuple[int, int, int]: @@ -811,10 +910,19 @@ def migrate_one( if prefix is None: match = target_account_for_login(target, rustbox_user) if match is None: - raise ValueError( - f"RustBox account not provisioned for {rustbox_user!r}; log in once first" - ) - _, prefix, _ = match + if not provision_missing: + raise ValueError( + f"RustBox account not provisioned for {rustbox_user!r}; " + "use --provision-missing or log in once first" + ) + prefix = tenant_prefix(user_hash) + if dry_run: + print(f"WOULD PROVISION {rustbox_user}: {prefix}") + else: + prefix = provision_sqlite_account(target, user_hash) + print(f"PROVISION {rustbox_user}: {prefix}") + else: + _, prefix, _ = match print( f"{roundcube_user} -> {rustbox_user}: {len(contacts)} contact(s), " @@ -847,6 +955,8 @@ def run(args: argparse.Namespace) -> int: ) config_path, _config, backend, target_url = resolve_target(args) + if args.provision_missing and backend != "sqlite": + raise ValueError("--provision-missing currently supports SQLite targets only") roundcube_url = build_roundcube_url(args) print(f"RustBox config: {config_path}") @@ -872,6 +982,7 @@ def run(args: argparse.Namespace) -> int: roundcube_user, rustbox_user, dry_run=args.dry_run, + provision_missing=args.provision_missing, user_hash_override=args.user_hash, target_prefix_override=args.target_prefix, ) @@ -887,13 +998,23 @@ def run(args: argparse.Namespace) -> int: try: match = target_account_for_login(target, roundcube_user) if match is None: - skipped += 1 - print( - f"SKIP {roundcube_user}: matching RustBox account is not provisioned", - file=sys.stderr, - ) - continue - _, prefix, rustbox_user = match + if not args.provision_missing: + skipped += 1 + print( + f"SKIP {roundcube_user}: matching RustBox account is not provisioned", + file=sys.stderr, + ) + continue + rustbox_user = roundcube_user + user_hash = hash_user(rustbox_user) + prefix = tenant_prefix(user_hash) + if args.dry_run: + print(f"WOULD PROVISION {rustbox_user}: {prefix}") + else: + prefix = provision_sqlite_account(target, user_hash) + print(f"PROVISION {rustbox_user}: {prefix}") + else: + _, prefix, rustbox_user = match counts = migrate_one( source, target, @@ -901,6 +1022,7 @@ def run(args: argparse.Namespace) -> int: roundcube_user, rustbox_user, dry_run=args.dry_run, + provision_missing=args.provision_missing, target_prefix_override=prefix, ) migrated += 1