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

@@ -1,10 +1,60 @@
"""Flask CLI commands for plugin management."""
import json
from pathlib import Path
import click
from flask import current_app
from flask.cli import with_appcontext
# JSON-Schema primitive name -> Python type(s) for the no-dependency validator.
_SCHEMA_TYPES = {
'string': str,
'boolean': bool,
'array': list,
'object': dict,
'integer': int,
'number': (int, float),
}
def _load_manifest_schema() -> dict:
"""Load the packaged manifest schema (ships with the app, unlike docs/)."""
schema_path = Path(__file__).with_name('manifest_schema.json')
with open(schema_path) as f:
return json.load(f)
def _check_against_schema(manifest: dict, schema: dict) -> list:
"""Lightweight schema check without a jsonschema dependency.
Verifies required fields are present and that known typed fields hold the
right JSON type; unknown fields are allowed (additionalProperties). Returns
a list of human-readable error strings (empty = valid).
"""
errors = []
for field in schema.get('required', []):
if field not in manifest:
errors.append(f"missing required field '{field}'")
props = schema.get('properties', {})
for key, value in manifest.items():
spec = props.get(key)
if not spec:
continue # additionalProperties permitted
expected = spec.get('type')
pytype = _SCHEMA_TYPES.get(expected)
# bool is a subclass of int; guard so a boolean does not pass 'integer'
if pytype and (not isinstance(value, pytype)
or (expected in ('integer', 'number')
and isinstance(value, bool))):
errors.append(f"field '{key}' should be {expected}")
if spec.get('enum') and value not in spec['enum']:
errors.append(
f"field '{key}' must be one of {spec['enum']}, got '{value}'")
return errors
@click.group('plugin')
def plugin_cli():
"""Plugin management commands."""
@@ -230,6 +280,130 @@ def new_plugin(name: str, description: str, overwrite: bool):
click.echo(f' 6. Run: pytest plugins/{name}/tests/')
@plugin_cli.command('validate')
@click.argument('name')
@with_appcontext
def validate_plugin(name: str):
"""Validate a plugin directory against the manifest schema + contract.
Pre-publish gate (directory mode). Checks: manifest loads and its name
matches the directory, required/typed fields per the manifest schema, the
core_version range admits this framework's contract version, and every
declared hard dependency exists on disk. Exits non-zero on any failure.
Usage: flask plugin validate printers
"""
from shopdb import __contract_version__
from ..exceptions import PluginError
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
failures = []
# 1. Manifest loads (raises on missing/unparseable/name-mismatch).
try:
manifest = pm.loader.load_manifest(name)
except PluginError as e:
click.echo(click.style(f" manifest: {e}", fg='red'))
raise SystemExit(1)
click.echo(click.style(" manifest loads + name matches directory", fg='green'))
# 2. Schema.
schema_errors = _check_against_schema(manifest, _load_manifest_schema())
if schema_errors:
for err in schema_errors:
failures.append(f"schema: {err}")
click.echo(click.style(f" schema: {err}", fg='red'))
else:
click.echo(click.style(" schema OK", fg='green'))
# 3. Contract version range admits this framework.
try:
pm.loader.check_contract_version(name, __contract_version__)
click.echo(click.style(
f" core_version admits contract {__contract_version__}", fg='green'))
except PluginError as e:
failures.append(str(e))
click.echo(click.style(f" core_version: {e}", fg='red'))
# 4. Declared hard dependencies exist on disk (name-only; ranges stripped
# later when the loader gains range semantics).
available = set(pm.loader.discover_plugins())
for dep in manifest.get('dependencies', []):
depname = dep.split('>')[0].split('<')[0].split('=')[0].split('!')[0].split('~')[0].strip()
if depname not in available:
failures.append(f"dependency '{depname}' not found on disk")
click.echo(click.style(
f" dependency '{depname}' not found on disk", fg='red'))
if not failures and manifest.get('dependencies'):
click.echo(click.style(" dependencies present on disk", fg='green'))
click.echo("")
if failures:
click.echo(click.style(
f"{name}: INVALID ({len(failures)} problem(s))", fg='red'))
raise SystemExit(1)
click.echo(click.style(f"{name}: valid", fg='green'))
@plugin_cli.command('apply-profile')
@click.argument('profile', type=click.Path(exists=True, dir_okay=False))
@with_appcontext
def apply_profile(profile: str):
"""Install AND enable exactly the plugins named in a site profile.
Declarative site setup: replaces the hand-ordered install/enable sequences
in the deploy runbooks. Resolves the hard-dependency closure and applies it
in dependency order; idempotent. Does NOT remove anything absent from the
list.
Profile JSON shape:
{ "site": "west-jefferson",
"plugins": ["machines", "printers", "computers"],
"locked": ["computers"] }
Usage: flask plugin apply-profile site-profile.json
"""
from ..exceptions import PluginError
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
with open(profile) as f:
data = json.load(f)
names = data.get('plugins', [])
if not isinstance(names, list) or not names:
click.echo(click.style(
"Profile has no 'plugins' list to apply", fg='red'))
raise SystemExit(1)
click.echo(f"Applying profile: {data.get('site', profile)}")
try:
result = pm.apply_profile(names, locked=data.get('locked'))
except PluginError as e:
click.echo(click.style(f"Profile failed: {e}", fg='red'))
raise SystemExit(1)
if result['installed']:
click.echo(click.style(
f" installed: {', '.join(result['installed'])}", fg='green'))
if result['enabled']:
click.echo(click.style(
f" enabled: {', '.join(result['enabled'])}", fg='green'))
if result['already']:
click.echo(click.style(
f" unchanged: {', '.join(result['already'])}", fg='white'))
click.echo("")
click.echo(click.style(
"Run 'flask plugin upgrade-all' to apply plugin migrations, then "
"restart so new blueprints/routes register.", fg='yellow'))
@plugin_cli.command('migrate')
@click.argument('name')
@click.option('--revision', default='head', help='Target revision')