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
|
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
|
||||||
|
|||||||
Reference in new issue
Block a user