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
|
fixed: int # rows put right between data and constraints
|
||||||
|
|
||||||
|
|
||||||
def all_metadata() -> MetaData:
|
def all_metadata(plugin_dirs: list[Path] | None = None) -> MetaData:
|
||||||
"""Steward's tables, every bundled plugin's included, without an app:
|
"""Steward's tables, every plugin's included, without an app: the
|
||||||
the restore runs before anything else would load them."""
|
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 importlib
|
||||||
import pkgutil
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import plugins
|
import steward
|
||||||
import steward.models # noqa: F401 — registers core tables
|
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
|
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
|
roots = [Path(d).resolve() for d in (plugin_dirs or [Path(steward.__file__).parent.parent / "plugins"])]
|
||||||
# models imported a second time would map every table twice.
|
names = sorted({p.name for root in roots if root.is_dir() for p in root.iterdir()
|
||||||
loaded = f"_steward_plugin_{info.name}"
|
if (p / "__init__.py").exists() and (p / "models.py").exists()})
|
||||||
package = loaded if loaded in sys.modules else f"plugins.{info.name}"
|
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:
|
try:
|
||||||
importlib.import_module(f"{package}.models")
|
package = _import_plugin(name, path)
|
||||||
except ImportError: # no models, or an optional dependency missing
|
importlib.import_module(f"{package.__name__}.models")
|
||||||
continue
|
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
|
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,
|
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
|
"""Restore a backup file into the database at `database_url`. It must be
|
||||||
empty unless `wipe`, which drops everything in its public schema first."""
|
empty unless `wipe`, which drops everything in its public schema first."""
|
||||||
existing = await _tables(database_url)
|
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))
|
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, "pre-data")
|
||||||
await _pg_restore(database_url, dump, "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")
|
await _pg_restore(database_url, dump, "post-data")
|
||||||
return Restored(info, key, fixed)
|
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"]))
|
boot = load_bootstrap(Path(ctx.obj["config"]))
|
||||||
try:
|
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:
|
except BackupError as exc:
|
||||||
raise click.ClickException(str(exc)) from None
|
raise click.ClickException(str(exc)) from None
|
||||||
made = done.manifest.get("created_at", "?")
|
made = done.manifest.get("created_at", "?")
|
||||||
|
|||||||
Reference in New Issue
Block a user