ADR-013 Phase 0: plugin lifecycle groundwork

Additive, zero-risk-to-running-sites prep for the plugin catalog. No
distribution or lean-build behavior yet; fixes latent bugs and adds the
declarative + validate tooling later phases build on.

Fixes:
- upgrade_all_plugins iterates registry.get_all(); only adopted plugins are
  migrated. Removes the phantom hasattr(registry, 'list_installed') probe
  that always fell through to migrating every folder on disk (unadopted DDL
  ran with full DB rights on every deploy).
- Reverse-dependency checks on uninstall/disable read dependencies from the
  manifest on disk via _installed_dependents, so an installed-but-unloaded or
  disabled dependent is counted. Uninstall blocks on any installed dependent;
  disable blocks on an enabled dependent.
- _sort_by_dependencies detects a dependency cycle (back edge in the DFS) and
  raises PluginDependencyError instead of looping or dropping a plugin.

New:
- flask plugin validate <name>: manifest loads + name match, manifest-schema
  check, core_version admits the framework contract, declared dependencies
  exist on disk. No new dependency (lightweight checker); schema ships in the
  package at shopdb/plugins/manifest_schema.json (docs/ is stripped on
  publish). The check caught that provides is an object, not an array.
- flask plugin apply-profile <file>: declarative install AND enable of a
  chosen plugin set plus its hard-dependency closure, in dependency order,
  idempotent. Replaces the hand-ordered runbook sequences that could enable a
  plugin that was never installed. deploy/site-profile.example.json template.
- Dockerfile header corrected (all 13 catalog plugins, not "eleven core").

10 new lifecycle tests (reverse-deps from disk, cycle detection, upgrade-all
scope, profile closure, schema, all 13 manifests match schema). 1018 pass,
naming green.
This commit is contained in:
cproudlock
2026-07-18 18:08:34 -04:00
parent 3ac5ed2580
commit d178726687
7 changed files with 533 additions and 39 deletions

View File

@@ -149,6 +149,143 @@ def test_enable_seeds_plugin_permissions(manager, app, db):
assert Permission.query.filter_by(name='alpha.view').count() == 1
# --- ADR-013 Phase 0: reverse-deps from disk, cycles, upgrade-all, profiles --
def test_disable_blocked_by_unloaded_dependent(manager):
"""alpha cannot be disabled while an enabled dependent exists, even when
that dependent was never loaded as an instance (manifest read from disk)."""
# Mark both enabled directly in the registry WITHOUT loading beta.
manager.registry.enable('alpha')
manager.registry.enable('beta')
assert manager.loader.get_loaded_plugin('beta') is None
# The old loaded-instance-only check would wrongly allow this.
assert manager.disable_plugin('alpha') is False
assert manager.registry.is_enabled('alpha')
def test_uninstall_blocked_by_disabled_dependent(manager):
"""An installed-but-DISABLED dependent still blocks uninstalling its
dependency (uninstall reads all installed manifests, not enabled instances)."""
assert manager.registry.is_installed('beta')
assert not manager.registry.is_enabled('beta')
assert manager.uninstall_plugin('alpha') is False
assert manager.registry.is_installed('alpha')
def test_dependency_cycle_detected(tmp_path):
"""A -> B -> A raises instead of looping or silently dropping a plugin."""
from shopdb.exceptions import PluginDependencyError
plugins_dir = tmp_path / 'plugins'
plugins_dir.mkdir()
_write_plugin(plugins_dir, 'aa', deps=['bb'])
_write_plugin(plugins_dir, 'bb', deps=['aa'])
loader = PluginLoader(plugins_dir, PluginRegistry(tmp_path / 'plugins.json'))
with pytest.raises(PluginDependencyError):
loader._sort_by_dependencies(['aa', 'bb'])
def test_upgrade_all_only_touches_registered(manager, tmp_path):
"""upgrade-all migrates only ADOPTED plugins; an unregistered folder on
disk is never migrated as a side effect."""
_write_plugin(tmp_path / 'plugins', 'gamma') # on disk, not registered
class StubMigrationManager:
def has_pending_migrations(self, name):
return False
manager.migration_manager = StubMigrationManager()
results = manager.upgrade_all_plugins()
assert set(results) == {'alpha', 'beta'}
assert 'gamma' not in results
def test_apply_profile_enables_dependency_closure(manager):
"""Naming only the dependent enables it AND its dependency, in order."""
result = manager.apply_profile(['beta'])
assert manager.registry.is_enabled('alpha')
assert manager.registry.is_enabled('beta')
assert 'alpha' in result['enabled']
assert 'beta' in result['enabled']
def test_apply_profile_installs_and_enables_fresh_plugin(manager, tmp_path):
"""A plugin on disk but not yet registered is installed AND enabled."""
_write_plugin(tmp_path / 'plugins', 'gamma')
class StubMigrationManager:
def has_pending_migrations(self, name):
return False
def run_plugin_migrations(self, name, revision='head'):
return True
def has_unapplied_migrations(self, name):
return False
manager.migration_manager = StubMigrationManager()
result = manager.apply_profile(['gamma'])
assert manager.registry.is_installed('gamma')
assert manager.registry.is_enabled('gamma')
assert 'gamma' in result['installed']
def test_apply_profile_rejects_unknown_plugin(manager):
"""A profile naming a plugin that is not on disk fails loudly."""
from shopdb.exceptions import PluginError
with pytest.raises(PluginError):
manager.apply_profile(['doesnotexist'])
def test_manifest_schema_check():
"""The no-dependency manifest schema check flags missing/mistyped fields
and accepts a valid manifest (with unknown extra fields)."""
from shopdb.plugins.cli import _check_against_schema, _load_manifest_schema
schema = _load_manifest_schema()
good = {'name': 'x', 'version': '1.0.0', 'description': 'd',
'dependencies': [], 'tier': 'optional', 'somefuture': 42}
assert _check_against_schema(good, schema) == []
bad = {'name': 'x', 'version': '1.0.0', 'description': 'd',
'dependencies': 'notalist', 'tier': 'bogus',
'default_enabled': 'yes'}
errors = _check_against_schema(bad, schema)
assert any('dependencies' in e for e in errors)
assert any('tier' in e for e in errors)
assert any('default_enabled' in e for e in errors)
assert any('version' in e for e in _check_against_schema({'name': 'x'}, schema))
def test_all_bundled_manifests_match_schema(app):
"""Every bundled plugin's manifest passes the schema - the schema must
describe reality, not an idealized shape."""
from shopdb.plugins.cli import _check_against_schema, _load_manifest_schema
schema = _load_manifest_schema()
pm = app.extensions['plugin_manager']
for name in pm.loader.discover_plugins():
manifest = pm.loader.load_manifest(name)
assert _check_against_schema(manifest, schema) == [], name
def test_validate_bundled_plugin_passes(app):
"""`flask plugin validate <bundled>` exits clean on a real plugin."""
runner = app.test_cli_runner()
result = runner.invoke(args=['plugin', 'validate', 'printers'])
assert result.exit_code in (0, None), result.output
assert 'valid' in result.output
# --- Seed idempotency -------------------------------------------------------
def test_seed_settings_is_idempotent(app, db):