Files
shopdb-flask/shopdb/plugins/cli.py
cproudlock 86f5f1be68 ADR-013 Phase 1: signed plugin artifacts (pack/validate/keygen)
Packaging + provenance for the plugin marketplace. No runtime behavior change
yet - verification is available on demand; enforcing it at plugin load/migrate
and pulling from a shelf are Phase 2.

- signing.py: ed25519 key pairs + provenance. Provenance is a sorted per-file
  SHA-256 map plus metadata; the detached signature covers the exact
  serialized provenance bytes, so verifying is re-hash files, re-serialize,
  check signature. verify() accepts any of several trusted keys (rotation).
  Uses cryptography (already a dependency).
- packaging.py: pack() builds a signed <name>-<version>.shopdbplugin (zip +
  PROVENANCE.json + PROVENANCE.sig). verify_artifact()/verify_dir() re-hash
  and check the signature, and flag a tampered file, an unexpected file, a
  wrong/absent key - all fail closed.
- CLI: `flask plugin keygen` (publisher key pair), `flask plugin pack <name>
  --key` (validates then signs), and `flask plugin validate` extended to a
  signed artifact by path (--pubkey, else PLUGIN_TRUSTED_KEYS).
- config PLUGIN_TRUSTED_KEYS: os.pathsep-separated public-key PEM paths,
  delivered with the site config, never read from the shelf. .env.example
  documents it.
- docs/PLUGIN-SIGNING.md: curator flow (keygen offline, review, pack, publish,
  pin keys, rotate).

The signature proves an artifact is exactly what a curator signed, not that the
code is safe - human review before signing is the control. 11 tests: sign/verify
round trip, wrong key, provenance excludes noise, serialize determinism, pack +
verify, tamper -> hash mismatch, extra file, no-key fail-closed, verify_dir.
1028 pass, naming green.
2026-07-18 20:21:57 -04:00

605 lines
21 KiB
Python

