1 parent
7f643c8509
commit
5dc9db0b1c
1 file changed
+134
-12
+134
-12
@@ -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
|
||||
|
||||
Reference in new issue
Block a user