Files
shopdb-flask/scripts/build-site.sh
cproudlock 534f179ee6 Make main the lean product a site receives
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.
2026-09-19 11:24:40 -04:00

117 lines
5.8 KiB
Bash
Executable File

#!/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 <site-profile.json> [output-dir]
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROFILE="${1:?usage: build-site.sh <site-profile.json> [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.
# Kept in scripts/resolve_plugin_closure.py rather than inline, so the closure
# rules have one home and a second builder cannot drift from them.
CLOSURE=$(python3 "$REPO/scripts/resolve_plugin_closure.py" "$PROFILE" "$REPO")
echo "Site profile: $PROFILE"
echo "Plugin closure: $CLOSURE"
# Frontend: TWO builds, because Vite compiles the mount path into the bundle and
# it therefore cannot be chosen at install time from a single build - the page
# would load and then request its assets from the wrong path.
# dist -> own IIS site (method A, the default)
# dist-subpath -> IIS Application under an existing site (method B)
# SUBPATH_ALIAS is fixed per bundle so the three places that must agree - the IIS
# application alias, MOUNT_PATH and this base - cannot drift apart.
SUBPATH_ALIAS="${SUBPATH_ALIAS:-shopdb}"
SUBPATH_TMP="$(mktemp -d)"
trap 'rm -rf "$SUBPATH_TMP"' EXIT
echo "==> Building frontend for /$SUBPATH_ALIAS/ (SITE_PLUGINS=$CLOSURE) ..."
( cd "$REPO/frontend" && SITE_PLUGINS="$CLOSURE" VITE_BASE_PATH="/$SUBPATH_ALIAS/" npm run build --silent )
cp -r "$REPO/frontend/dist" "$SUBPATH_TMP/dist-subpath"
echo "$SUBPATH_ALIAS" > "$SUBPATH_TMP/dist-subpath/.alias"
# Root build LAST, so frontend/dist is left in the state a developer expects.
echo "==> Building frontend for / (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"
# cp (not rsync) so a minimal runner/deploy box without rsync can stage;
# bytecode is pruned afterwards to match the old --exclude filters.
cp -a "$REPO/shopdb" "$OUT/"
for name in ${CLOSURE//,/ }; do
cp -a "$REPO/plugins/$name" "$OUT/plugins/"
done
cp -r "$REPO/frontend/dist" "$OUT/frontend-dist"
# Staged after the rm -rf above, or it would be deleted with everything else.
cp -r "$SUBPATH_TMP/dist-subpath" "$OUT/frontend-dist-subpath"
# Runtime files a deployable tree needs beyond the Python packages. Without these
# the staged tree can be imported but not actually run or migrated, so the
# Windows installer (which consumes this output as its app\ payload) had to
# assemble them separately - and could assemble a tree whose plugin set did not
# match the profile it was built from.
cp "$REPO/wsgi.py" "$REPO/requirements.txt" "$OUT/"
cp -a "$REPO/migrations" "$OUT/"
# deploy/ is deliberately NOT copied. It holds deployment SOURCE - compose files
# and site profiles - and none of it belongs in an application tree that gets
# copied onto a server.
# A CycloneDX SBOM of everything this tree depends on, Python and npm together.
# Staged INTO the tree so it installs onto the server with the application: an
# air-gapped site cannot be scanned remotely, so the only way to answer "are we
# exposed to this CVE, and where" is for the answer to be sitting on the box.
# Generated from requirements.txt and package-lock.json, both already pinned and
# committed, so it is a translation rather than a scan - and deterministic.
echo "==> Generating SBOM ..."
python3 "$REPO/scripts/generate_sbom.py" "$REPO" -o "$OUT/sbom.cdx.json"
# Docs the RUNNING SITE serves or the operator needs on the box. Without
# openapi.json and llms.txt the self-hosted /api/docs page is broken on every
# installed server - which matters most for the sites least able to debug it, and
# for the operators who will point an assistant at their own instance. The
# Windows runbooks ship because an air-gapped server has no other way to reach
# them; docs/ is not otherwise staged, so shipping the whole tree would be noise.
echo "==> Staging docs ..."
mkdir -p "$OUT/docs"
for doc in openapi.json llms.txt api-inventory.json \
INSTALL-WINDOWS.md OPERATE-WINDOWS.md BACKUP-RESTORE.md UPGRADE.md \
FLOOR-MAP.md; do
[ -f "$REPO/docs/$doc" ] && cp -a "$REPO/docs/$doc" "$OUT/docs/"
done
# Never let a missing optional doc fail the build - the loop's last test governs
# the exit status under `set -e`.
true
# Stage the profile INTO the tree. This is what makes the set self-describing:
# `flask plugin apply-profile` at provisioning time reads the same profile the
# tree was staged from, so the installed plugin set and the shipped plugin code
# cannot drift. It is also what `flask plugin prune-schema` effectively keys off
# (via what ends up installed), so a mismatch here would drop the wrong tables.
cp "$PROFILE" "$OUT/site-profile.json"
find "$OUT" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$OUT" -type f -name '*.pyc' -delete
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)"
echo " profile staged as: $OUT/site-profile.json"
echo ""
echo "At provisioning, after 'flask db upgrade' and 'flask plugin upgrade-all':"
echo " flask plugin apply-profile site-profile.json"
echo " flask plugin prune-schema --yes --force # ADR-014; BOTH flags required"