Skip to content

Commit 5e19ad2

Browse files
committed
fix: handle newer db revisions during init
- detect when the installed shellbrain package is older than the database revision\n- report a clean blocked conflict from init and a friendly admin migrate error\n- bump version to 0.1.24
1 parent 643b0fd commit 5e19ad2

6 files changed

Lines changed: 141 additions & 12 deletions

File tree

‎app/boot/migrations.py‎

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
from __future__ import annotations
44

55
from importlib.resources import as_file, files
6+
import importlib.metadata
67

78
from alembic import command
89
from alembic.config import Config
10+
from alembic.script import ScriptDirectory
11+
from alembic.script.revision import ResolutionError
912

1013
from app.boot.admin_db import get_admin_db_dsn, get_backup_dir, get_backup_mirror_dir, get_instance_mode_default
1114
from app.boot.db import get_optional_db_dsn
@@ -14,20 +17,26 @@
1417
from app.periphery.admin.privileges import reconcile_app_role_privileges
1518

1619

20+
class DatabaseRevisionAheadOfInstalledPackageError(RuntimeError):
21+
"""Raised when the target database revision is newer than the installed package knows about."""
22+
23+
1724
def upgrade_database(revision: str = "head") -> None:
1825
"""Apply packaged Alembic migrations to the configured database."""
1926

