Phase 7B: per-plugin Alembic chains for bundled plugins

Each of the six bundled plugins (computers, equipment, network,
notifications, printers, usb) now has its own Alembic chain with a
baseline migration. Sister sites adopting one of these plugins can
manage its schema via `flask plugin migrate <name>` instead of relying
on db.create_all to bootstrap everything.

Existing single-site deploys that bootstrap via db.create_all continue
to work unchanged. The chains coexist; the bootstrap path stays the
operator's choice.

Framework
- shopdb/plugins/alembic_template.py: shared env.py logic + helpers.
  PLUGIN_TABLE_OWNERS pins which tables belong to which plugin (explicit
  registry, not import-side-effect). _get_plugin_metadata filters
  db.metadata to only the named plugin's tables. create_plugin_tables /
  drop_plugin_tables emit DDL via SQLAlchemy CreateTable so the table
  definitions stay sourced from the models, not duplicated.
- shopdb/plugins/__init__.py: PluginManager.upgrade_all_plugins() runs
  pending migrations across every discovered plugin and returns a status
  dict. Idempotent (Alembic skips applied revisions).

CLI
- `flask plugin upgrade-all` runs pending migrations for every plugin.
  Used on a fresh deploy after the core schema is in place.

Per-plugin scaffolding
- plugins/{computers,equipment,network,notifications,printers,usb}/
  migrations/{alembic.ini, env.py, script.py.mako, versions/0001_baseline.py}
- Each env.py is a 5-line shim that sets PLUGIN_NAME and delegates to
  the shared template. Each 0001_baseline calls create_plugin_tables(name)
  / drop_plugin_tables(name); no duplication of column definitions.

