From 99025d10f0eb3b8a3bc77e398bce7481e59f4948 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Sun, 19 Jul 2026 00:11:41 -0400 Subject: [PATCH] ADR-013 Phase 5: lean per-site builds (build-site.sh + guards + CI) --- .github/workflows/ci.yml | 36 +++++++++++++++++ scripts/build-site.sh | 70 +++++++++++++++++++++++++++++++++ shopdb/cli/__init__.py | 22 ++++++++--- tests/test_lean_build_guards.py | 60 ++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 5 deletions(-) create mode 100755 scripts/build-site.sh create mode 100644 tests/test_lean_build_guards.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf5fa5e5..605cb365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,42 @@ jobs: - run: npx vitest run - run: npm run build + lean-build: + # ADR-013 Phase 5: prove a per-site build carries only its chosen plugins. + # Builds a lean site (machines + printers) and asserts an omitted plugin's + # code is absent from the bundle - the delete-a-plugin guarantee in CI. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: cd frontend && npm ci + - name: Build lean site (machines + printers) + run: | + printf '{ "site": "ci-lean", "plugins": ["machines", "printers"] }' \ + > /tmp/lean-profile.json + bash scripts/build-site.sh /tmp/lean-profile.json /tmp/leansite + - name: Assert omitted plugin code is absent, chosen present + run: | + assets=/tmp/leansite/frontend-dist/assets + for code in PartsKiosk ManifestEditor USBLabelBatch KnowledgeBaseDetail; do + if grep -rqoh "$code" "$assets"/*.js; then + echo "FAIL: omitted-plugin code '$code' leaked into the lean bundle" + exit 1 + fi + done + for code in MachineDetail PrinterDetail; do + grep -rqoh "$code" "$assets"/*.js || { + echo "FAIL: chosen-plugin code '$code' missing from the lean bundle" + exit 1; } + done + test -d /tmp/leansite/plugins/machines + test ! -d /tmp/leansite/plugins/printedparts + echo "lean build verified: only chosen plugins present" + migrations-mysql: runs-on: ubuntu-latest services: diff --git a/scripts/build-site.sh b/scripts/build-site.sh new file mode 100755 index 00000000..a4f151ef --- /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 9da72494..eb395f79 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 00000000..d8ff8d02 --- /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))