main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
1040 lines
39 KiB
Python
1040 lines
39 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)
|
|
from .packaging import strip_bytecode
|
|
|
|
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
|
|
# Stamp a clean tree: no bytecode, so verify-at-load never trips on a
|
|
# cache the provenance does not cover.
|
|
strip_bytecode(plugin_dir)
|
|
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, expected_sha = 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,
|
|
expected_version=resolved_version,
|
|
expected_sha256=expected_sha)
|
|
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). It also seeds any settings a
|
|
plugin declares (get_settings_defaults) that this site is missing, so a
|
|
setting added in a later version reaches a site that installed earlier.
|
|
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'))
|
|
|
|
|
|
@plugin_cli.command('prune-schema')
|
|
@click.option('--yes', is_flag=True,
|
|
help='Actually drop the tables (default is a dry-run preview)')
|
|
@click.option('--force', is_flag=True,
|
|
help='Drop even tables that hold rows (DATA LOSS); default refuses')
|
|
@with_appcontext
|
|
def prune_schema(yes: bool, force: bool):
|
|
"""Drop tables owned by plugins this site did NOT install (ADR-014).
|
|
|
|
Schema-lean per-site DBs: the shared core Alembic baseline creates every
|
|
plugin's tables, so a lean site that omits a plugin still carries that
|
|
plugin's (empty) tables. This drops the tables of every plugin in
|
|
PLUGIN_TABLE_OWNERS that is not installed here, plus the
|
|
ORPHAN_TABLE_OWNERS tables the core chain creates for no bundled plugin at
|
|
all, leaving core + chosen-plugin tables only. Run once at deploy AFTER
|
|
`flask db upgrade` and `flask plugin upgrade-all`.
|
|
|
|
Dry-run by default; pass --yes to execute. Drops by table name (no plugin
|
|
code import) so it works on a lean image where the omitted plugin's
|
|
directory is absent. Refuses to drop a non-empty table unless --force, so a
|
|
misfire on a populated full site cannot silently delete data.
|
|
"""
|
|
from sqlalchemy import create_engine, inspect, text
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.plugins.alembic_template import (
|
|
ORPHAN_TABLE_OWNERS, PLUGIN_TABLE_OWNERS)
|
|
|
|
pm = current_app.extensions.get('plugin_manager')
|
|
if not pm:
|
|
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
installed = {p['name'] for p in pm.discover_available() if p['installed']}
|
|
engine = db.engine
|
|
existing = set(inspect(engine).get_table_names())
|
|
|
|
# A table is a prune candidate when its owning plugin is not installed here
|
|
# AND the table actually exists in this database.
|
|
#
|
|
# ORPHAN_TABLE_OWNERS is folded in here and nowhere else: those tables have
|
|
# no plugin left to install, so they are never in `installed` and are always
|
|
# candidates. Without them a lean site carries them for ever, because the
|
|
# core chain that creates them cannot be edited.
|
|
candidates = dict(PLUGIN_TABLE_OWNERS)
|
|
candidates.update(ORPHAN_TABLE_OWNERS)
|
|
victims = []
|
|
for plugin, tables in candidates.items():
|
|
if plugin in installed:
|
|
continue
|
|
for tablename in tables:
|
|
if tablename in existing:
|
|
victims.append((plugin, tablename))
|
|
|
|
if not victims:
|
|
click.echo("Nothing to prune: every plugin-owned table belongs to an "
|
|
"installed plugin.")
|
|
return
|
|
|
|
# On MySQL, use a SEPARATE engine in AUTOCOMMIT, not db.engine. Two reasons,
|
|
# both of which burned real debugging time:
|
|
# 1. db.engine's pool keeps connections idle-in-transaction (Flask-
|
|
# SQLAlchemy has no request-teardown in a CLI context); a DROP sharing
|
|
# that pool waits on their locks.
|
|
# 2. Without engine-level AUTOCOMMIT, SQLAlchemy runs the COUNT probes in an
|
|
# open transaction (SET AUTOCOMMIT=0). Those reads hold shared metadata
|
|
# locks on every victim table, so the later DROP blocks on the metadata
|
|
# lock forever. Setting isolation_level on the CONNECTION (not the
|
|
# engine) silently did NOT take effect - it must be on the engine.
|
|
# With engine-level AUTOCOMMIT every statement commits on its own, so no read
|
|
# holds a lock into the DROP phase. lock_wait_timeout makes any residual
|
|
# contention fail fast. SQLite has none of this (no metadata locks), and a
|
|
# separate engine to an in-memory database would be a different, empty DB, so
|
|
# there we just use db.engine. db.session is only used above, for pluginstate.
|
|
dialect = engine.dialect.name
|
|
db.session.remove()
|
|
if dialect == 'mysql':
|
|
prune_engine = create_engine(
|
|
current_app.config['SQLALCHEMY_DATABASE_URI'],
|
|
isolation_level='AUTOCOMMIT')
|
|
own_engine = True
|
|
else:
|
|
prune_engine = engine
|
|
own_engine = False
|
|
|
|
try:
|
|
with prune_engine.connect() as conn:
|
|
if dialect == 'mysql':
|
|
conn.execute(text('SET SESSION lock_wait_timeout=15'))
|
|
|
|
# Row counts so a non-empty table is never dropped by accident.
|
|
nonempty = []
|
|
for plugin, tablename in victims:
|
|
count = conn.execute(
|
|
text(f'SELECT COUNT(*) FROM `{tablename}`')).scalar()
|
|
if count:
|
|
nonempty.append((plugin, tablename, count))
|
|
|
|
click.echo(click.style(
|
|
f"Plugins not installed here: "
|
|
f"{', '.join(sorted({p for p, _ in victims}))}", fg='cyan'))
|
|
click.echo(f"Tables to prune ({len(victims)}):")
|
|
for plugin, tablename in victims:
|
|
click.echo(f" {tablename:32} ({plugin})")
|
|
|
|
if nonempty and not force:
|
|
click.echo("")
|
|
click.echo(click.style(
|
|
"REFUSING: the following tables hold rows. Re-run with "
|
|
"--force to drop them anyway (this deletes data), or install "
|
|
"the owning plugin instead.", fg='red'))
|
|
for plugin, tablename, count in nonempty:
|
|
click.echo(click.style(f" {tablename:32} {count} rows",
|
|
fg='red'))
|
|
raise SystemExit(1)
|
|
|
|
if not yes:
|
|
click.echo("")
|
|
click.echo("Dry-run. Re-run with --yes to drop the tables above.")
|
|
return
|
|
|
|
# Intra-plugin foreign keys mean drop order matters; disable the
|
|
# checks for the batch rather than topologically sorting the tables
|
|
# without the models. Restore the PRIOR foreign-key setting after,
|
|
# not a hard ON: on SQLite the same connection is reused (StaticPool),
|
|
# so forcing ON would leak into whatever ran next.
|
|
fk_prev = None
|
|
if dialect == 'mysql':
|
|
conn.execute(text('SET FOREIGN_KEY_CHECKS=0'))
|
|
elif dialect == 'sqlite':
|
|
fk_prev = conn.execute(text('PRAGMA foreign_keys')).scalar()
|
|
conn.execute(text('PRAGMA foreign_keys=OFF'))
|
|
for _plugin, tablename in victims:
|
|
conn.execute(text(f'DROP TABLE IF EXISTS `{tablename}`'))
|
|
if dialect == 'mysql':
|
|
conn.execute(text('SET FOREIGN_KEY_CHECKS=1'))
|
|
elif dialect == 'sqlite':
|
|
conn.execute(text(f'PRAGMA foreign_keys={int(fk_prev or 0)}'))
|
|
conn.commit()
|
|
finally:
|
|
if own_engine:
|
|
prune_engine.dispose()
|
|
|
|
click.echo(click.style(f"Pruned {len(victims)} table(s). This database now "
|
|
f"carries core + installed-plugin tables only.",
|
|
fg='green'))
|