Completes the marketplace security model. Verification stops being advisory: a plugin only loads or migrates when its tree matches a trusted signature, and plugins are pulled from a signed shelf with anti-rollback and revocation. Enforcement (default OFF - existing deploys unchanged): - verification.py PluginVerifier, shared by the loader (verify-at-load, before plugin.py is imported) and the migration manager (verify-at-migrate, before any DDL). Fail-closed: an unsigned/tampered/wrong-key plugin does not run. - Gated by PLUGIN_REQUIRE_SIGNED. PLUGIN_DEV_TRUST_DIRS exempts named dirs but only under DEBUG/TESTING; production ignores it. - flask plugin stamp-bundled writes provenance into in-tree plugins so verify-at-load applies to bundled plugins too (image build step). - tier:core manifest guard: uninstall/disable refuse a core-tier plugin. Shelf (shelf.py): - Signed shelf-index.json (+ .sig): monotonic serial (a site refuses an older index - anti-rollback), revoked list carried across builds, per-entry version/tier/core_version for browse. Index is a browse layer only; adopt reads security-bearing fields from the verified artifact. - flask plugin shelf-build / shelf-list / adopt / audit. adopt verifies index + artifact (signature + every file hash), unpacks to staging, re-verifies, then atomically moves into place and installs+enables the closure. Refuses a downgrade without --force-downgrade. Anti-rollback serial stored in instance/shelf-state.json. - config PLUGIN_SHELF_DIR; the app only reads the folder, never speaks a network. .env.example + docs/PLUGIN-SIGNING.md document the flow. 22 tests: verifier policy (off / no-keys / signed / tampered / wrong-key / dev-exempt), verify-at-load + verify-at-migrate integration, tier guard, index sign/verify + tamper/wrong-key, serial state, revocation, version resolution, verified atomic unpack + tamper refusal. Live-smoked keygen->pack->shelf-build ->list->adopt->audit + serial guard. 1050 pass, naming green.
882 lines
32 KiB
Python
882 lines
32 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'))
|
|
|
|
|
|
def _shelf_keys(pubkeys):
|
|
"""Trusted keys for shelf/artifact verification: explicit --pubkey first,
|
|
else the site's PLUGIN_TRUSTED_KEYS."""
|
|
from .signing import load_trusted_keys
|
|
paths = list(pubkeys) or current_app.config.get('PLUGIN_TRUSTED_KEYS', [])
|
|
return load_trusted_keys(paths), paths
|
|
|
|
|
|
@plugin_cli.command('stamp-bundled')
|
|
@click.option('--key', 'key_path', required=True, type=click.Path(exists=True),
|
|
help='Private signing key PEM')
|
|
@click.option('--publisher', default='', help='Publisher id in the provenance')
|
|
@click.argument('name', required=False)
|
|
@with_appcontext
|
|
def stamp_bundled(key_path, publisher, name):
|
|
"""Write PROVENANCE.json/.sig into in-tree plugin dirs (image build step).
|
|
|
|
Stamps one plugin (NAME) or every discovered plugin, so verify-at-load
|
|
applies to bundled plugins identically to adopted ones. Run at image build
|
|
with the site/build key, then set PLUGIN_REQUIRE_SIGNED.
|
|
"""
|
|
from .signing import (load_private_key, build_provenance,
|
|
serialize_provenance, sign,
|
|
PROVENANCE_NAME, PROVENANCE_SIG)
|
|
|
|
pm = current_app.extensions.get('plugin_manager')
|
|
if not pm:
|
|
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
private_key = load_private_key(Path(key_path).read_bytes())
|
|
names = [name] if name else pm.loader.discover_plugins()
|
|
plugins_dir = Path(pm.loader.plugins_dir)
|
|
|
|
for plugin_name in names:
|
|
plugin_dir = plugins_dir / plugin_name
|
|
manifest = pm.loader.load_manifest(plugin_name)
|
|
provenance = build_provenance(
|
|
plugin_dir, manifest['name'], manifest['version'], publisher)
|
|
provenance_bytes = serialize_provenance(provenance)
|
|
(plugin_dir / PROVENANCE_NAME).write_bytes(provenance_bytes)
|
|
(plugin_dir / PROVENANCE_SIG).write_bytes(
|
|
sign(private_key, provenance_bytes))
|
|
click.echo(click.style(f" stamped {plugin_name}", fg='green'))
|
|
|
|
|
|
@plugin_cli.command('shelf-build')
|
|
@click.option('--dir', 'shelf_dir', required=True,
|
|
type=click.Path(exists=True, file_okay=False),
|
|
help='Shelf directory holding the artifacts')
|
|
@click.option('--key', 'key_path', required=True,
|
|
type=click.Path(exists=True), help='Private signing key PEM')
|
|
@click.option('--serial', type=int, required=True,
|
|
help='Index serial (must exceed the current published serial)')
|
|
@click.option('--publisher', default='')
|
|
def shelf_build(shelf_dir, key_path, serial, publisher):
|
|
"""Build + sign the shelf index from the artifacts in DIR.
|
|
|
|
Carries forward the existing revoked list and refuses a serial that does not
|
|
advance past the published one (a lower serial would let a site's
|
|
anti-rollback reject the new index).
|
|
"""
|
|
import json as _json
|
|
from .signing import load_private_key
|
|
from .shelf import build_index, INDEX_NAME
|
|
|
|
shelf = Path(shelf_dir)
|
|
revoked = []
|
|
existing = shelf / INDEX_NAME
|
|
if existing.exists():
|
|
try:
|
|
prev = _json.loads(existing.read_text())
|
|
revoked = prev.get('revoked', [])
|
|
if serial <= int(prev.get('serial', -1)):
|
|
click.echo(click.style(
|
|
f"serial {serial} must exceed current "
|
|
f"{prev.get('serial')}", fg='red'))
|
|
raise SystemExit(1)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
private_key = load_private_key(Path(key_path).read_bytes())
|
|
index = build_index(shelf, private_key, serial,
|
|
publisher=publisher, revoked=revoked)
|
|
total = sum(len(v) for v in index['plugins'].values())
|
|
click.echo(click.style(
|
|
f"Shelf index signed: serial {serial}, {total} artifact(s), "
|
|
f"{len(revoked)} revoked.", fg='green'))
|
|
|
|
|
|
@plugin_cli.command('shelf-list')
|
|
@click.option('--pubkey', 'pubkeys', multiple=True, type=click.Path(exists=True))
|
|
@with_appcontext
|
|
def shelf_list(pubkeys):
|
|
"""Browse the configured shelf (verifies index signature + serial)."""
|
|
from shopdb import __contract_version__
|
|
from packaging.specifiers import SpecifierSet
|
|
from packaging.version import Version
|
|
from .shelf import load_index, read_last_serial, write_last_serial, is_revoked
|
|
|
|
shelf_dir = current_app.config.get('PLUGIN_SHELF_DIR')
|
|
if not shelf_dir:
|
|
click.echo(click.style("PLUGIN_SHELF_DIR not configured", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
keys, _ = _shelf_keys(pubkeys)
|
|
index, errors = load_index(shelf_dir, keys)
|
|
for err in errors:
|
|
click.echo(click.style(f" {err}", fg='red'))
|
|
if index is None:
|
|
raise SystemExit(1)
|
|
|
|
last = read_last_serial(current_app.instance_path)
|
|
if index['serial'] < last:
|
|
click.echo(click.style(
|
|
f"REFUSING shelf: index serial {index['serial']} < last seen "
|
|
f"{last} (rollback?)", fg='red'))
|
|
raise SystemExit(1)
|
|
if errors:
|
|
raise SystemExit(1)
|
|
write_last_serial(current_app.instance_path, index['serial'])
|
|
|
|
click.echo("")
|
|
click.echo(click.style(
|
|
f"Shelf (serial {index['serial']}):", fg='cyan', bold=True))
|
|
for name in sorted(index.get('plugins', {})):
|
|
for entry in index['plugins'][name]:
|
|
compat = 'ok'
|
|
spec = entry.get('core_version', '')
|
|
if spec and Version(__contract_version__) not in SpecifierSet(spec):
|
|
compat = f'needs core {spec}'
|
|
flags = []
|
|
if is_revoked(index, name, entry['version']):
|
|
flags.append('REVOKED')
|
|
if compat != 'ok':
|
|
flags.append(compat)
|
|
tag = (' [' + ', '.join(flags) + ']') if flags else ''
|
|
click.echo(f" {name:20} {entry['version']:10} "
|
|
f"({entry.get('tier', 'optional')}){tag}")
|
|
|
|
|
|
@plugin_cli.command('adopt')
|
|
@click.argument('spec')
|
|
@click.option('--pubkey', 'pubkeys', multiple=True, type=click.Path(exists=True))
|
|
@click.option('--force-downgrade', is_flag=True,
|
|
help='Allow adopting a version lower than the installed one')
|
|
@with_appcontext
|
|
def adopt_plugin(spec, pubkeys, force_downgrade):
|
|
"""Pull a plugin from the shelf, verify it, and install + enable it.
|
|
|
|
SPEC is a plugin name or name==version. Verifies the shelf index and the
|
|
artifact signature + hashes, unpacks atomically only after verification,
|
|
then installs + enables the plugin and its dependency closure.
|
|
|
|
Usage: flask plugin adopt printers (or printers==1.2.0)
|
|
"""
|
|
from packaging.version import Version
|
|
from ..exceptions import PluginError
|
|
from .shelf import (load_index, read_last_serial, write_last_serial,
|
|
resolve_version, unpack_verified)
|
|
|
|
pm = current_app.extensions.get('plugin_manager')
|
|
if not pm:
|
|
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
name, _, version = spec.partition('==')
|
|
name = name.strip()
|
|
version = version.strip() or None
|
|
|
|
shelf_dir = current_app.config.get('PLUGIN_SHELF_DIR')
|
|
if not shelf_dir:
|
|
click.echo(click.style("PLUGIN_SHELF_DIR not configured", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
keys, _ = _shelf_keys(pubkeys)
|
|
index, errors = load_index(shelf_dir, keys)
|
|
for err in errors:
|
|
click.echo(click.style(f" {err}", fg='red'))
|
|
if index is None or errors:
|
|
raise SystemExit(1)
|
|
|
|
last = read_last_serial(current_app.instance_path)
|
|
if index['serial'] < last:
|
|
click.echo(click.style(
|
|
f"REFUSING shelf: serial {index['serial']} < last seen {last} "
|
|
f"(rollback?)", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
try:
|
|
resolved_version, artifact_name = resolve_version(index, name, version)
|
|
except (KeyError, ValueError) as e:
|
|
click.echo(click.style(f"{e}", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
state = pm.registry.get(name)
|
|
if state and not force_downgrade:
|
|
try:
|
|
if Version(resolved_version) < Version(state.version):
|
|
click.echo(click.style(
|
|
f"Refusing downgrade {state.version} -> {resolved_version} "
|
|
f"(use --force-downgrade)", fg='red'))
|
|
raise SystemExit(1)
|
|
except Exception:
|
|
pass
|
|
|
|
artifact_path = Path(shelf_dir) / artifact_name
|
|
plugins_dir = Path(pm.loader.plugins_dir)
|
|
click.echo(f"Verifying + unpacking {name} {resolved_version} ...")
|
|
try:
|
|
unpack_verified(artifact_path, keys, plugins_dir, name)
|
|
except ValueError as e:
|
|
click.echo(click.style(f" verification failed: {e}", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
# Clear any cached manifest so the freshly unpacked one is read.
|
|
pm.loader._manifests.pop(name, None)
|
|
try:
|
|
result = pm.apply_profile([name])
|
|
except PluginError as e:
|
|
click.echo(click.style(f" {e}", fg='red'))
|
|
click.echo(click.style(
|
|
" (adopt the missing dependency plugins first)", fg='yellow'))
|
|
raise SystemExit(1)
|
|
|
|
write_last_serial(current_app.instance_path, index['serial'])
|
|
click.echo("")
|
|
click.echo(click.style(
|
|
f"Adopted {name} {resolved_version}.", fg='green'))
|
|
if result['installed']:
|
|
click.echo(f" installed: {', '.join(result['installed'])}")
|
|
if result['enabled']:
|
|
click.echo(f" enabled: {', '.join(result['enabled'])}")
|
|
click.echo(click.style(
|
|
"Run 'flask plugin upgrade-all' then restart so routes register.",
|
|
fg='yellow'))
|
|
|
|
|
|
@plugin_cli.command('audit')
|
|
@click.option('--pubkey', 'pubkeys', multiple=True, type=click.Path(exists=True))
|
|
@with_appcontext
|
|
def audit_plugins(pubkeys):
|
|
"""Warn about installed plugins whose version is revoked on the shelf."""
|
|
from .shelf import load_index, is_revoked
|
|
|
|
pm = current_app.extensions.get('plugin_manager')
|
|
if not pm:
|
|
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
shelf_dir = current_app.config.get('PLUGIN_SHELF_DIR')
|
|
if not shelf_dir:
|
|
click.echo(click.style("PLUGIN_SHELF_DIR not configured", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
keys, _ = _shelf_keys(pubkeys)
|
|
index, errors = load_index(shelf_dir, keys)
|
|
for err in errors:
|
|
click.echo(click.style(f" {err}", fg='red'))
|
|
if index is None:
|
|
raise SystemExit(1)
|
|
|
|
flagged = []
|
|
for name, state in pm.registry.get_all().items():
|
|
if is_revoked(index, name, state.version):
|
|
flagged.append(f"{name} {state.version}")
|
|
|
|
click.echo("")
|
|
if flagged:
|
|
click.echo(click.style(
|
|
"REVOKED plugins installed:", fg='red', bold=True))
|
|
for item in flagged:
|
|
click.echo(click.style(f" {item}", fg='red'))
|
|
raise SystemExit(1)
|
|
click.echo(click.style("No installed plugin is revoked.", fg='green'))
|
|
|
|
|
|
@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'))
|