fix(backup): restore finds plugin tables by path, as the app loads them (#5674)

Run 9075: the restore got through schema and data, then all_metadata's
`import plugins` failed in the image, where plugins/ is a directory the app
loads by path (PLUGIN_DIRS), not an installed package. It now loads each
plugin under the loader's own module name from the bootstrap's plugin
dirs, which `steward restore` passes. A plugin whose models are already
mapped under either name is skipped rather than mapped twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 07:23:58 -04:00
co-authored by Claude Opus 5.5
parent fe51451704
commit 357bb6480d
2 changed files with 24 additions and 17 deletions
+22 -16
View File
@@ -36,26 +36,32 @@ class Restored:
fixed: int # rows put right between data and constraints
def all_metadata() -> MetaData:
"""Steward's tables, every bundled plugin's included, without an app:
the restore runs before anything else would load them."""
def all_metadata(plugin_dirs: list[Path] | None = None) -> MetaData:
"""Steward's tables, every plugin's included, without an app: the
restore runs before anything else would load them. Plugins are found in
`plugin_dirs` (the bootstrap's, bundled first) and loaded by path under
the same module names the app gives them, so a plugin the app already
loaded isn't mapped twice. Without dirs: the repository's plugins/."""
import importlib
import pkgutil
import sys
import plugins
import steward
import steward.models # noqa: F401 — registers core tables
from steward.core.plugin_manager import _import_plugin, resolve_plugin_path
from steward.models.base import Base
for info in pkgutil.iter_modules(plugins.__path__):
# A plugin the app already loaded is under the loader's name; its
# models imported a second time would map every table twice.
loaded = f"_steward_plugin_{info.name}"
package = loaded if loaded in sys.modules else f"plugins.{info.name}"
roots = [Path(d).resolve() for d in (plugin_dirs or [Path(steward.__file__).parent.parent / "plugins"])]
names = sorted({p.name for root in roots if root.is_dir() for p in root.iterdir()
if (p / "__init__.py").exists() and (p / "models.py").exists()})
for name in names:
if f"plugins.{name}.models" in sys.modules or f"_steward_plugin_{name}.models" in sys.modules:
continue # already mapped, under one name or the other
path = resolve_plugin_path(roots, name)
try:
importlib.import_module(f"{package}.models")
except ImportError: # no models, or an optional dependency missing
continue
package = _import_plugin(name, path)
importlib.import_module(f"{package.__name__}.models")
except Exception: # a plugin that won't load: its tables are left as pg_restore put them
logger.warning("Restore: couldn't load plugin %r's tables", name, exc_info=True)
return Base.metadata
@@ -167,7 +173,7 @@ async def _execute(database_url: str, statements: list[str]) -> int:
async def restore(path: Path, database_url: str, *, wipe: bool = False,
metadata: MetaData | None = None) -> Restored:
metadata: MetaData | None = None, plugin_dirs: list[Path] | None = None) -> Restored:
"""Restore a backup file into the database at `database_url`. It must be
empty unless `wipe`, which drops everything in its public schema first."""
existing = await _tables(database_url)
@@ -182,6 +188,6 @@ async def restore(path: Path, database_url: str, *, wipe: bool = False,
info, dump, key = await asyncio.to_thread(read_backup, path, Path(tmp))
await _pg_restore(database_url, dump, "pre-data")
await _pg_restore(database_url, dump, "data")
fixed = await _execute(database_url, dangling_fixes(metadata or all_metadata()))
fixed = await _execute(database_url, dangling_fixes(metadata or all_metadata(plugin_dirs)))
await _pg_restore(database_url, dump, "post-data")
return Restored(info, key, fixed)
+2 -1
View File
@@ -96,7 +96,8 @@ def restore_command(ctx: click.Context, backup_file: Path, wipe: bool) -> None:
boot = load_bootstrap(Path(ctx.obj["config"]))
try:
done = asyncio.run(restore(backup_file, boot["database_url"], wipe=wipe))
done = asyncio.run(restore(backup_file, boot["database_url"], wipe=wipe,
plugin_dirs=[Path(d) for d in boot["plugin_dirs"]]))
except BackupError as exc:
raise click.ClickException(str(exc)) from None
made = done.manifest.get("created_at", "?")