"""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."""
pass
@plugin_cli.command('list')
@with_appcontext
def list_plugins():
"""List all available plugins."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
return
plugins = pm.discover_available()
if not plugins:
click.echo("No plugins found in plugins directory.")
return
# Format output
click.echo("")
click.echo(click.style("Available Plugins:", fg='cyan', bold=True))
click.echo("-" * 60)
for p in plugins:
if p['enabled']:
status = click.style("[Enabled]", fg='green')
elif p['installed']:
status = click.style("[Disabled]", fg='yellow')
else:
status = click.style("[Available]", fg='white')
click.echo(f" {p['name']:20} v{p['version']:10} {status}")
if p['description']:
click.echo(f" {p['description'][:55]}...")
if p['dependencies']:
deps = ', '.join(p['dependencies'])
click.echo(f" Dependencies: {deps}")
click.echo("")
@plugin_cli.command('install')
@click.argument('name')
@click.option('--skip-migrations', is_flag=True, help='Skip database migrations')
@with_appcontext
def install_plugin(name: str, skip_migrations: bool):
"""
Install a plugin.
Usage: flask plugin install printers
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
click.echo(f"Installing plugin: {name}")
if pm.install_plugin(name, run_migrations=not skip_migrations):
click.echo(click.style(f"Successfully installed {name}", fg='green'))
else:
click.echo(click.style(f"Failed to install {name}", fg='red'))
raise SystemExit(1)
@plugin_cli.command('uninstall')
@click.argument('name')
@click.option('--remove-data', is_flag=True, help='Remove plugin database tables')
@click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?')
@with_appcontext
def uninstall_plugin(name: str, remove_data: bool):
"""
Uninstall a plugin.
Usage: flask plugin uninstall printers
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
click.echo(f"Uninstalling plugin: {name}")
if pm.uninstall_plugin(name, remove_data=remove_data):
click.echo(click.style(f"Successfully uninstalled {name}", fg='green'))
else:
click.echo(click.style(f"Failed to uninstall {name}", fg='red'))
raise SystemExit(1)
@plugin_cli.command('enable')
@click.argument('name')
@with_appcontext
def enable_plugin(name: str):
"""Enable a disabled plugin."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
if pm.enable_plugin(name):
click.echo(click.style(f"Enabled {name}", fg='green'))
# Nudge the operator if the plugin's chain is ahead of the DB.
try:
if pm.migration_manager and \
pm.migration_manager.has_unapplied_migrations(name):
click.echo(click.style(
f" {name} has unapplied migrations - "
f"run 'flask plugin upgrade-all'", fg='yellow'))
except Exception:
pass
else:
click.echo(click.style(f"Failed to enable {name}", fg='red'))
raise SystemExit(1)
@plugin_cli.command('disable')
@click.argument('name')
@with_appcontext
def disable_plugin(name: str):
"""Disable an enabled plugin."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
if pm.disable_plugin(name):
click.echo(click.style(f"Disabled {name}", fg='green'))
else:
click.echo(click.style(f"Failed to disable {name}", fg='red'))
raise SystemExit(1)
@plugin_cli.command('info')
@click.argument('name')
@with_appcontext
def plugin_info(name: str):
"""Show detailed information about a plugin."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
plugin_class = pm.loader.load_plugin_class(name)
if not plugin_class:
click.echo(click.style(f"Plugin {name} not found", fg='red'))
raise SystemExit(1)
try:
temp = plugin_class()
meta = temp.meta
except Exception as e:
click.echo(click.style(f"Error loading plugin: {e}", fg='red'))
raise SystemExit(1)
state = pm.registry.get(name)
click.echo("")
click.echo("=" * 50)
click.echo(click.style(f"Plugin: {meta.name}", fg='cyan', bold=True))
click.echo("=" * 50)
click.echo(f"Version: {meta.version}")
click.echo(f"Description: {meta.description}")
click.echo(f"Author: {meta.author or 'Unknown'}")
click.echo(f"API Prefix: {meta.api_prefix}")
click.echo(f"Dependencies: {', '.join(meta.dependencies) or 'None'}")
click.echo(f"Core Version: {meta.core_version}")
click.echo("")
if state:
status = click.style('Enabled', fg='green') if state.enabled else click.style('Disabled', fg='yellow')
click.echo(f"Status: {status}")
click.echo(f"Installed: {state.installed_at}")
click.echo(f"Migrations: {len(state.migrations_applied)} applied")
else:
click.echo(f"Status: {click.style('Not installed', fg='white')}")
click.echo("")
@plugin_cli.command('new')
@click.argument('name')
@click.option('--description', default='', help='One-sentence plugin description')
@click.option('--overwrite', is_flag=True, help='Overwrite existing plugin directory')
@with_appcontext
def new_plugin(name: str, description: str, overwrite: bool):
"""Scaffold a new plugin from the bundled templates.
Usage: flask plugin new cameras --description "Tracks shop-floor cameras"
"""
from pathlib import Path
from .scaffolder import scaffold_plugin, ScaffoldError
plugins_dir = Path(current_app.root_path).parent / 'plugins'
if not description:
description = f'{name.capitalize()} plugin (TODO: replace this description)'
try:
target = scaffold_plugin(
name=name,
description=description,
plugins_dir=plugins_dir,
overwrite=overwrite,
)
except ScaffoldError as e:
click.echo(click.style(f'Scaffold failed: {e}', fg='red'))
raise SystemExit(1)
click.echo(click.style(f'Created plugin at {target}', fg='green'))
click.echo('')
click.echo('Next steps:')
click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields')
click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints')
click.echo(f' 3. Add plugins/{name}/migrations/ with an Alembic chain that')
click.echo(f' creates your tables (per-plugin chain, NOT the core chain;')
click.echo(f' see ADR-008). Register the tables in PLUGIN_TABLE_OWNERS.')
click.echo(f' 4. Run: flask plugin install {name}')
click.echo(f' 5. Run: flask plugin upgrade-all')
click.echo(f' 6. Run: pytest plugins/{name}/tests/')
def _dep_name(dep: str) -> str:
"""Bare plugin name from a dependency spec, dropping any PEP440 range."""
for sep in ('>', '<', '=', '!', '~', ' '):
dep = dep.split(sep)[0]
return dep.strip()
def _validate_directory(pm, name: str) -> list:
"""Run the directory-mode checks, echoing each. Returns a failures list."""
from shopdb import __contract_version__
from ..exceptions import PluginError
failures = []
try:
manifest = pm.loader.load_manifest(name)
except PluginError as e:
click.echo(click.style(f" manifest: {e}", fg='red'))
return [str(e)]
click.echo(click.style(
" manifest loads + name matches directory", fg='green'))
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'))
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'))
available = set(pm.loader.discover_plugins())
dep_failures = []
for dep in manifest.get('dependencies', []):
depname = _dep_name(dep)
if depname not in available:
dep_failures.append(depname)
failures.append(f"dependency '{depname}' not found on disk")
click.echo(click.style(
f" dependency '{depname}' not found on disk", fg='red'))
if manifest.get('dependencies') and not dep_failures:
click.echo(click.style(" dependencies present on disk", fg='green'))
return failures
def _validate_artifact(pubkey_paths, artifact_path: str) -> list:
"""Verify a signed .shopdbplugin: signature + hashes + manifest schema +
contract. Returns a failures list."""
from shopdb import __contract_version__
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from .packaging import verify_artifact
from .signing import load_trusted_keys
keys = load_trusted_keys(pubkey_paths)
if not keys:
click.echo(click.style(
" no trusted keys (pass --pubkey or set PLUGIN_TRUSTED_KEYS)",
fg='red'))
manifest, errors = verify_artifact(artifact_path, keys)
failures = list(errors)
for err in errors:
click.echo(click.style(f" {err}", fg='red'))
if not errors:
click.echo(click.style(
" signature trusted + every file hash intact", fg='green'))
if manifest is not None:
schema_errors = _check_against_schema(
manifest, _load_manifest_schema())
for err in schema_errors:
failures.append(f"schema: {err}")
click.echo(click.style(f" schema: {err}", fg='red'))
if not schema_errors:
click.echo(click.style(" schema OK", fg='green'))
spec = manifest.get('core_version', '')
if spec and Version(__contract_version__) not in SpecifierSet(spec):
failures.append(
f"core_version {spec} excludes contract {__contract_version__}")
click.echo(click.style(
f" core_version {spec} excludes contract "
f"{__contract_version__}", fg='red'))
elif spec:
click.echo(click.style(
f" core_version admits contract {__contract_version__}",
fg='green'))
return failures
@plugin_cli.command('validate')
@click.argument('target')
@click.option('--pubkey', 'pubkeys', multiple=True,
type=click.Path(exists=True),
help='Trusted public key PEM (artifact mode). Repeatable.')
@with_appcontext
def validate_plugin(target: str, pubkeys):
"""Validate a plugin - directory (by name) or a signed artifact (by path).
Directory mode (`flask plugin validate printers`): manifest loads + name
match, manifest schema, core_version admits this contract, dependencies
exist on disk.
Artifact mode (`flask plugin validate dist/printers-1.0.0.shopdbplugin`):
signature against trusted keys (--pubkey, else PLUGIN_TRUSTED_KEYS), every
file hash, no unexpected files, manifest schema + core_version. Exits
non-zero on any failure.
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
is_artifact = target.endswith('.shopdbplugin') and Path(target).is_file()
if is_artifact:
keys = list(pubkeys) or current_app.config.get(
'PLUGIN_TRUSTED_KEYS', [])
failures = _validate_artifact(keys, target)
label = Path(target).name
else:
failures = _validate_directory(pm, target)
label = target
click.echo("")
if failures:
click.echo(click.style(
f"{label}: INVALID ({len(failures)} problem(s))", fg='red'))
raise SystemExit(1)
click.echo(click.style(f"{label}: valid", fg='green'))
@plugin_cli.command('keygen')
@click.option('--out', 'out_dir', default='.', type=click.Path(),
help='Directory to write the key pair into')
@click.option('--name', 'key_name', default='curator',
help='Base filename for the pair')
def keygen(out_dir: str, key_name: str):
"""Generate an ed25519 publisher key pair for signing plugin artifacts.
Writes <name>.key (PRIVATE - keep offline with the curator) and <name>.pub
(public - pin on each site via PLUGIN_TRUSTED_KEYS). Not app-bound.
"""
from .signing import generate_keypair
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
private_pem, public_pem = generate_keypair()
private_path = out / f'{key_name}.key'
public_path = out / f'{key_name}.pub'
private_path.write_bytes(private_pem)
public_path.write_bytes(public_pem)
try:
private_path.chmod(0o600)
except OSError:
pass
click.echo(click.style(f"Private key: {private_path}", fg='green'))
click.echo(click.style(f"Public key: {public_path}", fg='green'))
click.echo("")
click.echo(click.style(
"Keep the .key OFFLINE. Never place it on the plugin shelf. Distribute "
"the .pub with each site's config and list it in PLUGIN_TRUSTED_KEYS.",
fg='yellow'))
@plugin_cli.command('pack')
@click.argument('name')
@click.option('--key', 'key_path', required=True,
type=click.Path(exists=True), help='Private signing key PEM')
@click.option('--publisher', default='',
help='Publisher id recorded in the provenance')
@click.option('--out', 'out_dir', default=None, type=click.Path(),
help='Output directory (default: the plugins/ directory)')
@with_appcontext
def pack_plugin(name: str, key_path: str, publisher: str, out_dir):
"""Validate a plugin directory, then emit a signed artifact.
Produces <name>-<version>.shopdbplugin. Fails without packing if the
directory does not validate.
Usage: flask plugin pack printers --key curator.key --publisher west-jefferson
"""
from .signing import load_private_key
from .packaging import pack
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
click.echo(f"Validating {name} ...")
failures = _validate_directory(pm, name)
if failures:
click.echo("")
click.echo(click.style(
f"Refusing to pack {name}: {len(failures)} validation problem(s)",
fg='red'))
raise SystemExit(1)
plugin_dir = Path(pm.loader.plugins_dir) / name
private_key = load_private_key(Path(key_path).read_bytes())
artifact = pack(plugin_dir, private_key,
publisher=publisher, out_dir=out_dir)
click.echo("")
click.echo(click.style(f"Packed + signed: {artifact}", 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')
@with_appcontext
def migrate_plugin(name: str, revision: str):
"""Run migrations for a specific plugin."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
if not pm.registry.is_installed(name):
click.echo(click.style(f"Plugin {name} is not installed", fg='red'))
raise SystemExit(1)
click.echo(f"Running migrations for {name}...")
if pm.migration_manager.run_plugin_migrations(name, revision):
click.echo(click.style("Migrations completed", fg='green'))
else:
click.echo(click.style("Migration failed", fg='red'))
raise SystemExit(1)
@plugin_cli.command('upgrade-all')
@with_appcontext
def upgrade_all_plugins():
"""Run pending migrations for every discovered plugin.
Idempotent. Run this after `flask db upgrade` on every deploy and
upgrade. It stamps each bundled plugin's anchor revision into
alembic_version_<plugin> and applies any per-plugin migrations added
after the ownership cutover (ADR-008). Safe to re-run at head.
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
results = pm.upgrade_all_plugins()
if not results:
click.echo("No plugins discovered.")
return
for name, status in sorted(results.items()):
if status == 'ok':
click.echo(click.style(f" {name:20} ok", fg='green'))
elif status == 'no-migrations':
click.echo(click.style(f" {name:20} no migrations", fg='yellow'))
else:
click.echo(click.style(f" {name:20} {status}", fg='red'))