2027
config = Config()
2128
admin_dsn = get_admin_db_dsn()
22-
if _database_has_shellbrain_objects(admin_dsn):
23-
backup_and_verify_before_destructive_action(
24-
admin_dsn=admin_dsn,
25-
backup_root=get_backup_dir(),
26-
mirror_root=get_backup_mirror_dir(),
27-
)
28-
config.set_main_option("sqlalchemy.url", admin_dsn)
2929
with as_file(files("app").joinpath("migrations")) as migrations_path:
3030
config.set_main_option("script_location", str(migrations_path))
31+
script = ScriptDirectory.from_config(config)
32+
if _database_has_shellbrain_objects(admin_dsn):
33+
_assert_database_revision_is_known(admin_dsn=admin_dsn, script=script)
34+
backup_and_verify_before_destructive_action(
35+
admin_dsn=admin_dsn,
36+
backup_root=get_backup_dir(),
37+
mirror_root=get_backup_mirror_dir(),
38+
)
39+
config.set_main_option("sqlalchemy.url", admin_dsn)
3140
command.upgrade(config, revision)
3241
if fetch_instance_metadata(admin_dsn) is None:
3342
ensure_instance_metadata(
@@ -59,3 +68,48 @@ def _database_has_shellbrain_objects(admin_dsn: str) -> bool:
5968
"""
6069
)
6170
return bool(cur.fetchone()[0])
71+
72+
73+
def _assert_database_revision_is_known(*, admin_dsn: str, script: ScriptDirectory) -> None:
74+
"""Fail early with a user-facing error when the database revision is newer than this package."""
75+
76+
current_revision = _fetch_database_revision(admin_dsn)
77+
if current_revision is None:
78+
return
79+
try:
80+
script.get_revision(current_revision)
81+
except ResolutionError as exc:
82+
installed_version = _installed_shellbrain_version()
83+
raise DatabaseRevisionAheadOfInstalledPackageError(
84+
"Installed Shellbrain package "
85+
f"({installed_version}) cannot manage database revision {current_revision}. "
86+
"This database was likely migrated by a newer Shellbrain release than the one currently installed. "
87+
"Upgrade Shellbrain to a build that includes this revision, then rerun `shellbrain init` or "
88+
"`shellbrain admin migrate`."
89+
) from exc
90+
91+
92+
def _fetch_database_revision(admin_dsn: str) -> str | None:
93+
"""Return the current alembic revision when present."""
94+
95+
import psycopg
96+
97+
try:
98+
with psycopg.connect(admin_dsn.replace("+psycopg", "")) as conn:
99+
with conn.cursor() as cur:
100+
cur.execute("SELECT version_num FROM alembic_version")
101+
row = cur.fetchone()
102+
except psycopg.Error:
103+
return None
104+
if row is None or row[0] is None:
105+
return None
106+
return str(row[0])
107+
108+
109+
def _installed_shellbrain_version() -> str:
110+
"""Return the installed Shellbrain package version when available."""
111+
112+
try:
113+
return importlib.metadata.version("shellbrain")
114+
except importlib.metadata.PackageNotFoundError:
115+
return "dev"

‎app/periphery/admin/init.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,10 +469,13 @@ def _reconcile_database(config: MachineConfig) -> tuple[bool, MachineConfig]:
469469
def _apply_schema_migrations(config: MachineConfig) -> bool:
470470
"""Apply packaged schema migrations to the configured Shellbrain database."""
471471

472-
from app.boot.migrations import upgrade_database
472+
from app.boot.migrations import DatabaseRevisionAheadOfInstalledPackageError, upgrade_database
473473

474474
before_revision = _fetch_schema_revision(config.database.admin_dsn)
475-
upgrade_database()
475+
try:
476+
upgrade_database()
477+
except DatabaseRevisionAheadOfInstalledPackageError as exc:
478+
raise InitConflictError(str(exc)) from exc
476479
after_revision = _fetch_schema_revision(config.database.admin_dsn)
477480
return before_revision != after_revision
478481

‎app/periphery/cli/main.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -714,9 +714,13 @@ def _run_admin_command(args: argparse.Namespace) -> int:
714714
"""Execute one admin command."""
715715

716716
if args.admin_command == "migrate":
717-
from app.boot.migrations import upgrade_database
717+
from app.boot.migrations import DatabaseRevisionAheadOfInstalledPackageError, upgrade_database
718718

719-
upgrade_database()
719+
try:
720+
upgrade_database()
721+
except DatabaseRevisionAheadOfInstalledPackageError as exc:
722+
print(str(exc), file=sys.stderr)
723+
return 1
720724
print("Applied shellbrain schema migrations to head.")
721725
return 0
722726

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "shellbrain"
7-
version = "0.1.23"
7+
version = "0.1.24"
88
description = "Repo-scoped Shellbrain CLI with explicit evidence-backed writes."
99
readme = "README.md"
1010
requires-python = ">=3.11"

‎tests/config/test_cli_surface.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,27 @@ def test_admin_migrate_should_invoke_packaged_migration_runner(
566566
assert "Applied shellbrain schema migrations to head." in capsys.readouterr().out
567567

568568

569+
def test_admin_migrate_should_fail_cleanly_when_installed_package_is_older_than_database_revision(
570+
monkeypatch,
571+
capsys: pytest.CaptureFixture[str],
572+
) -> None:
573+
"""admin migrate should print one clear message when the database revision is newer than this package."""
574+
575+
from app.boot.migrations import DatabaseRevisionAheadOfInstalledPackageError
576+
577+
monkeypatch.setattr(
578+
"app.boot.migrations.upgrade_database",
579+
lambda: (_ for _ in ()).throw(
580+
DatabaseRevisionAheadOfInstalledPackageError("Installed Shellbrain package (0.1.22) cannot manage database revision 20260415_0012.")
581+
),
582+
)
583+
584+
exit_code = cli_main.main(["admin", "migrate"])
585+
586+
assert exit_code == 1
587+
assert "cannot manage database revision 20260415_0012" in capsys.readouterr().err
588+
589+
569590
def test_operational_command_should_fail_cleanly_when_app_role_is_unsafe(
570591
monkeypatch,
571592
tmp_path: Path,

‎tests/operations/recovery/execution/test_init_bootstrap.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,53 @@ def test_run_init_should_mark_repair_needed_when_blocked_by_conflict(tmp_path: P
131131
assert marked == ["managed port already claimed"]
132132

133133

134+
def test_run_init_should_block_cleanly_when_installed_package_is_older_than_database_revision(
135+
tmp_path: Path,
136+
monkeypatch,
137+
) -> None:
138+
"""init should report one blocked conflict instead of leaking an Alembic traceback on schema-version mismatch."""
139+
140+
from app.boot.migrations import DatabaseRevisionAheadOfInstalledPackageError
141+
142+
repo_root = tmp_path / "repo"
143+
repo_root.mkdir()
144+
home_root = tmp_path / "home"
145+
initial_config = _machine_config(bootstrap_state="provisioning", last_error=None)
146+
marked: list[str] = []
147+
148+
monkeypatch.setattr(init_module, "get_shellbrain_home", lambda: home_root)
149+
monkeypatch.setattr(init_module, "_acquire_init_lock", lambda: nullcontext())
150+
monkeypatch.setattr(init_module, "_ensure_dependencies", lambda: None)
151+
monkeypatch.setattr(init_module, "try_load_machine_config", lambda: (initial_config, None))
152+
monkeypatch.setattr(init_module, "save_machine_config", lambda config: None)
153+
monkeypatch.setattr(init_module, "_migrate_machine_config", lambda config: config)
154+
monkeypatch.setattr(init_module, "_ensure_managed_dependencies", lambda: None)
155+
monkeypatch.setattr(init_module, "_ensure_managed_container", lambda config: False)
156+
monkeypatch.setattr(init_module, "_wait_for_postgres", lambda admin_dsn: None)
157+
monkeypatch.setattr(init_module, "_reconcile_database", lambda config: (False, config))
158+
monkeypatch.setattr(
159+
"app.boot.migrations.upgrade_database",
160+
lambda: (_ for _ in ()).throw(
161+
DatabaseRevisionAheadOfInstalledPackageError(
162+
"Installed Shellbrain package (0.1.22) cannot manage database revision 20260415_0012."
163+
)
164+
),
165+
)
166+
monkeypatch.setattr(init_module, "_mark_repair_needed", lambda message: marked.append(message))
167+
168+
result = init_module.run_init(
169+
repo_root=repo_root,
170+
repo_id_override=None,
171+
register_repo_now=False,
172+
skip_model_download=False,
173+
skip_host_assets=True,
174+
)
175+
176+
assert result.outcome == init_module.INIT_OUTCOME_BLOCKED_CONFLICT
177+
assert result.lines == ["Installed Shellbrain package (0.1.22) cannot manage database revision 20260415_0012."]
178+
assert marked == ["Installed Shellbrain package (0.1.22) cannot manage database revision 20260415_0012."]
179+
180+
134181
def test_run_init_should_defer_repo_registration_outside_any_repo(tmp_path: Path, monkeypatch) -> None:
135182
"""plain init should succeed outside a repo and defer repo registration until first use."""
136183

0 commit comments

Comments
 (0)