Skip to content

Commit 8c5c35e

Browse files
feat(server): bundle migrations in wheel and add agent-control-migrate (#209)
## Summary Ship Alembic migration scripts and `alembic.ini` inside the installed wheel via hatch `force-include`, and expose a small `agent-control-migrate` console script that runs `alembic upgrade head` against the bundled config. Lets non-Docker consumers (e.g. wheel-based deployments) run migrations from a vanilla install without vendoring migration files.
1 parent 7da7bf1 commit 8c5c35e

3 files changed

Lines changed: 352 additions & 0 deletions

File tree

server/pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ dev = [
5151

5252
[project.scripts]
5353
agent-control-server = "agent_control_server.main:run"
54+
agent-control-migrate = "agent_control_server.migrate:main"
5455

5556

5657

@@ -69,6 +70,13 @@ packages = [
6970
"src/agent_control_telemetry",
7071
]
7172

73+
# Ship Alembic migration scripts and config inside the wheel so that
74+
# non-Docker consumers (e.g. wheel-based deployments) can run migrations
75+
# via the `agent-control-migrate` console script without vendoring.
76+
[tool.hatch.build.targets.wheel.force-include]
77+
"alembic" = "agent_control_server/_alembic"
78+
"alembic.ini" = "agent_control_server/_alembic.ini"
79+
7280
[tool.pytest.ini_options]
7381
asyncio_mode = "auto"
7482
asyncio_default_fixture_loop_scope = "function"
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Run bundled Alembic migrations for agent-control-server.
2+
3+
Exposed as the ``agent-control-migrate`` console script. The wheel ships
4+
its Alembic config and migration scripts under the package so this
5+
command works in any install location (Docker, venv, system Python).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import logging
12+
import sys
13+
from pathlib import Path
14+
15+
from alembic import command
16+
from alembic.config import Config
17+
18+
import agent_control_server
19+
20+
21+
def _bundled_config() -> Config:
22+
pkg_dir = Path(agent_control_server.__file__).parent
23+
ini_path = pkg_dir / "_alembic.ini"
24+
alembic_dir = pkg_dir / "_alembic"
25+
if not ini_path.exists() or not alembic_dir.exists():
26+
raise RuntimeError(
27+
"Bundled Alembic resources not found. Expected "
28+
f"{ini_path} and {alembic_dir}. The installed wheel is missing "
29+
"migration assets."
30+
)
31+
cfg = Config(str(ini_path))
32+
cfg.set_main_option("script_location", str(alembic_dir).replace("%", "%%"))
33+
return cfg
34+
35+
36+
def _build_parser() -> argparse.ArgumentParser:
37+
parser = argparse.ArgumentParser(
38+
prog="agent-control-migrate",
39+
description="Run bundled Alembic migrations for agent-control-server.",
40+
)
41+
subparsers = parser.add_subparsers(dest="command")
42+
43+
upgrade = subparsers.add_parser("upgrade", help="Upgrade to a revision.")
44+
upgrade.add_argument("revision", nargs="?", default="head")
45+
upgrade.add_argument("--sql", action="store_true", help="Emit SQL instead of executing.")
46+
47+
downgrade = subparsers.add_parser("downgrade", help="Downgrade to a revision.")
48+
downgrade.add_argument("revision")
49+
downgrade.add_argument("--sql", action="store_true", help="Emit SQL instead of executing.")
50+
51+
subparsers.add_parser("current", help="Show the current revision.")
52+
subparsers.add_parser("history", help="List migration history.")
53+
subparsers.add_parser("heads", help="Show current available heads.")
54+
return parser
55+
56+
57+
def _configure_logging() -> None:
58+
logging.basicConfig(
59+
level=logging.INFO,
60+
format="%(levelname)s [%(name)s] %(message)s",
61+
stream=sys.stderr,
62+
)
63+
64+
65+
def main(argv: list[str] | None = None) -> int:
66+
"""Entry point for the ``agent-control-migrate`` console script.
67+
68+
With no arguments, runs ``upgrade head``. Supports a small subset of
69+
Alembic commands sufficient for deploys and operational debugging:
70+
``upgrade``, ``downgrade``, ``current``, ``history``, ``heads``.
71+
"""
72+
args = list(argv) if argv is not None else sys.argv[1:]
73+
if not args:
74+
args = ["upgrade", "head"]
75+
76+
parser = _build_parser()
77+
parsed = parser.parse_args(args)
78+
_configure_logging()
79+
80+
try:
81+
cfg = _bundled_config()
82+
if parsed.command == "upgrade":
83+
command.upgrade(cfg, parsed.revision, sql=parsed.sql)
84+
elif parsed.command == "downgrade":
85+
command.downgrade(cfg, parsed.revision, sql=parsed.sql)
86+
elif parsed.command == "current":
87+
command.current(cfg)
88+
elif parsed.command == "history":
89+
command.history(cfg)
90+
elif parsed.command == "heads":
91+
command.heads(cfg)
92+
else: # pragma: no cover - argparse guarantees this cannot happen.
93+
parser.error("missing command")
94+
except Exception as exc:
95+
print(f"agent-control-migrate: {exc}", file=sys.stderr)
96+
return 1
97+
98+
return 0
99+
100+
101+
if __name__ == "__main__":
102+
raise SystemExit(main())

server/unit_tests/test_migrate.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""Unit tests for the bundled-migrations entry point.
2+
3+
These do not run migrations against a database. They verify the wheel-bundling
4+
contract: the console script resolves to the right callable, dispatches
5+
correctly to Alembic commands, and the bundled-config helper can load the
6+
packaged migration layout.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import tomllib
12+
from pathlib import Path, PurePosixPath
13+
from unittest.mock import MagicMock
14+
15+
import pytest
16+
from alembic.script import ScriptDirectory
17+
18+
from agent_control_server import migrate
19+
20+
21+
@pytest.fixture
22+
def stub_config(monkeypatch: pytest.MonkeyPatch) -> object:
23+
"""Replace bundled-config building with a sentinel object.
24+
25+
Lets dispatch tests verify which Alembic command was called and
26+
what config was passed without needing real migration assets.
27+
"""
28+
sentinel = object()
29+
monkeypatch.setattr(migrate, "_bundled_config", lambda: sentinel)
30+
return sentinel
31+
32+
33+
def _patch_command(monkeypatch: pytest.MonkeyPatch, name: str) -> MagicMock:
34+
mock = MagicMock()
35+
monkeypatch.setattr(migrate.command, name, mock)
36+
return mock
37+
38+
39+
def test_main_default_runs_upgrade_head(
40+
stub_config: object, monkeypatch: pytest.MonkeyPatch
41+
) -> None:
42+
upgrade = _patch_command(monkeypatch, "upgrade")
43+
rc = migrate.main([])
44+
assert rc == 0
45+
upgrade.assert_called_once_with(stub_config, "head", sql=False)
46+
47+
48+
def test_main_bare_upgrade_runs_upgrade_head(
49+
stub_config: object, monkeypatch: pytest.MonkeyPatch
50+
) -> None:
51+
upgrade = _patch_command(monkeypatch, "upgrade")
52+
rc = migrate.main(["upgrade"])
53+
assert rc == 0
54+
upgrade.assert_called_once_with(stub_config, "head", sql=False)
55+
56+
57+
def test_main_explicit_upgrade_revision(
58+
stub_config: object, monkeypatch: pytest.MonkeyPatch
59+
) -> None:
60+
upgrade = _patch_command(monkeypatch, "upgrade")
61+
rc = migrate.main(["upgrade", "abc123"])
62+
assert rc == 0
63+
upgrade.assert_called_once_with(stub_config, "abc123", sql=False)
64+
65+
66+
def test_main_upgrade_supports_sql(
67+
stub_config: object, monkeypatch: pytest.MonkeyPatch
68+
) -> None:
69+
upgrade = _patch_command(monkeypatch, "upgrade")
70+
rc = migrate.main(["upgrade", "head", "--sql"])
71+
assert rc == 0
72+
upgrade.assert_called_once_with(stub_config, "head", sql=True)
73+
74+
75+
def test_main_bare_downgrade_requires_explicit_revision(
76+
monkeypatch: pytest.MonkeyPatch,
77+
) -> None:
78+
monkeypatch.setattr(migrate, "_bundled_config", pytest.fail)
79+
with pytest.raises(SystemExit) as exc_info:
80+
migrate.main(["downgrade"])
81+
assert exc_info.value.code == 2
82+
83+
84+
def test_main_explicit_downgrade_revision(
85+
stub_config: object, monkeypatch: pytest.MonkeyPatch
86+
) -> None:
87+
downgrade = _patch_command(monkeypatch, "downgrade")
88+
rc = migrate.main(["downgrade", "abc123"])
89+
assert rc == 0
90+
downgrade.assert_called_once_with(stub_config, "abc123", sql=False)
91+
92+
93+
def test_main_downgrade_supports_sql(
94+
stub_config: object, monkeypatch: pytest.MonkeyPatch
95+
) -> None:
96+
downgrade = _patch_command(monkeypatch, "downgrade")
97+
rc = migrate.main(["downgrade", "-1", "--sql"])
98+
assert rc == 0
99+
downgrade.assert_called_once_with(stub_config, "-1", sql=True)
100+
101+
102+
@pytest.mark.parametrize("op", ["current", "history", "heads"])
103+
def test_main_query_commands(
104+
stub_config: object, monkeypatch: pytest.MonkeyPatch, op: str
105+
) -> None:
106+
cmd = _patch_command(monkeypatch, op)
107+
rc = migrate.main([op])
108+
assert rc == 0
109+
cmd.assert_called_once_with(stub_config)
110+
111+
112+
def test_main_unknown_command_returns_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
113+
monkeypatch.setattr(migrate, "_bundled_config", pytest.fail)
114+
with pytest.raises(SystemExit) as exc_info:
115+
migrate.main(["does-not-exist"])
116+
assert exc_info.value.code == 2
117+
118+
119+
def test_main_unknown_command_prints_usage(
120+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
121+
) -> None:
122+
monkeypatch.setattr(migrate, "_bundled_config", pytest.fail)
123+
with pytest.raises(SystemExit):
124+
migrate.main(["does-not-exist"])
125+
out = capsys.readouterr()
126+
assert "invalid choice: 'does-not-exist'" in out.err
127+
assert "usage:" in out.err
128+
129+
130+
def test_main_help_prints_usage(
131+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
132+
) -> None:
133+
monkeypatch.setattr(migrate, "_bundled_config", pytest.fail)
134+
with pytest.raises(SystemExit) as exc_info:
135+
migrate.main(["--help"])
136+
assert exc_info.value.code == 0
137+
out = capsys.readouterr()
138+
assert "usage:" in out.out
139+
assert "Run bundled Alembic migrations" in out.out
140+
141+
142+
def test_main_rejects_extra_positional_args(monkeypatch: pytest.MonkeyPatch) -> None:
143+
monkeypatch.setattr(migrate, "_bundled_config", pytest.fail)
144+
with pytest.raises(SystemExit) as exc_info:
145+
migrate.main(["upgrade", "head", "typo"])
146+
assert exc_info.value.code == 2
147+
148+
149+
def test_main_returns_nonzero_for_command_errors(
150+
stub_config: object, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
151+
) -> None:
152+
upgrade = _patch_command(monkeypatch, "upgrade")
153+
upgrade.side_effect = RuntimeError("database unavailable")
154+
rc = migrate.main(["upgrade", "head"])
155+
assert rc == 1
156+
out = capsys.readouterr()
157+
assert "agent-control-migrate: database unavailable" in out.err
158+
159+
160+
def test_bundled_config_raises_when_assets_missing(
161+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
162+
) -> None:
163+
# Point the bundled-config lookup at a directory with no migration assets.
164+
fake_pkg_init = tmp_path / "__init__.py"
165+
fake_pkg_init.write_text("")
166+
monkeypatch.setattr(migrate.agent_control_server, "__file__", str(fake_pkg_init))
167+
168+
with pytest.raises(RuntimeError, match="Bundled Alembic resources not found"):
169+
migrate._bundled_config()
170+
171+
172+
def test_bundled_config_loads_real_bundled_layout(
173+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
174+
) -> None:
175+
pkg_dir = tmp_path / "agent_control_server"
176+
versions_dir = pkg_dir / "_alembic" / "versions"
177+
versions_dir.mkdir(parents=True)
178+
(pkg_dir / "__init__.py").write_text("")
179+
(pkg_dir / "_alembic.ini").write_text("[alembic]\nscript_location = unused\n")
180+
(pkg_dir / "_alembic" / "env.py").write_text("")
181+
(pkg_dir / "_alembic" / "script.py.mako").write_text("")
182+
(versions_dir / "abc123_initial.py").write_text(
183+
'"""Initial revision."""\n'
184+
'revision = "abc123"\n'
185+
"down_revision = None\n"
186+
"branch_labels = None\n"
187+
"depends_on = None\n"
188+
)
189+
monkeypatch.setattr(
190+
migrate.agent_control_server,
191+
"__file__",
192+
str(pkg_dir / "__init__.py"),
193+
)
194+
195+
cfg = migrate._bundled_config()
196+
script_dir = ScriptDirectory.from_config(cfg)
197+
198+
assert script_dir.get_heads() == ["abc123"]
199+
200+
201+
def test_bundled_config_escapes_percent_paths(
202+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
203+
) -> None:
204+
pkg_dir = tmp_path / "agent%control_server"
205+
(pkg_dir / "_alembic").mkdir(parents=True)
206+
(pkg_dir / "__init__.py").write_text("")
207+
(pkg_dir / "_alembic.ini").write_text("[alembic]\nscript_location = unused\n")
208+
monkeypatch.setattr(
209+
migrate.agent_control_server,
210+
"__file__",
211+
str(pkg_dir / "__init__.py"),
212+
)
213+
214+
cfg = migrate._bundled_config()
215+
216+
assert cfg.get_main_option("script_location") == str(pkg_dir / "_alembic")
217+
218+
219+
def test_force_include_source_paths_exist() -> None:
220+
"""Hatch force-include mappings must ship real migration assets under the package."""
221+
server_dir = Path(__file__).resolve().parent.parent
222+
with (server_dir / "pyproject.toml").open("rb") as pyproject:
223+
config = tomllib.load(pyproject)
224+
225+
scripts = config["project"]["scripts"]
226+
assert scripts["agent-control-migrate"] == "agent_control_server.migrate:main"
227+
228+
wheel_config = config["tool"]["hatch"]["build"]["targets"]["wheel"]
229+
force_include = wheel_config["force-include"]
230+
assert force_include
231+
232+
for source, target in force_include.items():
233+
source_path = server_dir / source
234+
assert source_path.exists(), f"missing force-include source: {source_path}"
235+
236+
target_path = PurePosixPath(target)
237+
assert target_path.parts[0] == "agent_control_server"
238+
239+
alembic_target = force_include["alembic"]
240+
versions = list((server_dir / "alembic" / "versions").glob("*.py"))
241+
assert alembic_target == "agent_control_server/_alembic"
242+
assert versions, f"no migration scripts under {server_dir / 'alembic' / 'versions'}"

0 commit comments

Comments
 (0)