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 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/ 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 from __future__ import annotations
@@ -188,6 +189,11 @@ Dependencies:
p.add_argument("--user-hash", help="single-user RustBox user hash override") 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("--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("--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") p.add_argument("--fail-fast", action="store_true", help="stop all-users migration after first user failure")
return p return p
@@ -414,6 +420,98 @@ def hash_user(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest() 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]: def dedupe(values: Iterable[str]) -> list[str]:
result: list[str] = [] result: list[str] = []
seen: set[str] = set() seen: set[str] = set()
@@ -795,6 +893,7 @@ def migrate_one(
rustbox_user: str, rustbox_user: str,
*, *,
dry_run: bool, dry_run: bool,
provision_missing: bool = False,
user_hash_override: str | None = None, user_hash_override: str | None = None,
target_prefix_override: str | None = None, target_prefix_override: str | None = None,
) -> tuple[int, int, int]: ) -> tuple[int, int, int]:
@@ -811,10 +910,19 @@ def migrate_one(
if prefix is None: if prefix is None:
match = target_account_for_login(target, rustbox_user) match = target_account_for_login(target, rustbox_user)
if match is None: if match is None:
raise ValueError( if not provision_missing:
f"RustBox account not provisioned for {rustbox_user!r}; log in once first" raise ValueError(
) f"RustBox account not provisioned for {rustbox_user!r}; "
_, prefix, _ = match "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( print(
f"{roundcube_user} -> {rustbox_user}: {len(contacts)} contact(s), " 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) 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) roundcube_url = build_roundcube_url(args)
print(f"RustBox config: {config_path}") print(f"RustBox config: {config_path}")
@@ -872,6 +982,7 @@ def run(args: argparse.Namespace) -> int:
roundcube_user, roundcube_user,
rustbox_user, rustbox_user,
dry_run=args.dry_run, dry_run=args.dry_run,
provision_missing=args.provision_missing,
user_hash_override=args.user_hash, user_hash_override=args.user_hash,
target_prefix_override=args.target_prefix, target_prefix_override=args.target_prefix,
) )
@@ -887,13 +998,23 @@ def run(args: argparse.Namespace) -> int:
try: try:
match = target_account_for_login(target, roundcube_user) match = target_account_for_login(target, roundcube_user)
if match is None: if match is None:
skipped += 1 if not args.provision_missing:
print( skipped += 1
f"SKIP {roundcube_user}: matching RustBox account is not provisioned", print(
file=sys.stderr, f"SKIP {roundcube_user}: matching RustBox account is not provisioned",
) file=sys.stderr,
continue )
_, prefix, rustbox_user = match 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( counts = migrate_one(
source, source,
target, target,
@@ -901,6 +1022,7 @@ def run(args: argparse.Namespace) -> int:
roundcube_user, roundcube_user,
rustbox_user, rustbox_user,
dry_run=args.dry_run, dry_run=args.dry_run,
provision_missing=args.provision_missing,
target_prefix_override=prefix, target_prefix_override=prefix,
) )
migrated += 1 migrated += 1