v0.2.11
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 10:39:00 +01:00
1 parent 7f643c8509
commit 5dc9db0b1c
1 file changed
+134 -12
+134 -12
View File
@@ -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