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:
+22
-16
@@ -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
@@ -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", "?")
|
||||
|
||||
Reference in New Issue
Block a user