diff --git a/scripts/build-site.sh b/scripts/build-site.sh new file mode 100755 index 0000000..a4f151e --- /dev/null +++ b/scripts/build-site.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Build a LEAN per-site artifact from a site profile (ADR-013 Phase 5). +# +# A site declares its plugins in a profile (deploy/site-profile.example.json). +# This resolves the hard-dependency closure, builds the frontend carrying only +# those plugins (via SITE_PLUGINS -> scripts/stage-frontend.mjs), and stages a +# backend tree containing core + only the chosen plugin dirs. A plugin a site +# did not choose ends up in neither the bundle nor the image. +# +# Usage: scripts/build-site.sh [output-dir] +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROFILE="${1:?usage: build-site.sh [output-dir]}" +OUT="${2:-$REPO/build/site}" + +[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; } + +# Resolve the chosen plugins + their hard-dependency closure from the manifests. +CLOSURE=$(python3 - "$PROFILE" "$REPO" <<'PY' +import json, sys, os +profile_path, repo = sys.argv[1], sys.argv[2] +chosen = json.load(open(profile_path)).get('plugins', []) +plugins_dir = os.path.join(repo, 'plugins') + +def deps(name): + mpath = os.path.join(plugins_dir, name, 'manifest.json') + if not os.path.exists(mpath): + sys.exit(f'profile plugin not found on disk: {name}') + out = [] + for dep in json.load(open(mpath)).get('dependencies', []): + # name-only (strip any PEP440 range) + for sep in '><=!~ ': + dep = dep.split(sep)[0] + out.append(dep.strip()) + return out + +closure, seen = [], set() +def add(name): + if name in seen: return + seen.add(name) + for d in deps(name): add(d) + closure.append(name) +for p in chosen: add(p) +print(','.join(closure)) +PY +) + +echo "Site profile: $PROFILE" +echo "Plugin closure: $CLOSURE" + +# Frontend: build carrying only the closure's plugins. +echo "==> Building frontend (SITE_PLUGINS=$CLOSURE) ..." +( cd "$REPO/frontend" && SITE_PLUGINS="$CLOSURE" npm run build --silent ) + +# Backend: stage core + only the chosen plugin dirs. +echo "==> Staging backend into $OUT ..." +rm -rf "$OUT" +mkdir -p "$OUT/plugins" +rsync -a --exclude '__pycache__' --exclude '*.pyc' "$REPO/shopdb" "$OUT/" +for name in ${CLOSURE//,/ }; do + rsync -a --exclude '__pycache__' --exclude '*.pyc' \ + "$REPO/plugins/$name" "$OUT/plugins/" +done +cp -r "$REPO/frontend/dist" "$OUT/frontend-dist" + +echo "" +echo "Lean site staged at: $OUT" +echo " backend plugins: $(ls "$OUT/plugins" | tr '\n' ' ')" +echo " (a plugin not listed is absent from both the backend tree and the bundle)" diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 9da7249..eb395f7 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -579,6 +579,8 @@ def seed_demo(force): serialnumber=None, subtype_kwargs=None): # create one Asset + its plugin subtype row, idempotent on assetnumber. # returns the Asset, or None when the plugin type is not installed. + if subtype_model is None: + return None # plugin absent on a lean build - skip its demo rows atype = AssetType.query.filter_by(assettype=assettype_name).first() if not atype: return None @@ -602,11 +604,21 @@ def seed_demo(force): made['assets'] += 1 return asset - from plugins.machines.models import Machine - from plugins.computers.models import Computer - from plugins.printers.models import Printer - from plugins.network.models import NetworkDevice - from plugins.measuringtools.models import MeasuringTool + # Guarded so demo seeding still works on a lean build that omits any of + # these plugins (ADR-013 Phase 5): a missing model just skips its section. + def _subtype_model(modulename, classname): + try: + module = __import__(f'plugins.{modulename}.models', + fromlist=[classname]) + return getattr(module, classname) + except ImportError: + return None + + Machine = _subtype_model('machines', 'Machine') + Computer = _subtype_model('computers', 'Computer') + Printer = _subtype_model('printers', 'Printer') + NetworkDevice = _subtype_model('network', 'NetworkDevice') + MeasuringTool = _subtype_model('measuringtools', 'MeasuringTool') machines = [ ('MILL-01', 'Haas VF-2 Mill', 'In Use', 'Cell A', 'Machining'), diff --git a/tests/test_lean_build_guards.py b/tests/test_lean_build_guards.py new file mode 100644 index 0000000..d8ff8d0 --- /dev/null +++ b/tests/test_lean_build_guards.py @@ -0,0 +1,60 @@ +"""Lean-build safety: core must not hard-import a plugin (ADR-013 Phase 5). + +For a per-site build that omits a plugin, any core `from plugins. import` +outside a try/except would crash the whole app when that plugin is absent. This +statically asserts every such import in shopdb/core and shopdb/cli is guarded. +""" + +import ast +import os + +CORE_ROOTS = ['shopdb/core', 'shopdb/cli'] + + +def _imports_plugin(node): + if isinstance(node, ast.ImportFrom): + return bool(node.module and node.module.startswith('plugins.')) + if isinstance(node, ast.Import): + return any(alias.name.startswith('plugins.') for alias in node.names) + return False + + +def _unguarded_in(path): + found = [] + tree = ast.parse(open(path).read()) + + class Visitor(ast.NodeVisitor): + def __init__(self): + self.try_depth = 0 + + def visit_Try(self, node): + self.try_depth += 1 + for child in node.body: + self.visit(child) + self.try_depth -= 1 + for handler in node.handlers: + for child in handler.body: + self.visit(child) + for child in node.orelse + node.finalbody: + self.visit(child) + + def generic_visit(self, node): + if _imports_plugin(node) and self.try_depth == 0: + found.append(f"{path}:{node.lineno}") + super().generic_visit(node) + + Visitor().visit(tree) + return found + + +def test_no_unguarded_plugin_imports_in_core(): + unguarded = [] + for root in CORE_ROOTS: + for dirpath, _, filenames in os.walk(root): + for name in filenames: + if name.endswith('.py'): + unguarded += _unguarded_in(os.path.join(dirpath, name)) + assert unguarded == [], ( + "Core imports a plugin without a try/except ImportError guard; a lean " + "build omitting that plugin would crash. Wrap in try/except:\n " + + "\n ".join(unguarded))