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.
This commit is contained in:
2026-09-06 22:11:17 +00:00
parent 9b10663c10
commit fefd54f02a
7 changed files with 148 additions and 56 deletions
+2
View File
@@ -6,6 +6,8 @@ dist/
package-lock.json package-lock.json
paskia.sqlite paskia.sqlite
*.paskiadb *.paskiadb
*.converted-bak
*.kantadb
*.data *.data
/paskia/frontend-build /paskia/frontend-build
/paskia/_version.py /paskia/_version.py
+4 -3
View File
@@ -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). 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 ```text
paskia init [options] # one-time bootstrap paskia init [options] # one-time bootstrap
paskia [-l endpoint] # serve paskia migrate [--rp-id] # convert a legacy {rp-id}.paskiadb database
paskia [-l endpoint] # serve
``` ```
| init option | Description | Default | | 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 | | --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 | | --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 ## Tutorial: From Local Testing to Production
+23 -19
View File
@@ -188,15 +188,19 @@ instance:
- `--listen`: stored into `Config.listen` (process-global). - `--listen`: stored into `Config.listen` (process-global).
- Seeds the admin user + registration reset link (link URL from the - Seeds the admin user + registration reset link (link URL from the
default realm) and prints the link. Refuses to run if default realm) and prints the link. Refuses to run if
`paskia.kantadb` already exists, or if an un-adopted legacy `paskia.kantadb` already exists, or if an unconverted legacy
`*.paskiadb` is present (serve adopts it first). `*.paskiadb` is present (`paskia migrate` converts it first).
- **`paskia migrate`** — converts a legacy `<rp-id>.paskiadb` database
(§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id`
selects `<rp-id>.paskiadb` by name; the others are left in place.
- **`paskia`** — serve. Takes **no realm options**; only `--listen` - **`paskia`** — serve. Takes **no realm options**; only `--listen`
(per-run override of stored `Config.listen`, never persisted). Startup: (per-run override of stored `Config.listen`, never persisted). Startup:
legacy-adoption pre-flight → open `paskia.kantadb` → validate the open `paskia.kantadb` → validate the stored realm set cross-realm
stored realm set cross-realm (rp-ids distinct; auth hosts distinct from (rp-ids distinct; auth hosts distinct from each other and from every
each other and from every rp-id; related origins capped and rp-id; related origins capped and collision-free) → build the realm
collision-free) → build the realm registry → serve. Missing database → registry → serve. The serve command never converts databases: with no
startup error pointing at `paskia init`. `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 Nested rp-ids are allowed (longest-suffix dispatch determinism). Adding a
child rp-id moves **no data** — users are global; only new ceremonies 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. deployment.
- **User files** (avatars) live in the fixed sibling directory - **User files** (avatars) live in the fixed sibling directory
**`paskia.data/users/`**. **`paskia.data/users/`**.
- **Legacy adoption**: if `paskia.kantadb` is absent and exactly one - **Legacy conversion**: `paskia migrate` converts a legacy
`*.paskiadb` candidate exists in CWD — a directory containing `*.paskiadb` database — a directory containing `main.db`, or a legacy
`main.db`, or a legacy single-file database — it is adopted: `main.db` single-file database — into `paskia.kantadb`: `main.db` (or the single
(or the single file) becomes `paskia.kantadb`, `users/` becomes file) becomes `paskia.kantadb`, `users/` becomes `paskia.data/users/`,
`paskia.data/users/`, and the old directory is renamed aside to and the old directory is renamed aside to `<name>.converted-bak`. A
`<name>.converted-bak`. Multiple candidates → startup error listing lone candidate converts without options; with several candidates
them, asking the operator to remove or rename strays (e.g. a `--rp-id <rp-id>` selects `<rp-id>.paskiadb` by name and the rest are
`*.bak.paskiadb` backup); empty directories are ignored. Adoption runs left in place (e.g. a `*.bak.paskiadb` backup does not block
as an explicit pre-flight step in the serve command, before the conversion). Empty directories are ignored. Conversion is an explicit
read-only startup open — read-only opens never trigger adoption or operator action, never a serve side effect — read-only opens never
writes. trigger conversion or writes.
- The legacy database's structs live in a separate module - The legacy database's structs live in a separate module
(`paskia/db/legacy.py`). There is no multi-database merging. (`paskia/db/legacy.py`). There is no multi-database merging.
- The startup box prints per-realm lines. - 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 - One `Kanta` for `paskia.kantadb`, opened once in the lifespan; one
background cleanup task (DB is global). background cleanup task (DB is global).
- The kanta bootstrap hook only ever fires for a database created by - 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 - The registry is built from the stored `Config` after open; per-realm
`Passkey` instances constructed (each realm's origins validated at `Passkey` instances constructed (each realm's origins validated at
startup — fail-fast, including related-origin cap checks). startup — fail-fast, including related-origin cap checks).
+28 -6
View File
@@ -28,6 +28,7 @@ from paskia.util.runtime import ServeConfig
EPILOG = """\ EPILOG = """\
Examples: Examples:
paskia init --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com paskia init --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
paskia migrate --rp-id example.com
paskia paskia
""" """
@@ -87,8 +88,8 @@ def cmd_init(args: argparse.Namespace) -> None:
if found := legacy.find_legacy_databases(): if found := legacy.find_legacy_databases():
names = ", ".join(str(p) for p in found) names = ", ".join(str(p) for p in found)
raise SystemExit( raise SystemExit(
f"Legacy database(s) found ({names}) — run 'paskia' to adopt " f"Legacy database(s) found ({names}) — run 'paskia migrate' to "
"and convert, not 'paskia init'." "convert, not 'paskia init'."
) )
rp_ids = _split_multi(args.rp_id) or ["localhost"] 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 <rp-id>.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: def cmd_serve(args: argparse.Namespace) -> None:
"""Open the combined database and serve all configured realms.""" """Open the combined database and serve all configured realms."""
db_path = db_file_path() db_path = db_file_path()
if not db_path.exists(): if not db_path.exists():
adopted = legacy.adopt_legacy_if_present() if found := legacy.find_legacy_databases():
if adopted: names = ", ".join(str(p) for p in found)
print(f"✅ Converted legacy database to {db_path} (realm: {adopted})") raise SystemExit(
if not db_path.exists(): 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.") raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.")
config = _load_stored_config(db_path) config = _load_stored_config(db_path)
@@ -233,9 +242,22 @@ def main():
) )
_add_listen_option(init_parser, help_extra=" (stored in the database)") _add_listen_option(init_parser, help_extra=" (stored in the database)")
migrate_parser = argparse.ArgumentParser(
prog="paskia migrate",
description="Convert a legacy <rp-id>.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 "
"<rp-id>.paskiadb when several legacy candidates exist.",
)
argv = sys.argv[1:] argv = sys.argv[1:]
if argv and argv[0] == "init": if argv and argv[0] == "init":
cmd_init(init_parser.parse_args(argv[1:])) cmd_init(init_parser.parse_args(argv[1:]))
elif argv and argv[0] == "migrate":
cmd_migrate(migrate_parser.parse_args(argv[1:]))
else: else:
cmd_serve(parser.parse_args(argv)) cmd_serve(parser.parse_args(argv))
+27 -17
View File
@@ -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 Assumes the on-disk records are in the latest legacy format (schema
migrations were discarded together with the old format). This module will 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 from __future__ import annotations
@@ -204,31 +204,41 @@ def find_legacy_databases(cwd: Path | None = None) -> list[Path]:
return candidates return candidates
def adopt_legacy_if_present() -> str | None: def migrate_legacy_database(rp_id: str | None = None) -> str:
"""Convert a lone legacy database to ``paskia.kantadb`` if present. """Convert a legacy database to ``paskia.kantadb``.
Returns the adopted realm's rp-id, or None when ``paskia.kantadb`` With ``rp_id``, selects the ``<rp-id>.paskiadb`` candidate by name;
already exists or no legacy database is present. The converted legacy without it, exactly one candidate must exist. Returns the migrated
directory/file is renamed aside to ``<name>.converted-bak`` rather than realm's rp-id. The converted legacy directory/file is renamed aside
deleted. to ``<name>.converted-bak`` rather than deleted.
Raises SystemExit when multiple legacy databases are found — automatic Raises SystemExit when ``paskia.kantadb`` already exists, when no
merging is not supported. candidate matches, or when several candidates exist and no ``rp_id``
was given to select one.
""" """
target = db_file_path() target = db_file_path()
if target.exists(): if target.exists():
return None raise SystemExit(f"Database {target} already exists — nothing to migrate.")
candidates = find_legacy_databases() candidates = find_legacy_databases()
if not candidates: if rp_id is not None:
return None name = f"{rp_id}.paskiadb"
if len(candidates) > 1: 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) names = ", ".join(str(c) for c in candidates)
raise SystemExit( raise SystemExit(
f"Multiple legacy databases found ({names}). Automatic merging is " f"Multiple legacy databases found ({names}) — select one with "
"not supported — remove or rename all but the one to adopt." "'paskia migrate --rp-id <rp-id>'."
) )
else:
src = candidates[0] src = candidates[0]
legacy_file = src / "main.db" if src.is_dir() else src legacy_file = src / "main.db" if src.is_dir() else src
config = convert_legacy_database(legacy_file, target) config = convert_legacy_database(legacy_file, target)
+8 -3
View File
@@ -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. """Bootstrap paskia.kantadb via 'paskia init' when no database exists.
Realm options are init-only; 'paskia' (serve) reads all configuration Realm options are init-only; 'paskia' (serve) reads all configuration
from the database. A legacy *.paskiadb database is adopted by serve, from the database. A legacy *.paskiadb database must be converted with
so no init is run in that case either. 'paskia migrate' first.
""" """
if db_file_path().exists() or find_legacy_databases(): if db_file_path().exists():
return 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}"] cmd = [sys.executable, "-m", "paskia", "init", f"--listen={listen}"]
for rp_id in rp_ids: for rp_id in rp_ids:
+56 -8
View File
@@ -1,8 +1,9 @@
"""Tests for the CLI entry point in paskia/__main__.py. """Tests for the CLI entry point in paskia/__main__.py.
The CLI is split into ``paskia init`` (create the combined paskia.kantadb The CLI is split into ``paskia init`` (create the combined paskia.kantadb
with the initial realm(s)) and bare ``paskia`` (serve the stored realms, with the initial realm(s)), ``paskia migrate`` (convert a legacy
adopting a lone legacy ``<rp-id>.paskiadb`` database if present). ``<rp-id>.paskiadb`` database), and bare ``paskia`` (serve the stored
realms; never migrates).
""" """
from __future__ import annotations 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"] 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( src_dir = write_legacy_db(
tmp_path, legacy.LegacyConfig(rp_id="example.com", rp_name="Legacy Name") 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.mkdir(parents=True)
(avatar / "profile.webp").write_bytes(b"RIFF1234WEBP") (avatar / "profile.webp").write_bytes(b"RIFF1234WEBP")
run_cli() run_cli("migrate")
config = stored_config(tmp_path) config = stored_config(tmp_path)
assert [r.rp_id for r in config.realms] == ["example.com"] assert [r.rp_id for r in config.realms] == ["example.com"]
assert config.realms[0].rp_name == "Legacy Name" 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 not src_dir.exists()
assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir() assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir()
assert ( assert (
@@ -191,11 +198,41 @@ def test_serve_adopts_legacy_database(run_cli, tmp_path):
).read_bytes() == b"RIFF1234WEBP" ).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="one.com"))
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com")) write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com"))
with pytest.raises(SystemExit, match="Multiple legacy"): with pytest.raises(SystemExit, match="--rp-id"):
run_cli() 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(): def test_cli_help():
@@ -218,3 +255,14 @@ def test_cli_init_help():
) )
assert result.returncode == 0 assert result.returncode == 0
assert "Bootstrap" in result.stdout 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