From fefd54f02ae29cb97a085ecbd6ce88bb4cd3bafc Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 6 Sep 2026 22:11:17 +0000 Subject: [PATCH] CLI: explicit 'paskia migrate' subcommand for legacy conversion Serve never converts databases: with no paskia.kantadb it points at 'paskia init', or at 'paskia migrate' when legacy *.paskiadb candidates exist. migrate converts a lone candidate, or the one named by --rp-id when several exist; the rest stay in place. devserver fails fast with the same hint. gitignore covers paskia.kantadb and *.converted-bak. --- .gitignore | 2 ++ README.md | 7 ++--- docs/MultiSite.md | 42 ++++++++++++++++------------- paskia/__main__.py | 34 ++++++++++++++++++----- paskia/db/legacy.py | 44 ++++++++++++++++++------------ scripts/devserver.py | 11 +++++--- tests/test_cli.py | 64 ++++++++++++++++++++++++++++++++++++++------ 7 files changed, 148 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index 1afc8db..63adca1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist/ package-lock.json paskia.sqlite *.paskiadb +*.converted-bak +*.kantadb *.data /paskia/frontend-build /paskia/_version.py diff --git a/README.md b/README.md index 7e8ee36..0d37990 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,9 @@ uv tool install paskia Bootstrapping is done once with `paskia init`; after that, `paskia` serves all configured realms from the database `paskia.kantadb` in the current directory. Realm configuration (rp-name, auth host, origins) is managed via the admin web interface, including adding further realms (rp-ids). ```text -paskia init [options] # one-time bootstrap -paskia [-l endpoint] # serve +paskia init [options] # one-time bootstrap +paskia migrate [--rp-id] # convert a legacy {rp-id}.paskiadb database +paskia [-l endpoint] # serve ``` | init option | Description | Default | @@ -67,7 +68,7 @@ paskia [-l endpoint] # serve | --origin *url* | Only sites listed can login on the default realm (repeatable) | rp-id and all subdomains | | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site | -The `paskia` serve command accepts only `--listen` (overriding the stored value). An existing legacy `{rp-id}.paskiadb` database is converted to `paskia.kantadb` automatically on first serve. +The `paskia` serve command accepts only `--listen` (overriding the stored value) and never converts databases: with no `paskia.kantadb` it tells you to run `paskia init`, or `paskia migrate` when a legacy `{rp-id}.paskiadb` database is present. `paskia migrate` converts the legacy database; with several candidates, `--rp-id` selects one by name and the rest are left in place. ## Tutorial: From Local Testing to Production diff --git a/docs/MultiSite.md b/docs/MultiSite.md index bee0a0b..65f29c1 100644 --- a/docs/MultiSite.md +++ b/docs/MultiSite.md @@ -188,15 +188,19 @@ instance: - `--listen`: stored into `Config.listen` (process-global). - Seeds the admin user + registration reset link (link URL from the default realm) and prints the link. Refuses to run if - `paskia.kantadb` already exists, or if an un-adopted legacy - `*.paskiadb` is present (serve adopts it first). + `paskia.kantadb` already exists, or if an unconverted legacy + `*.paskiadb` is present (`paskia migrate` converts it first). +- **`paskia migrate`** — converts a legacy `.paskiadb` database + (§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id` + selects `.paskiadb` by name; the others are left in place. - **`paskia`** — serve. Takes **no realm options**; only `--listen` (per-run override of stored `Config.listen`, never persisted). Startup: - legacy-adoption pre-flight → open `paskia.kantadb` → validate the - stored realm set cross-realm (rp-ids distinct; auth hosts distinct from - each other and from every rp-id; related origins capped and - collision-free) → build the realm registry → serve. Missing database → - startup error pointing at `paskia init`. + open `paskia.kantadb` → validate the stored realm set cross-realm + (rp-ids distinct; auth hosts distinct from each other and from every + rp-id; related origins capped and collision-free) → build the realm + registry → serve. The serve command never converts databases: with no + `paskia.kantadb`, the startup error points at `paskia init`, or at + `paskia migrate` when legacy `*.paskiadb` candidates are present. Nested rp-ids are allowed (longest-suffix dispatch determinism). Adding a child rp-id moves **no data** — users are global; only new ceremonies @@ -397,17 +401,17 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No deployment. - **User files** (avatars) live in the fixed sibling directory **`paskia.data/users/`**. -- **Legacy adoption**: if `paskia.kantadb` is absent and exactly one - `*.paskiadb` candidate exists in CWD — a directory containing - `main.db`, or a legacy single-file database — it is adopted: `main.db` - (or the single file) becomes `paskia.kantadb`, `users/` becomes - `paskia.data/users/`, and the old directory is renamed aside to - `.converted-bak`. Multiple candidates → startup error listing - them, asking the operator to remove or rename strays (e.g. a - `*.bak.paskiadb` backup); empty directories are ignored. Adoption runs - as an explicit pre-flight step in the serve command, before the - read-only startup open — read-only opens never trigger adoption or - writes. +- **Legacy conversion**: `paskia migrate` converts a legacy + `*.paskiadb` database — a directory containing `main.db`, or a legacy + single-file database — into `paskia.kantadb`: `main.db` (or the single + file) becomes `paskia.kantadb`, `users/` becomes `paskia.data/users/`, + and the old directory is renamed aside to `.converted-bak`. A + lone candidate converts without options; with several candidates + `--rp-id ` selects `.paskiadb` by name and the rest are + left in place (e.g. a `*.bak.paskiadb` backup does not block + conversion). Empty directories are ignored. Conversion is an explicit + operator action, never a serve side effect — read-only opens never + trigger conversion or writes. - The legacy database's structs live in a separate module (`paskia/db/legacy.py`). There is no multi-database merging. - The startup box prints per-realm lines. @@ -417,7 +421,7 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No - One `Kanta` for `paskia.kantadb`, opened once in the lifespan; one background cleanup task (DB is global). - The kanta bootstrap hook only ever fires for a database created by - `paskia init`; the serve command never bootstraps. + `paskia init` or `paskia migrate`; the serve command never bootstraps. - The registry is built from the stored `Config` after open; per-realm `Passkey` instances constructed (each realm's origins validated at startup — fail-fast, including related-origin cap checks). diff --git a/paskia/__main__.py b/paskia/__main__.py index 25ef656..8ba5393 100644 --- a/paskia/__main__.py +++ b/paskia/__main__.py @@ -28,6 +28,7 @@ from paskia.util.runtime import ServeConfig EPILOG = """\ Examples: paskia init --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com + paskia migrate --rp-id example.com paskia """ @@ -87,8 +88,8 @@ def cmd_init(args: argparse.Namespace) -> None: if found := legacy.find_legacy_databases(): names = ", ".join(str(p) for p in found) raise SystemExit( - f"Legacy database(s) found ({names}) — run 'paskia' to adopt " - "and convert, not 'paskia init'." + f"Legacy database(s) found ({names}) — run 'paskia migrate' to " + "convert, not 'paskia init'." ) rp_ids = _split_multi(args.rp_id) or ["localhost"] @@ -148,14 +149,22 @@ def cmd_init(args: argparse.Namespace) -> None: ) +def cmd_migrate(args: argparse.Namespace) -> None: + """Convert a legacy .paskiadb database to paskia.kantadb.""" + rp_id = legacy.migrate_legacy_database(args.rp_id) + print(f"✅ Converted legacy database to {db_file_path()} (realm: {rp_id})") + + def cmd_serve(args: argparse.Namespace) -> None: """Open the combined database and serve all configured realms.""" db_path = db_file_path() if not db_path.exists(): - adopted = legacy.adopt_legacy_if_present() - if adopted: - print(f"✅ Converted legacy database to {db_path} (realm: {adopted})") - if not db_path.exists(): + if found := legacy.find_legacy_databases(): + names = ", ".join(str(p) for p in found) + raise SystemExit( + f"Database {db_path} not found, but legacy database(s) exist " + f"({names}) — run 'paskia migrate' to convert." + ) raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.") config = _load_stored_config(db_path) @@ -233,9 +242,22 @@ def main(): ) _add_listen_option(init_parser, help_extra=" (stored in the database)") + migrate_parser = argparse.ArgumentParser( + prog="paskia migrate", + description="Convert a legacy .paskiadb database to paskia.kantadb", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + migrate_parser.add_argument( + "--rp-id", + help="rp-id of the legacy database to convert, selecting " + ".paskiadb when several legacy candidates exist.", + ) + argv = sys.argv[1:] if argv and argv[0] == "init": cmd_init(init_parser.parse_args(argv[1:])) + elif argv and argv[0] == "migrate": + cmd_migrate(migrate_parser.parse_args(argv[1:])) else: cmd_serve(parser.parse_args(argv)) diff --git a/paskia/db/legacy.py b/paskia/db/legacy.py index cb8d5a1..1be8f7b 100644 --- a/paskia/db/legacy.py +++ b/paskia/db/legacy.py @@ -8,7 +8,7 @@ current schema are redefined here; unchanged structs are imported from Assumes the on-disk records are in the latest legacy format (schema migrations were discarded together with the old format). This module will -be deleted once legacy adoption is no longer supported. +be deleted once legacy conversion is no longer supported. """ from __future__ import annotations @@ -204,31 +204,41 @@ def find_legacy_databases(cwd: Path | None = None) -> list[Path]: return candidates -def adopt_legacy_if_present() -> str | None: - """Convert a lone legacy database to ``paskia.kantadb`` if present. +def migrate_legacy_database(rp_id: str | None = None) -> str: + """Convert a legacy database to ``paskia.kantadb``. - Returns the adopted realm's rp-id, or None when ``paskia.kantadb`` - already exists or no legacy database is present. The converted legacy - directory/file is renamed aside to ``.converted-bak`` rather than - deleted. + With ``rp_id``, selects the ``.paskiadb`` candidate by name; + without it, exactly one candidate must exist. Returns the migrated + realm's rp-id. The converted legacy directory/file is renamed aside + to ``.converted-bak`` rather than deleted. - Raises SystemExit when multiple legacy databases are found — automatic - merging is not supported. + Raises SystemExit when ``paskia.kantadb`` already exists, when no + candidate matches, or when several candidates exist and no ``rp_id`` + was given to select one. """ target = db_file_path() if target.exists(): - return None + raise SystemExit(f"Database {target} already exists — nothing to migrate.") candidates = find_legacy_databases() - if not candidates: - return None - if len(candidates) > 1: + if rp_id is not None: + name = f"{rp_id}.paskiadb" + matches = [c for c in candidates if c.name == name] + if not matches: + found = ", ".join(str(c) for c in candidates) or "none" + raise SystemExit( + f"No legacy database {name} in this directory (candidates: {found})." + ) + src = matches[0] + elif not candidates: + raise SystemExit("No legacy *.paskiadb database found — nothing to migrate.") + elif len(candidates) > 1: names = ", ".join(str(c) for c in candidates) raise SystemExit( - f"Multiple legacy databases found ({names}). Automatic merging is " - "not supported — remove or rename all but the one to adopt." + f"Multiple legacy databases found ({names}) — select one with " + "'paskia migrate --rp-id '." ) - - src = candidates[0] + else: + src = candidates[0] legacy_file = src / "main.db" if src.is_dir() else src config = convert_legacy_database(legacy_file, target) diff --git a/scripts/devserver.py b/scripts/devserver.py index f9f1cb8..0a175d0 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -151,11 +151,16 @@ def ensure_database(rp_ids: list[str], args: argparse.Namespace, listen: str) -> """Bootstrap paskia.kantadb via 'paskia init' when no database exists. Realm options are init-only; 'paskia' (serve) reads all configuration - from the database. A legacy *.paskiadb database is adopted by serve, - so no init is run in that case either. + from the database. A legacy *.paskiadb database must be converted with + 'paskia migrate' first. """ - if db_file_path().exists() or find_legacy_databases(): + if db_file_path().exists(): return + if find_legacy_databases(): + raise SystemExit( + "Legacy *.paskiadb database found — run 'paskia migrate' to " + "convert it before starting the dev server." + ) cmd = [sys.executable, "-m", "paskia", "init", f"--listen={listen}"] for rp_id in rp_ids: diff --git a/tests/test_cli.py b/tests/test_cli.py index 451f861..2f1c853 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,9 @@ """Tests for the CLI entry point in paskia/__main__.py. The CLI is split into ``paskia init`` (create the combined paskia.kantadb -with the initial realm(s)) and bare ``paskia`` (serve the stored realms, -adopting a lone legacy ``.paskiadb`` database if present). +with the initial realm(s)), ``paskia migrate`` (convert a legacy +``.paskiadb`` database), and bare ``paskia`` (serve the stored +realms; never migrates). """ from __future__ import annotations @@ -165,7 +166,13 @@ def test_serve_listen_override_not_persisted(run_cli, tmp_path): assert stored_config(tmp_path).listen == ["4402"] -def test_serve_adopts_legacy_database(run_cli, tmp_path): +def test_serve_suggests_migrate_when_legacy_present(run_cli, tmp_path): + write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com")) + with pytest.raises(SystemExit, match="paskia migrate"): + run_cli() + + +def test_migrate_converts_legacy_database(run_cli, tmp_path): src_dir = write_legacy_db( tmp_path, legacy.LegacyConfig(rp_id="example.com", rp_name="Legacy Name") ) @@ -174,12 +181,12 @@ def test_serve_adopts_legacy_database(run_cli, tmp_path): avatar.mkdir(parents=True) (avatar / "profile.webp").write_bytes(b"RIFF1234WEBP") - run_cli() + run_cli("migrate") config = stored_config(tmp_path) assert [r.rp_id for r in config.realms] == ["example.com"] assert config.realms[0].rp_name == "Legacy Name" - # Legacy directory renamed aside, user files adopted + # Legacy directory renamed aside, user files moved over assert not src_dir.exists() assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir() assert ( @@ -191,11 +198,41 @@ def test_serve_adopts_legacy_database(run_cli, tmp_path): ).read_bytes() == b"RIFF1234WEBP" -def test_serve_multiple_legacy_databases_abort(run_cli, tmp_path): +def test_migrate_multiple_legacy_databases_require_rp_id(run_cli, tmp_path): write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com")) write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com")) - with pytest.raises(SystemExit, match="Multiple legacy"): - run_cli() + with pytest.raises(SystemExit, match="--rp-id"): + run_cli("migrate") + + +def test_migrate_explicit_rp_id_selects_candidate(run_cli, tmp_path): + write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com")) + write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com")) + + run_cli("migrate", "--rp-id", "two.com") + + config = stored_config(tmp_path) + assert [r.rp_id for r in config.realms] == ["two.com"] + # The other candidate is left in place + assert (tmp_path / "one.com.paskiadb").is_dir() + assert (tmp_path / "two.com.paskiadb.converted-bak").is_dir() + + +def test_migrate_unknown_rp_id(run_cli, tmp_path): + write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com")) + with pytest.raises(SystemExit, match="nope.com.paskiadb"): + run_cli("migrate", "--rp-id", "nope.com") + + +def test_migrate_refuses_existing_database(run_cli): + run_cli("init") + with pytest.raises(SystemExit, match="already exists"): + run_cli("migrate") + + +def test_migrate_without_legacy_database(run_cli): + with pytest.raises(SystemExit, match="No legacy"): + run_cli("migrate") def test_cli_help(): @@ -218,3 +255,14 @@ def test_cli_init_help(): ) assert result.returncode == 0 assert "Bootstrap" in result.stdout + + +def test_cli_migrate_help(): + result = subprocess.run( + [sys.executable, "-m", "paskia", "migrate", "--help"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "Convert" in result.stdout