fix(cli): exempt backend command from DB migration gate
test / integration (pull_request) Successful in 8s
tracker-policy-pr / check-pr (pull_request) Successful in 9s
test / unit (pull_request) Successful in 31s
test / coverage (pull_request) Successful in 36s
test / integration (push) Successful in 12s
test / unit (push) Successful in 36s
test / coverage (push) Successful in 39s
Update Quality Badges / update-badges (push) Successful in 37s
lint / lint (push) Successful in 2m38s
test / integration (pull_request) Successful in 8s
tracker-policy-pr / check-pr (pull_request) Successful in 9s
test / unit (pull_request) Successful in 31s
test / coverage (pull_request) Successful in 36s
test / integration (push) Successful in 12s
test / unit (push) Successful in 36s
test / coverage (push) Successful in 39s
Update Quality Badges / update-badges (push) Successful in 37s
lint / lint (push) Successful in 2m38s
`backend setup/status/teardown` manage host prerequisites only and never open the store, but the CLI dispatcher ran the schema-migration gate before every command. On a non-TTY runner the gate's `Migrate now? [y/N]` prompt reads EOF and refuses, so `backend status --backend=firecracker` exits 1 — breaking the Firecracker CI preflight on any host without a pre-migrated DB. Exempt `backend` from the gate; store-touching commands stay gated. Fixes #419 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1qRZTJC6qgBsUSjNrBdkX
This commit was merged in pull request #420.
This commit is contained in:
@@ -38,6 +38,13 @@ COMMANDS = {
|
|||||||
"supervise": cmd_supervise,
|
"supervise": cmd_supervise,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Commands that manage host prerequisites (or are otherwise store-free) and
|
||||||
|
# must run before — or without — a migrated DB. `backend` provisions/probes
|
||||||
|
# the host (TAP pool, /dev/kvm, firecracker) and never opens the store, so
|
||||||
|
# gating it on the schema breaks preflight on a fresh CI runner where stdin
|
||||||
|
# isn't a TTY and the migration prompt can't be answered.
|
||||||
|
NO_MIGRATION_COMMANDS = frozenset({"backend"})
|
||||||
|
|
||||||
|
|
||||||
def usage() -> None:
|
def usage() -> None:
|
||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
@@ -80,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
usage()
|
usage()
|
||||||
die(f"unknown command: {command}")
|
die(f"unknown command: {command}")
|
||||||
mgr = StoreManager.instance()
|
mgr = StoreManager.instance()
|
||||||
if not mgr.is_migrated():
|
if command not in NO_MIGRATION_COMMANDS and not mgr.is_migrated():
|
||||||
sys.stderr.write("bot-bottle: database schema is out of date\n")
|
sys.stderr.write("bot-bottle: database schema is out of date\n")
|
||||||
sys.stderr.write("Migrate now? [y/N] ")
|
sys.stderr.write("Migrate now? [y/N] ")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
|
|||||||
@@ -95,5 +95,60 @@ class TestMainDispatch(unittest.TestCase):
|
|||||||
self.assertEqual(130, main(["x"]))
|
self.assertEqual(130, main(["x"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigrationGate(unittest.TestCase):
|
||||||
|
"""The dispatcher's schema-migration gate (cli/__init__.py)."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
# Force the "schema out of date" branch for every test here.
|
||||||
|
patcher = patch.object(StoreManager, "is_migrated", return_value=False)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
self.addCleanup(StoreManager.reset)
|
||||||
|
|
||||||
|
def test_store_command_blocks_when_stdin_cannot_confirm(self) -> None:
|
||||||
|
# Non-TTY stdin at EOF (as in CI): the [y/N] prompt reads "" and the
|
||||||
|
# command is refused rather than migrating silently.
|
||||||
|
ran: list[bool] = []
|
||||||
|
|
||||||
|
def handler(_rest: list[str]) -> int:
|
||||||
|
ran.append(True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with patch.dict(climod.COMMANDS, {"list": handler}), \
|
||||||
|
patch("sys.stdin", io.StringIO("")), \
|
||||||
|
patch("sys.stderr", io.StringIO()):
|
||||||
|
self.assertEqual(1, main(["list"]))
|
||||||
|
self.assertEqual([], ran, "gated command must not dispatch")
|
||||||
|
|
||||||
|
def test_backend_command_skips_gate(self) -> None:
|
||||||
|
# `backend` provisions/probes the host and never opens the store, so
|
||||||
|
# it must run even on an unmigrated DB with unanswerable stdin.
|
||||||
|
ran: list[bool] = []
|
||||||
|
|
||||||
|
def handler(_rest: list[str]) -> int:
|
||||||
|
ran.append(True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with patch.dict(climod.COMMANDS, {"backend": handler}), \
|
||||||
|
patch("sys.stdin", io.StringIO("")), \
|
||||||
|
patch("sys.stderr", io.StringIO()):
|
||||||
|
self.assertEqual(0, main(["backend", "status"]))
|
||||||
|
self.assertEqual([True], ran, "exempt command must dispatch")
|
||||||
|
|
||||||
|
def test_store_command_migrates_on_confirmation(self) -> None:
|
||||||
|
migrated: list[bool] = []
|
||||||
|
|
||||||
|
def handler(_rest: list[str]) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with patch.dict(climod.COMMANDS, {"list": handler}), \
|
||||||
|
patch.object(StoreManager, "migrate",
|
||||||
|
side_effect=lambda: migrated.append(True)), \
|
||||||
|
patch("sys.stdin", io.StringIO("y\n")), \
|
||||||
|
patch("sys.stderr", io.StringIO()):
|
||||||
|
self.assertEqual(0, main(["list"]))
|
||||||
|
self.assertEqual([True], migrated, "confirmed gate must migrate")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user