Tests
- tests/test_plugin_migrations.py (18 cases): every bundled plugin has
  an entry in PLUGIN_TABLE_OWNERS, has the on-disk Alembic scaffolding,
  and the filtered MetaData contains every owned table (catches drift
  between the template's table list and what the models declare).
- 129 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-30 14:20:07 -04:00
parent 275928a03f
commit 689f1a21e2
28 changed files with 804 additions and 0 deletions

View File

@@ -71,6 +71,34 @@ class PluginManager:
for name, plugin in plugins.items():
self._register_plugin_components(plugin)
def upgrade_all_plugins(self) -> Dict[str, str]:
"""Run pending Alembic migrations for every loaded plugin.
Returns {plugin_name: 'ok'|'no-migrations'|<error str>}. Skips
plugins with no migrations/ directory. Use from the CLI
(`flask plugin upgrade-all`) on a fresh deploy after the core
schema is in place; existing deploys that still use db.create_all
can ignore this and continue to do so.
"""
results: Dict[str, str] = {}
if not self.migration_manager:
return results
plugin_names = list(self.registry.list_installed().keys()) \
if hasattr(self.registry, 'list_installed') else []
if not plugin_names:
# Fall back to whatever the loader found on disk.
plugin_names = self.loader.discover_plugins()
for name in plugin_names:
if not self.migration_manager.has_pending_migrations(name):
results[name] = 'no-migrations'
continue
try:
ok = self.migration_manager.run_plugin_migrations(name)
results[name] = 'ok' if ok else 'failed'
except Exception as ex:
results[name] = f'error: {ex}'
return results
def _register_plugin_components(self, plugin: BasePlugin) -> None:
"""Register plugin's blueprint, models, CLI commands, etc."""
# Register blueprint

View File

@@ -0,0 +1,146 @@
"""Shared Alembic env.py logic for bundled plugins.
Each bundled plugin (computers, equipment, network, notifications, printers,
usb) has a `migrations/env.py` that does the minimum:
import os
os.environ['PLUGIN_NAME'] = 'computers'
from shopdb.plugins.alembic_template import run_migrations
run_migrations()
This module wires the plugin's models into a MetaData object filtered to
only the tables that belong to that plugin, then runs Alembic in either
offline or online mode against the Flask app's configured engine.
Plugin tables must be importable via `plugins.<name>.models`. Plugins
register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the
filter is explicit (avoids depending on import-side-effect global state).
"""
from __future__ import annotations
import importlib
import logging
import os
from typing import Iterable
from alembic import context
from sqlalchemy import MetaData, pool
logger = logging.getLogger('alembic.env.plugin')
# Explicit table-ownership map. Adding tables to a plugin requires updating
# this dict so the per-plugin migration knows which tables to include.
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'computers': ('computertypes', 'computers', 'computerinstalledapps'),
'equipment': ('equipmenttypes', 'equipment'),
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
'notifications': ('notificationtypes', 'notifications'),
'printers': ('printertypes', 'printers', 'printerdata'),
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
}
def _get_plugin_metadata(plugin_name: str) -> MetaData:
"""Import the plugin's models and return a MetaData containing only its
declared tables (filtered via PLUGIN_TABLE_OWNERS)."""
owned = set(PLUGIN_TABLE_OWNERS.get(plugin_name, ()))
if not owned:
raise RuntimeError(
f"PLUGIN_TABLE_OWNERS has no entry for plugin '{plugin_name}'. "
f"Update shopdb/plugins/alembic_template.py."
)
# Importing models attaches them to the global db.metadata.
importlib.import_module(f'plugins.{plugin_name}.models')
from shopdb.extensions import db
full = db.metadata
plugin_md = MetaData()
for table in list(full.tables.values()):
if table.name in owned:
table.to_metadata(plugin_md)
return plugin_md
def create_plugin_tables(plugin_name: str):
"""Emit CreateTable DDL for every table this plugin owns. Idempotent
via Alembic's batch_op.create_table behavior (raises if exists; the
baseline migration is meant to run against an empty schema).
Called from each plugin's 0001_baseline.py upgrade() so the table
definitions stay sourced from the SQLAlchemy models rather than being
duplicated in handwritten Alembic ops.
"""
from alembic import op
from sqlalchemy.schema import CreateTable
md = _get_plugin_metadata(plugin_name)
bind = op.get_bind()
# Sort by FK dependency so parent tables are created first.
for table in md.sorted_tables:
op.execute(str(CreateTable(table).compile(dialect=bind.dialect)))
def drop_plugin_tables(plugin_name: str):
"""Mirror of create_plugin_tables for downgrade(). Drops in reverse FK
order."""
from alembic import op
md = _get_plugin_metadata(plugin_name)
for table in reversed(md.sorted_tables):
op.execute(f'DROP TABLE IF EXISTS "{table.name}"')
def run_migrations():
"""Entry point called by each plugin's migrations/env.py."""
plugin_name = os.environ.get('PLUGIN_NAME')
if not plugin_name:
raise RuntimeError("PLUGIN_NAME env var must be set before run_migrations()")
config = context.config
target_metadata = _get_plugin_metadata(plugin_name)
# Per-plugin version table so each plugin's chain is independent of core
# Alembic's alembic_version table.
version_table = f'alembic_version_{plugin_name}'
db_url = config.get_main_option('sqlalchemy.url')
if not db_url:
# Pull from the Flask app config if running inside an app context
# (e.g. via flask plugin migrate <name>).
try:
from flask import current_app
db_url = current_app.config['SQLALCHEMY_DATABASE_URI']
config.set_main_option('sqlalchemy.url', db_url.replace('%', '%%'))
except Exception as ex:
raise RuntimeError(
"sqlalchemy.url not set and no Flask app context available. "
f"Original error: {ex}"
)
if context.is_offline_mode():
context.configure(
url=db_url,
target_metadata=target_metadata,
literal_binds=True,
version_table=version_table,
include_schemas=False,
)
with context.begin_transaction():
context.run_migrations()
else:
from sqlalchemy import engine_from_config
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table=version_table,
include_schemas=False,
)
with context.begin_transaction():
context.run_migrations()

View File

@@ -241,3 +241,30 @@ def migrate_plugin(name: str, revision: str):
else:
click.echo(click.style("Migration failed", fg='red'))
raise SystemExit(1)
@plugin_cli.command('upgrade-all')
@with_appcontext
def upgrade_all_plugins():
"""Run pending migrations for every loaded plugin.
Idempotent. Use on a fresh deploy after core schema is in place,
instead of (or alongside) `db-utils create-all`. Existing deployments
that still bootstrap plugin tables via db.create_all can ignore this
command until ready to move a plugin onto its Alembic version chain.
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
results = pm.upgrade_all_plugins()
if not results:
click.echo("No plugins discovered.")
return
for name, status in sorted(results.items()):
if status == 'ok':
click.echo(click.style(f" {name:20} ok", fg='green'))
elif status == 'no-migrations':
click.echo(click.style(f" {name:20} no migrations", fg='yellow'))
else:
click.echo(click.style(f" {name:20} {status}", fg='red'))