From c5fff4ce8366b08669c0c8b6d30b99b94b9fefca Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 12 Sep 2026 16:15:16 +0200 Subject: fix(packaging): the hub unit can start, and nothing carries the migration path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExecStartPre` ran `alembic -c /opt/meshbay-hub/migrations/alembic.ini upgrade head`. The build does stage that file, so the path existed and the contents were wrong: `alembic.ini` resolves `script_location` with `%(here)s`, so the copy pointed at `/opt/meshbay-hub/migrations/src/meshbay_hub/db/migrations` — which nothing installs, because the migrations ship inside `meshbay_hub`, in the shared venv. `ExecStartPre` failing stops the unit. A hub installed from the RPM or the DEB could not start at all, and nothing noticed because the one live deployment was assembled by hand — the same shape as the node unit that carried `User=` into the user unit directory. The same `%(here)s` trap was already found once on the server, where a stray `alembic.ini` resolved to a month-old snapshot of the tree. Twice is a trap rather than an accident, so the fix is that the path is no longer written down anywhere: `meshbay-hub migrate` asks the installed package where its own migrations are, which is correct for the RPM, the DEB, a venv and a checkout. The build stages no `alembic.ini`; the repo keeps its own for `alembic revision` and for deploy scripts that already work. `env.py` now prefers a URL the caller resolved over re-reading the environment itself, so `migrate --config` connects with exactly the string the server will — one resolution, not two that agree until they do not. Six tests, three of which fail against the unit as it was. They read the directives rather than the file, because searching the whole thing finds the comment explaining a directive and calls that the directive. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT --- packages/meshbay-hub/src/meshbay_hub/daemon.py | 48 ++++++++++++++++++++++ .../src/meshbay_hub/db/migrations/env.py | 24 ++++++++--- 2 files changed, 67 insertions(+), 5 deletions(-) (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/daemon.py b/packages/meshbay-hub/src/meshbay_hub/daemon.py index 024ca78..9609de6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/daemon.py +++ b/packages/meshbay-hub/src/meshbay_hub/daemon.py @@ -35,6 +35,12 @@ def main() -> None: prune.add_argument("--log-level", default=argparse.SUPPRESS, choices=["DEBUG", "INFO", "WARNING", "ERROR"]) + migrate = sub.add_parser( + "migrate", help="bring the database schema up to date") + migrate.add_argument("--config", type=Path, default=argparse.SUPPRESS) + migrate.add_argument("--log-level", default=argparse.SUPPRESS, + choices=["DEBUG", "INFO", "WARNING", "ERROR"]) + args = parser.parse_args() logging.basicConfig( @@ -47,6 +53,9 @@ def main() -> None: if args.command == "prune-groups": sys.exit(asyncio.run(_prune_groups(cfg, args.days, args.dry_run))) + if args.command == "migrate": + sys.exit(_migrate(cfg)) + single_worker_or_exit(cfg.server.workers) uvicorn.run( @@ -59,6 +68,45 @@ def main() -> None: ) +def migrations_dir() -> Path: + """Where this installation's migrations actually are. + + Derived from the package rather than written down, because the one place a + path like this can be correct is next to the code it describes. The RPM + installs `meshbay_hub` into a shared venv and copies `alembic.ini` to + `/opt/meshbay-hub/migrations/`, where `%(here)s/src/meshbay_hub/db/…` + resolves to a directory that does not exist — so the packaged unit's + `ExecStartPre` could never have succeeded, and a hub installed from the + package would not start at all. The same `%(here)s` trap had already been + found once on the server, with a copy of alembic.ini pointing at a + month-old snapshot of the tree. + """ + return Path(__file__).resolve().parent / "db" / "migrations" + + +def _migrate(cfg) -> int: + """`alembic upgrade head`, with the paths resolved from the installation. + + A command rather than a path in a unit file: it is correct for the RPM, + the DEB, a venv, and a checkout, and there is nothing to keep in step. + """ + from alembic import command + from alembic.config import Config + + scripts = migrations_dir() + if not (scripts / "versions").is_dir(): + print(f"meshbay-hub: no migrations at {scripts}", file=sys.stderr) + return 1 + + alembic_cfg = Config() + alembic_cfg.set_main_option("script_location", str(scripts)) + # `env.py` reads the URL from the environment the same way the server does, + # so the two cannot drift; this is set for the case where it does not. + alembic_cfg.set_main_option("sqlalchemy.url", cfg.db.url) + command.upgrade(alembic_cfg, "head") + return 0 + + def single_worker_or_exit(workers: int) -> None: """Refuse to start with more than one worker. diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/env.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/env.py index f572716..8908deb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/migrations/env.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/env.py @@ -19,11 +19,25 @@ if config.config_file_name is not None: target_metadata = Base.metadata -# Allow override via env var (production uses asyncpg, tests may use aiosqlite) -db_url = os.environ.get( - "MESHBAY_DATABASE_URL", - "postgresql+asyncpg://meshbay:meshbay@localhost/meshbay_hub", -) +# Three sources, in the order that keeps them from disagreeing. +# +# A caller that already resolved the URL wins: `meshbay-hub migrate` passes +# `cfg.db.url`, which is `load_config`'s answer — the very string the server +# will connect with. Reading the environment again here instead would be a +# second resolution of the same question, and the two would drift the day +# anything but the environment decides it. +# +# Then the environment, for `alembic` run by hand or from a deploy script, +# where `alembic.ini` carries only its placeholder. Then a local default, so +# a developer's checkout needs no setup. +_PLACEHOLDER = "driver://user:pass@localhost/dbname" + +db_url = config.get_main_option("sqlalchemy.url", "") +if not db_url or db_url == _PLACEHOLDER: + db_url = os.environ.get( + "MESHBAY_DATABASE_URL", + "postgresql+asyncpg://meshbay:meshbay@localhost/meshbay_hub", + ) config.set_main_option("sqlalchemy.url", db_url) -- cgit v1.2.3