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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user