Compare commits
221 Commits
lab-stage-
...
v0.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
959db2922b | ||
|
|
512d5fafac | ||
|
|
d8fe0a48b2 | ||
|
|
23b1dfff41 | ||
|
|
367bc56a6d | ||
|
|
53c1f6476c | ||
|
|
b44108f70c | ||
|
|
ead5bd8f58 | ||
|
|
705dd771bd | ||
|
|
85ff25462e | ||
|
|
e22322dcc9 | ||
|
|
cb18d170cf | ||
|
|
24266146d8 | ||
|
|
58b460fe3d | ||
|
|
3f320fcc8b | ||
|
|
f8c4246483 | ||
|
|
92a90fcec6 | ||
|
|
89e880afc3 | ||
|
|
fb53161578 | ||
|
|
412c2dc877 | ||
|
|
1c04ff28b9 | ||
|
|
ce521e84a5 | ||
|
|
1d73bd477e | ||
|
|
aeee210cf6 | ||
|
|
95b0b77c13 | ||
|
|
10ee3a3c58 | ||
|
|
97391cdee4 | ||
|
|
a352a21a10 | ||
|
|
f6b621d126 | ||
|
|
21110b86eb | ||
|
|
5f350179b1 | ||
|
|
14fedcee4c | ||
|
|
fe091e751a | ||
|
|
189a474082 | ||
|
|
5f18ca27a1 | ||
|
|
e650eb0220 | ||
|
|
c5797bb339 | ||
|
|
263ae8e3b4 | ||
|
|
8d0afc40d3 | ||
|
|
5321649e02 | ||
|
|
4a8bd138a9 | ||
|
|
f72813ed9c | ||
|
|
8d9d1d3439 | ||
|
|
2073d0dbe8 | ||
|
|
ee083ea80e | ||
|
|
bec138f5ac | ||
|
|
e158cb21f9 | ||
|
|
2c415a1712 | ||
|
|
aea2905de0 | ||
|
|
e58f376643 | ||
|
|
3606d8d696 | ||
|
|
1bf3cb2e1c | ||
|
|
13d831eb90 | ||
|
|
44237b5cbd | ||
|
|
88af7fd9ce | ||
|
|
75f0a57821 | ||
|
|
9c2c21c2cc | ||
|
|
6ebc79a2de | ||
|
|
5c4fdcb15e | ||
| 4d5b6c3c55 | |||
| 0fa5f1e910 | |||
| 0f766cf977 | |||
| 888b15a7dd | |||
| 94d6878a03 | |||
| 11f3d00a04 | |||
|
|
6639afd1f0 | ||
|
|
2528b7e556 | ||
|
|
a7fe2c8353 | ||
|
|
cbf90be7ec | ||
|
|
c80c612922 | ||
|
|
d5635a4306 | ||
|
|
b16f143467 | ||
|
|
71982fc0f1 | ||
|
|
86697a4e7b | ||
|
|
802256f929 | ||
|
|
af6bcd4726 | ||
|
|
01a545f507 | ||
|
|
95df51fddf | ||
|
|
ea6fae91c3 | ||
|
|
3ad26ba010 | ||
|
|
e6533d5205 | ||
|
|
516e33a6c3 | ||
|
|
5f02e488eb | ||
|
|
f0b5465917 | ||
|
|
b507884ad6 | ||
|
|
8575837d8e | ||
|
|
ecf4ef6edd | ||
|
|
346c428409 | ||
|
|
c075658ca6 | ||
|
|
cd79e610e9 | ||
|
|
ad84c9060a | ||
|
|
cb075a278f | ||
|
|
3eaaee0e50 | ||
|
|
0d40780f53 | ||
|
|
1ee9328bf9 | ||
|
|
071d40488b | ||
|
|
bf8842e1d7 | ||
|
|
ced356882c | ||
|
|
8dce622392 | ||
|
|
c5ee164565 | ||
|
|
b63690996a | ||
|
|
b9d0cfac6a | ||
|
|
174c6c0b9a | ||
|
|
7a7c7f37d5 | ||
|
|
b22701a444 | ||
|
|
e237cc2c05 | ||
|
|
3ba808028c | ||
|
|
b539e36096 | ||
|
|
89a1617103 | ||
|
|
9a2d0ccebb | ||
|
|
3a8df166cf | ||
|
|
5712f72ccf | ||
|
|
4c0cc672a2 | ||
|
|
f533af82cd | ||
|
|
67de46dfb9 | ||
|
|
d9080a59ca | ||
|
|
2d675720b7 | ||
|
|
0860aa85c5 | ||
|
|
19876a5640 | ||
|
|
6534590fca | ||
|
|
75386d2f51 | ||
|
|
d0bf37ced7 | ||
|
|
9d65ef103d | ||
|
|
b211e817d5 | ||
|
|
fb188fd302 | ||
|
|
824863a95a | ||
|
|
38c7ec347b | ||
|
|
d141fef203 | ||
|
|
baf6862151 | ||
|
|
e85553e5e7 | ||
|
|
0bb906a37c | ||
|
|
b00ef72581 | ||
|
|
60e2947fc7 | ||
|
|
b05fa33278 | ||
|
|
eb1c46c053 | ||
|
|
d39d34b55f | ||
|
|
0dc4dba265 | ||
|
|
482d6d4bfe | ||
|
|
a50e5b0ec1 | ||
|
|
8496441ddc | ||
|
|
18f027c951 | ||
|
|
108d4d396c | ||
|
|
31139267d1 | ||
|
|
91fa3b9115 | ||
|
|
89b3156ea1 | ||
|
|
a7ff882e21 | ||
|
|
0b247ed96f | ||
|
|
3c830244f8 | ||
|
|
e005d1846a | ||
|
|
49a0206b9f | ||
|
|
212165befd | ||
|
|
a5ba973974 | ||
|
|
5d86bc86b3 | ||
|
|
d009ac94fb | ||
|
|
c386e211df | ||
|
|
42ca8d75c3 | ||
|
|
5861caf78f | ||
|
|
da3cb37be8 | ||
|
|
c6a1e07a6c | ||
|
|
592ff49abe | ||
|
|
ebca0b00b0 | ||
|
|
23dc9fa379 | ||
|
|
af9a3b190b | ||
|
|
37c764ba8d | ||
|
|
296b6e024b | ||
|
|
8a2f984393 | ||
|
|
b669561421 | ||
|
|
e3c4b90afe | ||
|
|
beea6c0c9f | ||
|
|
c59d2dab56 | ||
|
|
dd30ca0c3f | ||
|
|
55a6f1b8d3 | ||
|
|
5b19f3b554 | ||
|
|
86f5f1be68 | ||
|
|
d178726687 | ||
|
|
3ac5ed2580 | ||
|
|
603872ff76 | ||
|
|
6ab1046ef4 | ||
|
|
83141bacb7 | ||
|
|
804c066de4 | ||
|
|
9deb194580 | ||
|
|
314f339ba9 | ||
|
|
1361dc6004 | ||
|
|
efb879d44a | ||
|
|
aba588cc07 | ||
|
|
f77f0a8d90 | ||
|
|
0e194c3237 | ||
|
|
9a60100cb2 | ||
|
|
0c37ef9057 | ||
|
|
f764c5d3e9 | ||
|
|
3a3dff285e | ||
|
|
6160a5142a | ||
|
|
d75e80ce79 | ||
|
|
ee80d684d4 | ||
|
|
bc9159742c | ||
|
|
e9235de8ec | ||
|
|
4f3ea2848a | ||
|
|
5625608bd0 | ||
|
|
0cc205d25e | ||
|
|
bb5308bae0 | ||
|
|
d297c5b75d | ||
|
|
deb6dd2162 | ||
|
|
02ed88c7c5 | ||
|
|
d1357defc4 | ||
|
|
96e48e0f50 | ||
|
|
4dfdb167d5 | ||
|
|
aa4bfcd41c | ||
|
|
26b6b6b32f | ||
|
|
eab225e1e6 | ||
|
|
a8a6baf979 | ||
|
|
427eb0de8c | ||
|
|
df918ed38f | ||
|
|
fc0d48a6a7 | ||
|
|
b68e927ef6 | ||
|
|
6439d1ccd9 | ||
|
|
6ed3da1b64 | ||
|
|
d6a78a72ff | ||
|
|
6dfc8906c4 | ||
|
|
cb367a38f9 | ||
|
|
d1c844d533 | ||
|
|
f5cfac33b4 |
23
.env.example
@@ -49,6 +49,10 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD
|
||||
MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD
|
||||
MYSQL_PORT=3306
|
||||
API_PORT=5001
|
||||
# Air-gapped deploy only (docker-compose.airgap.yml): the loaded image tag,
|
||||
# which MUST match what build-offline-bundle.ps1 -Version produced. Ignored by
|
||||
# the connected build template (docker-compose.yml builds from source).
|
||||
IMAGE_TAG=0.7.0
|
||||
|
||||
# ---- Zabbix integration (optional, for printer supply monitoring) ----
|
||||
ZABBIX_URL=
|
||||
@@ -61,6 +65,25 @@ ZABBIX_TOKEN=
|
||||
# COLLECTOR_API_KEY=
|
||||
# COLLECTOR_API_KEY_COMPUTERS=
|
||||
|
||||
# ---- Trusted plugin publisher keys (ADR-013, optional) ----
|
||||
# Public-key PEM paths (OS path separator: ':' on Linux, ';' on Windows) used
|
||||
# to verify signed plugin artifacts. Delivered with this config, NEVER from the
|
||||
# plugin shelf. Empty on a site that does not adopt marketplace plugins.
|
||||
# PLUGIN_TRUSTED_KEYS=/etc/shopdb/keys/curator.pub
|
||||
#
|
||||
# Enforce signatures: a plugin only loads/migrates if its tree matches a
|
||||
# trusted signature. Default off. Turn on only after stamping plugins
|
||||
# (flask plugin stamp-bundled) and pinning keys above.
|
||||
# PLUGIN_REQUIRE_SIGNED=false
|
||||
#
|
||||
# Dev-only: directories whose UNSIGNED plugins are trusted, honored ONLY under
|
||||
# DEBUG/TESTING (external-repo/symlink dev). Production ignores this.
|
||||
# PLUGIN_DEV_TRUST_DIRS=/home/dev/my-plugin-repo
|
||||
#
|
||||
# Read-only folder the app pulls plugin artifacts from (a SharePoint-synced or
|
||||
# copied shelf). The app reads this folder; it never speaks any network.
|
||||
# PLUGIN_SHELF_DIR=/srv/shopdb/plugin-shelf
|
||||
|
||||
# ---- Employee directory database (optional, read-only) ----
|
||||
# Separate HR/employee lookup DB consumed by the notifications plugin and the
|
||||
# public shopfloor kiosks. Leave unset if the feature is not used; there is no
|
||||
|
||||
5
.gitattributes
vendored
@@ -1 +1,6 @@
|
||||
.env filter=git-crypt diff=git-crypt
|
||||
|
||||
# Shell scripts must stay LF so Git Bash on Windows can run them
|
||||
*.sh text eol=lf
|
||||
scripts/check-naming-and-style.sh text eol=lf
|
||||
.githooks/pre-commit text eol=lf
|
||||
|
||||
5
.githooks/pre-commit
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Committed pre-commit hook. Activate per clone with:
|
||||
# git config core.hooksPath .githooks
|
||||
# Runs the naming/style gate; blocks the commit on failure.
|
||||
exec bash "$(git rev-parse --show-toplevel)/scripts/check-naming-and-style.sh"
|
||||
87
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
# Copilot instructions for shopdb-flask
|
||||
|
||||
Follow these when suggesting code. They are enforced by a naming/style hook and
|
||||
by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
|
||||
`CONTRIBUTING.md` is the full authority; this is the short version.
|
||||
|
||||
## Naming (LOCKED - the hook rejects violations)
|
||||
|
||||
- **DB tables**: lowercase, concatenated, plural. No underscores, no dashes.
|
||||
`machines`, `networkdevices`, `businessunits` - NOT `machine_types`, `BusinessUnits`.
|
||||
- **DB columns**: lowercase, concatenated, singular. No underscores.
|
||||
`machineid`, `lastzabbixsync`, `isactive` - NOT `machine_id`, `last_zabbix_sync`.
|
||||
- **Foreign keys**: referenced table (singular) + `id`: `locationid`, `vendorid`.
|
||||
- **Booleans**: `is`/`has` prefix: `isactive`, `isshopfloor`.
|
||||
- **Index names**: `idx_<table>_<column>` (underscores allowed here only).
|
||||
|
||||
### Python
|
||||
|
||||
- A variable, attribute, function, or dict key that holds a DB value MUST match
|
||||
the column name exactly - do NOT convert to snake_case.
|
||||
Column `machineid` -> `Machine.machineid`, `{"machineid": 1}`, local `machineid`.
|
||||
- Pure code that does NOT mirror a DB field uses normal snake_case (PEP 8):
|
||||
`loop_count`, `current_user`, `validate_input()`.
|
||||
- Classes: PascalCase, spelled out (`NetworkDevice`, `AssetType`).
|
||||
|
||||
### JavaScript / Vue
|
||||
|
||||
- A JS variable holding an API field value matches the API key exactly - do NOT
|
||||
camelCase it. API `{"machineid": 1}` -> `response.machineid`, never `machineId`.
|
||||
- Components: PascalCase (`AssetDetail.vue`). CSS classes: lowercase-with-dashes.
|
||||
|
||||
### API
|
||||
|
||||
- Endpoints: lowercase plural nouns, no underscores/dashes: `/api/networkdevices`.
|
||||
- Query params + response keys match column names: `?locationid=5`,
|
||||
`{"machineid": 1, "lastzabbixsync": "..."}`.
|
||||
|
||||
### Allowed acronyms only
|
||||
|
||||
Universal: id url api http https json jwt sql os ip dns csv pdf cors ttl uuid
|
||||
html css orm. Domain: cmm cnc pc usb vnc winrm ssh ssl tls tcp udp smtp ldap
|
||||
vlan sso dnc focas clm mtconnect. Anything else: spell it out.
|
||||
|
||||
### Banned shorthand
|
||||
|
||||
Never use `cfg ctx mgr req res env util helper` or `db` as a standalone variable
|
||||
name. Spell out: `config context manager request response environment utilities`.
|
||||
`_bp` is fine only as a suffix with a meaningful prefix (`printers_bp`).
|
||||
|
||||
## Style (ASCII only)
|
||||
|
||||
- NO emojis anywhere - code, comments, strings, UI.
|
||||
- NO em-dashes, en-dashes, Unicode arrows, or smart quotes. Plain ASCII only.
|
||||
- Comments default to NONE. Add one only when the WHY is non-obvious; keep inline
|
||||
`#`/`//` comments terse. Docstrings stay normal English.
|
||||
- Dark theme is the default; keep UI functional and professional.
|
||||
|
||||
## Architecture (do not violate)
|
||||
|
||||
- **Plugins are the product.** Plugin code lives in `plugins/<name>/{models,api,services,schemas}/`
|
||||
with a `manifest.json` (single source of truth: name, version, dependencies,
|
||||
api_prefix) and a `BasePlugin` subclass in `plugin.py`.
|
||||
- **Plugins never import core internals.** Use the contract surface `shopdb.api`
|
||||
(e.g. `from shopdb.api import db, Asset, success_response`). Adding to that
|
||||
surface is a contract-version bump + a `docs/PLUGIN-HOOKS` update in the same PR.
|
||||
- **Migrations, never `db.create_all()`.** The core Alembic chain is in
|
||||
`migrations/versions/`; each plugin owns its own chain under
|
||||
`plugins/<name>/migrations/`. New schema = a new migration with an idempotent
|
||||
guard and a real downgrade. Migrations must run clean on strict MySQL 8.
|
||||
- **Asset model is the platform contract.** Physical things are an `Asset` plus a
|
||||
plugin subtype row linked by `assetid` (FK, `ON DELETE CASCADE`). Consumables
|
||||
with quantities are standalone tables, not assets.
|
||||
- **Ledger pattern**: a cached `quantityonhand` moves in the SAME commit as the
|
||||
signed transaction row it reflects.
|
||||
|
||||
## Before you finish a change
|
||||
|
||||
Run the three gates (CI runs the same):
|
||||
|
||||
```
|
||||
python -m pytest tests/ -q
|
||||
cd frontend && npx vitest run && npm run build && cd ..
|
||||
bash scripts/check-naming-and-style.sh
|
||||
```
|
||||
|
||||
Commits: short present-tense subject, body says WHY. No AI/tool attribution in
|
||||
commit messages, code comments, or docs.
|
||||
106
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
# CI for shopdb-flask on GitHub Actions.
|
||||
#
|
||||
# Mirrors the internal CI pipeline. Four jobs on push + pull_request:
|
||||
# backend - pytest (SQLite via TestingConfig, no DB service needed)
|
||||
# naming - the CONTRIBUTING.md naming/style gate
|
||||
# frontend - vitest + Vue build
|
||||
# migrations-mysql - the REAL multi-site deploy path: fresh flask db upgrade
|
||||
# + every plugin's chain on utf8mb4 MySQL 8, idempotent on
|
||||
# a second run. The pytest suite only exercises SQLite
|
||||
# create_all(), so this is what catches an Alembic
|
||||
# regression on MySQL before it ships.
|
||||
|
||||
name: CI
|
||||
|
||||
# Jobs run on the org's self-hosted "arc-runner-set" (enterprise
|
||||
# ge-aerospace-runner-group, Linux). GitHub-hosted runners are blocked by the
|
||||
# org IP allow list (hosted Azure runner IPs are not allow-listed -> checkout
|
||||
# 403), so ubuntu-latest cannot be used here. arc-runner-set checks out from an
|
||||
# internal allow-listed IP and, being Linux, still supports service containers.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: arc-runner-set
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: pip
|
||||
- run: pip install -r requirements-dev.txt
|
||||
- run: python -m pytest -q
|
||||
|
||||
naming:
|
||||
runs-on: arc-runner-set
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bash scripts/check-naming-and-style.sh
|
||||
|
||||
frontend:
|
||||
runs-on: arc-runner-set
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
- 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: arc-runner-set
|
||||
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
|
||||
# Core frontends (no manifest, e.g. applications) must ship in EVERY
|
||||
# build regardless of SITE_PLUGINS, or a lean site loses a core page.
|
||||
grep -rqoh "ApplicationsList" "$assets"/*.js || {
|
||||
echo "FAIL: core page 'ApplicationsList' missing from the lean bundle"
|
||||
exit 1; }
|
||||
test -d /tmp/leansite/plugins/machines
|
||||
test ! -d /tmp/leansite/plugins/printedparts
|
||||
echo "lean build verified: only chosen plugins present"
|
||||
|
||||
# NOTE: the MySQL-8 migration/seed job (fresh `flask db upgrade` + every
|
||||
# plugin chain + strict-mode seeders on a real MySQL 8) runs on the internal
|
||||
# CI server, which supports service containers. The org's arc-runner-set is
|
||||
# Kubernetes/ARC without docker-in-docker, so GitHub Actions service
|
||||
# containers ("services: mysql") are unavailable here ("Job Container is
|
||||
# required"). That coverage stays on the internal CI rather than being
|
||||
# duplicated on GitHub.
|
||||
9
.gitignore
vendored
@@ -28,7 +28,11 @@ env/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
.vscode/*
|
||||
# Share the team's launch/tasks/extensions; keep personal settings out.
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -76,3 +80,6 @@ secrets.yml
|
||||
*_secrets
|
||||
credentials.json
|
||||
scripts/site_imports/wjf/idmap.json
|
||||
|
||||
# work-PC publication clone: bundle drop folder for the transfer pipeline
|
||||
_transfer/
|
||||
|
||||
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
}
|
||||
30
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Flask API (:5001)",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"FLASK_APP": "shopdb",
|
||||
"FLASK_ENV": "development",
|
||||
"FLASK_DEBUG": "1"
|
||||
},
|
||||
"args": ["run", "--port", "5001", "--no-reload"],
|
||||
"jinja": true,
|
||||
"justMyCode": false,
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Pytest (current file)",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "pytest",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"args": ["${file}", "-v"],
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
46
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Backend: flask run (:5001)",
|
||||
"type": "shell",
|
||||
"command": "${workspaceFolder}/venv/bin/flask run --port 5001",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": { "FLASK_APP": "shopdb" }
|
||||
},
|
||||
"windows": {
|
||||
"command": "${workspaceFolder}\\venv\\Scripts\\flask.exe run --port 5001"
|
||||
},
|
||||
"isBackground": true,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Frontend: npm run dev (:5173)",
|
||||
"type": "shell",
|
||||
"command": "npm run dev",
|
||||
"options": { "cwd": "${workspaceFolder}/frontend" },
|
||||
"isBackground": true,
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Dev site (backend + frontend)",
|
||||
"dependsOn": [
|
||||
"Backend: flask run (:5001)",
|
||||
"Frontend: npm run dev (:5173)"
|
||||
],
|
||||
"dependsOrder": "parallel",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Check: naming + tests + build",
|
||||
"type": "shell",
|
||||
"command": "bash scripts/check-naming-and-style.sh && venv/bin/python -m pytest tests/ -q && cd frontend && npx vitest run && npm run build",
|
||||
"windows": {
|
||||
"command": "bash scripts/check-naming-and-style.sh && venv\\Scripts\\python -m pytest tests/ -q && cd frontend && npx vitest run && npm run build"
|
||||
},
|
||||
"options": { "cwd": "${workspaceFolder}" },
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
190
CHANGELOG.md
@@ -10,6 +10,181 @@ ADR-007 and ADR-002.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.1] - 2026-08-05
|
||||
|
||||
Everything here shipped after v0.8.0 was tagged the same morning, driven by two
|
||||
sites putting real data in for the first time. Most of it is defects that only
|
||||
appear when somebody who did not build the software tries to use it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A blank optional code could be saved once and never again. A unique nullable
|
||||
column accepts any number of NULLs but exactly one empty string, so the second
|
||||
business unit with no code collided with the first and returned 500 from a
|
||||
field the form correctly showed as optional. Blank now stores as NULL, which
|
||||
covers every unique nullable column - asset numbers, hostnames, item codes,
|
||||
subnet names, gage-lab tags - not just the one that was reported. A genuine
|
||||
duplicate answers 409 with a readable message instead of a bare 500.
|
||||
- A lobby display with exactly two slides never changed between them. The
|
||||
slideshow was started twice, and two timer chains advancing one slide each
|
||||
landed back on the first every cycle. With three or more slides it advanced by
|
||||
two and merely skipped one, which is why it went unnoticed. The per-slide
|
||||
duration the feed has always sent was also ignored in favour of a hardcoded
|
||||
ten seconds.
|
||||
- Clicking slightly outside a modal discarded a part-filled form, in 35 modals
|
||||
across 30 files. Confirmation dialogs still dismiss that way, since they hold
|
||||
nothing to lose.
|
||||
- Selecting a filter while past page one returned an empty list, because the
|
||||
filter asked for page five of a result set that now had one page.
|
||||
- Model photos could not be saved: an uploaded photo sets the field to an
|
||||
application path, and the input was type="url", which demands an absolute
|
||||
address. The upload button was also hidden when creating a model.
|
||||
- An empty bordered box appeared after Notes on the network device form.
|
||||
|
||||
### Added
|
||||
|
||||
- The equipment catalog travels. `flask seed catalog` loads 53 vendors, 128
|
||||
models with photos, 146 printer supply part numbers and every type vocabulary,
|
||||
so a new site can start adding printers immediately instead of retyping a
|
||||
catalog another site spent a year building. Idempotent and additive; catalog
|
||||
only, with nothing site-specific. Offered by the installer as a tick-box and
|
||||
by the operator console.
|
||||
- `shopdb-admin.ps1 repair` completes an interrupted provisioning, and `check`
|
||||
now says when a server is not fully provisioned instead of leaving it to be
|
||||
inferred from 500s on unrelated pages.
|
||||
- Network devices can be linked to a catalog model, and their map position is
|
||||
picked on the floor plan rather than typed as coordinates.
|
||||
- An asset's vendor and type can be derived from its catalog model, by exact
|
||||
name match only, with a reviewable backfill script.
|
||||
|
||||
### Changed
|
||||
|
||||
- Labels say whose type they mean: Machine Type, PC Type, Printer Type, Device
|
||||
Type, next to the catalog's Model type.
|
||||
- The 3D parts kiosk label prefix is a setting rather than one site's initials
|
||||
hardcoded in the source.
|
||||
- The operator console menu is grouped by what each action touches.
|
||||
|
||||
## [0.8.0] - 2026-08-05
|
||||
|
||||
First release to carry the Windows installer. Everything below shipped after
|
||||
v0.7.0 was tagged, and a complete install was exercised end to end on Windows
|
||||
Server 2019 before this release was cut.
|
||||
|
||||
### Fixed - installer, from a real Server 2019 install
|
||||
|
||||
- `packaging` was imported by the plugin loader but declared nowhere. It reached
|
||||
development and CI only as a dependency of pytest, so the whole suite passed
|
||||
while a virtual environment built from `requirements.txt` alone - which is
|
||||
exactly what the installer builds - could not import the application at all.
|
||||
`tests/test_runtime_dependencies.py` now fails on any runtime import that is
|
||||
not a declared dependency.
|
||||
- IIS returned 500.52 before the application was ever launched. `web.config`
|
||||
declared `<allowedServerVariables>` for the X-Forwarded-For rule, and that
|
||||
section ships `overrideModeDefault="Deny"`, so the whole file was rejected.
|
||||
The installer now permits the single variable at server level instead of
|
||||
unlocking the section for every site on the machine, and repairs a
|
||||
`web.config` an earlier build had made unusable.
|
||||
- The config unlock ran before the application it unlocks existed, so the scoped
|
||||
form could never succeed on a first install and every install silently fell
|
||||
back to granting handler delegation server-wide.
|
||||
- The stage 5 smoke test discarded the status code and error page it had already
|
||||
received, reporting "site did not return 200" for a fault IIS had named. It
|
||||
now records both, plus the tail of the application log.
|
||||
- Database dumps were readable by every authenticated user: a directory created
|
||||
under ProgramData inherits `Users:RX`, and the owner-only ACL was applied only
|
||||
when the installer itself created it.
|
||||
- The uninstaller ran the 32-bit PowerShell, which cannot see IIS, so the site,
|
||||
pool and application survived a "successful" uninstall pointing at a deleted
|
||||
directory.
|
||||
- Uninstall matched applications by alias alone and would remove an unrelated
|
||||
application of the same name under another site.
|
||||
- Wizard input reached a command line unchecked: unvalidated ports, a drive-root
|
||||
path that escaped its own quote, and a password written as ANSI but read back
|
||||
as UTF-8, which reported a correct non-ASCII password as wrong.
|
||||
- Plugin deregistration could never succeed - it omitted `--yes` against a
|
||||
command that prompts - while the plugin directory was deleted regardless.
|
||||
- Preflight rows were drawn past the bottom of the panel and silently vanished;
|
||||
the failures loop had no cap at all.
|
||||
|
||||
### Changed - installer behaviour
|
||||
|
||||
- "Is this a re-run of my install?" is answered from a durable install record
|
||||
rather than inferred from the state of the machine. Nothing on a server says
|
||||
who created its database tables, so a retry after a failed first install was
|
||||
taken for an upgrade of a working system: it demanded a mandatory backup of a
|
||||
database its own failed attempt had written, then refused to prune tables it
|
||||
had created minutes earlier. During unfinished first provisioning the backup
|
||||
is advisory and prune may force; on an established install both are unchanged.
|
||||
- The installer offers every bundled plugin, so one build serves any site
|
||||
instead of one build per plugin profile.
|
||||
|
||||
### Added - operator documentation and diagnostics
|
||||
|
||||
- `docs/UPDATES-WINDOWS.md` - what operators should expect from future updates,
|
||||
bug fixes and security releases, including downtime, what is preserved, and
|
||||
the effect on other sites sharing the same IIS server.
|
||||
- `docs/RELEASING-WINDOWS.md` - how to build and release, and the two known gaps.
|
||||
- `deploy/windows/shopdb-diagnose.py` - collects what IIS answers, the config
|
||||
lock state, the application logs and the ACLs in one pass, scrubbing secrets
|
||||
before writing anything.
|
||||
|
||||
### Added - the installer itself
|
||||
|
||||
- Air-gapped Windows installer (`deploy/windows/installer/`). One `.exe` per
|
||||
site, built from that site's plugin profile, containing Python, the wheels,
|
||||
the SPA, the IIS modules and optionally MySQL. Operator docs:
|
||||
`docs/INSTALL-WINDOWS.md` and `docs/OPERATE-WINDOWS.md`, both shipped onto the
|
||||
server. `docs/INSTALL-WINDOWS-IIS.md` and `docs/DEPLOY-WINDOWS-IIS.md` are now
|
||||
reference-only, for hand-built servers.
|
||||
- `bundle-lock.json`: an exact sha256 + size record of the installer's
|
||||
third-party payload (wheels, Python installer, IIS MSIs). Verified as set
|
||||
equality by both builders and again on the server before anything runs; there
|
||||
is no install-time override. Regenerate with `refresh-bundle-lock.ps1`.
|
||||
- CycloneDX SBOM (`sbom.cdx.json`) generated on every build from
|
||||
`requirements.txt` and `package-lock.json`, covering both ecosystems, staged
|
||||
into the application tree so an air-gapped server can answer "do we carry this
|
||||
component" locally: `shopdb-admin.ps1 verify -Path <name>`.
|
||||
- `shopdb-admin.ps1`, the operator console: status, start/stop/restart, logs,
|
||||
health check, backup, plugins, verify. `check -Json` emits secret-free
|
||||
structured state for pasting into a support ticket or an AI assistant.
|
||||
- `build-installer.ps1`, the whole build natively on Windows, so a work PC needs
|
||||
no Bash. Shares `scripts/resolve_plugin_closure.py` with `build-site.sh`.
|
||||
- URL Rewrite is bundled and the wizard asks where client IPs come from
|
||||
(`-ClientIpSource direct|proxy`). Without the rule IIS sends no
|
||||
`X-Forwarded-For` at all and every client reads as 127.0.0.1.
|
||||
|
||||
### Changed
|
||||
|
||||
- `requirements.txt` and `requirements-dev.txt` are compiled `--universal
|
||||
--generate-hashes`. Installs run under `pip --require-hashes`, so a wheel whose
|
||||
sha256 is not listed is refused. The dev lockfile is constrained to the
|
||||
production pins; the two had drifted.
|
||||
- Python 3.14 across the Dockerfile, CI, `web.config` and the docs, which
|
||||
previously declared four different versions.
|
||||
- The naming/style gate covers Markdown, JSON and YAML, not just code.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `plugins/employees` imported `shopdb.core.models` directly, failing the
|
||||
contract-surface test on `main` since the dashboard employee-name resolver
|
||||
landed.
|
||||
- `scripts/build-site.sh` copied all of `deploy/` into its own output directory,
|
||||
which recursed when the output was staged inside it - the documented Windows
|
||||
build could not complete.
|
||||
- Plugin baseline migrations inherited the MySQL server's default charset: the
|
||||
utf8mb4 compiler hook lived in `migrations/env.py` and so covered the core
|
||||
chain only. It is now `shopdb/utils/mysql_charset.py`, imported by both.
|
||||
`flask db-utils preflight` reports the database's default charset.
|
||||
|
||||
### Added
|
||||
|
||||
- Configurable site timezone: a `site_timezone` site setting (default
|
||||
`America/New_York`, public-readable), editable in Settings > Site >
|
||||
Localization. Notification start/end times are entered and displayed in this
|
||||
zone, and daily-reset notification expiry is computed in it. A shared
|
||||
`frontend/src/utils/datetime.js` (Intl-based, DST-safe) does the conversion.
|
||||
|
||||
### Changed
|
||||
|
||||
- Asset detail pages (machines, PCs, printers, network devices, measuring
|
||||
@@ -24,6 +199,15 @@ ADR-007 and ADR-002.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Notification start/end times were off by the timezone offset (a 2:34 PM entry
|
||||
displayed as 6:34 PM). Times are now stored UTC and shown/entered in the site
|
||||
timezone; the calendar keys all-day events off the site-local day.
|
||||
- Kiosk displays showed a white screen on login: a legacy 32-bit kiosk
|
||||
installer's autostart kept relaunching Edge at a now-dead URL. The install's
|
||||
HKLM Run value was WOW64-redirected into `SOFTWARE\Wow6432Node\...\Run` and
|
||||
survived earlier cleanup. The `gea-shopfloor-display` dispatcher now purges
|
||||
the legacy autostarts every enforce cycle across both registry views, all
|
||||
user hives, Run/RunOnce/policy-Run, and every Startup folder.
|
||||
- List pages keep the current page (and search term) in the URL query, so
|
||||
paging to page 9, opening an item, and hitting browser Back returns to page 9
|
||||
instead of resetting to page 1. Applies to all 18 list views via a shared
|
||||
@@ -36,7 +220,7 @@ ADR-007 and ADR-002.
|
||||
- Shopfloor kiosk header text is readable (light on the dark navy header).
|
||||
|
||||
|
||||
### Added
|
||||
### Added (continued)
|
||||
|
||||
- Collector-driven PC -> printer relationships. The computers collector schema
|
||||
gained optional `defaultprinter` (string) and `printers` (array of strings)
|
||||
@@ -474,7 +658,9 @@ letting other GE Aerospace sites stand up their own self-hosted instance
|
||||
integration that passed the key as a query parameter. See
|
||||
`docs/COLLECTOR-INTEGRATION.md`.
|
||||
|
||||
[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.7.0...HEAD
|
||||
[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.1...HEAD
|
||||
[0.8.1]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.6.0...v0.7.0
|
||||
[0.6.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.5.0...v0.6.0
|
||||
[0.5.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/releases/tag/v0.5.0
|
||||
|
||||
17
CLAUDE.md
@@ -22,6 +22,8 @@ Architecture decisions live in `docs/adr/`. Read those before making schema or c
|
||||
- ADR-010: Frontend plugin hook contract - ACCEPTED
|
||||
- ADR-011: Machines rename + modeltypes retyping - ACCEPTED
|
||||
- ADR-012: GE-Enforce manifest ownership in shopdb - ACCEPTED
|
||||
- ADR-013: Plugin catalog, curated shelf, and lean per-site builds - PROPOSED
|
||||
- ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, prune not-installed plugin tables) - ACCEPTED
|
||||
|
||||
## Coding convention
|
||||
|
||||
@@ -42,11 +44,14 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
|
||||
|
||||
### Active state
|
||||
|
||||
- 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
|
||||
- `__contract_version__` at 0.11.0 (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
|
||||
- 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
|
||||
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
|
||||
- Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 15 stages, validated end-to-end including on a Windows + MySQL 8 VM).
|
||||
- 1159 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a lean-build job + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
|
||||
- GE-Enforce HTTPS cutover: the displays/kiosks cohort now fetches manifest + inline payloads entirely over HTTPS (share-less); the `gea-shopfloor-display` scope is authored in code (`plugins/geenforce/seed_display_scope.py`) and published via `seed_display_scope(publish=True)`. Other fleet PC types still enforce from the SMB share and only report. See `docs/geenforce-api-cutover.md`.
|
||||
- `__contract_version__` at 0.15.0 (0.12.0 mailer, 0.13.0 User/Role, 0.14.0 send_webhook, 0.15.0 authorized_service_token) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
|
||||
- 13 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printedparts, printers, slides, usb, warranty
|
||||
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d26_settings_description_text` (33 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
|
||||
- Lean per-site builds (ADR-013 + ADR-014): `scripts/build-site.sh` (backend) + `SITE_PLUGINS` via `scripts/stage-frontend.mjs` (frontend) ship only chosen plugins; `flask plugin prune-schema` drops non-installed plugins' tables at provisioning. Sidebar nav / settings / Displays all gate on staged routes. Manifest-less `plugins/<name>/frontend/` dirs (e.g. `applications`) are core and always ship.
|
||||
- Windows sites install from a single air-gapped installer `.exe` built per site from its plugin profile (`deploy/windows/installer/`, built by `build-installer.sh` or `build-installer.ps1`). Operator docs: `docs/INSTALL-WINDOWS.md` + `docs/OPERATE-WINDOWS.md` - these are canonical for a NEW site. `docs/INSTALL-WINDOWS-IIS.md` and `docs/DEPLOY-WINDOWS-IIS.md` are the MANUAL procedure, kept for hand-built servers only. The installer verifies its third-party payload against `bundle-lock.json` and installs wheels with `pip --require-hashes`; every build stages a CycloneDX SBOM (`sbom.cdx.json`) onto the server.
|
||||
- Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 16 stages, validated end-to-end including on a Windows + MySQL 8 VM).
|
||||
- API is migration-complete: an admin PAT + docs/IMPORT-API.md let a script import the whole legacy DB (X-Import-Mode preserves timestamps).
|
||||
- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0
|
||||
|
||||
@@ -135,4 +140,4 @@ Each plugin must have:
|
||||
- `migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - LocationOnly equipment type fix
|
||||
- `migrations/PRODUCTION_MIGRATION_GUIDE.md` - production import methods
|
||||
- `migrations/rename_underscore_columns.sql` - one-time rename of snake_case columns to lowercase concatenated (per CONTRIBUTING.md)
|
||||
- `migrations/versions/` - the core Alembic chain (baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes`). Run `flask db upgrade` to apply.
|
||||
- `migrations/versions/` - the core Alembic chain (baseline `68b3947ae14f` -> head `7d26_settings_description_text`). Run `flask db upgrade` to apply.
|
||||
|
||||
12
Dockerfile
@@ -2,10 +2,12 @@
|
||||
#
|
||||
# One image, one site. Per ADR-004, each adopting facility runs its own
|
||||
# stack with its own DB, secrets, and enabled-plugin list. This image
|
||||
# bundles all eleven core plugins (computers, employees, knowledgebase,
|
||||
# machines, measuringtools, network, notifications, printers, slides, usb,
|
||||
# warranty);
|
||||
# install them at runtime with `flask plugin install <name>`.
|
||||
# bundles all 13 catalog plugins (computers, employees, geenforce,
|
||||
# knowledgebase, machines, measuringtools, network, notifications,
|
||||
# printedparts, printers, slides, usb, warranty); a site installs + enables
|
||||
# the ones it wants with `flask plugin install <name>` (or, declaratively,
|
||||
# `flask plugin apply-profile <profile.json>`). Per ADR-013 a future lean
|
||||
# build stages only the chosen plugin directories into this image.
|
||||
#
|
||||
# The frontend is built in a first stage and its dist output is copied into
|
||||
# the final image so Flask can serve the SPA (register_frontend_routes in
|
||||
@@ -30,7 +32,7 @@ RUN npm run build
|
||||
# Output lands in /build/dist (Vite default), copied into the final stage below.
|
||||
|
||||
# ---- Stage 2: Python application image ----
|
||||
FROM python:3.12-slim AS base
|
||||
FROM python:3.14-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
||||
67
README.md
@@ -20,7 +20,7 @@ ShopDB tracks and manages:
|
||||
## Tech Stack
|
||||
|
||||
**Backend:**
|
||||
- Python 3.12 with Flask
|
||||
- Python 3.14 with Flask
|
||||
- SQLAlchemy ORM
|
||||
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
|
||||
- JWT authentication
|
||||
@@ -36,26 +36,26 @@ ShopDB tracks and manages:
|
||||
|
||||
```
|
||||
shopdb-flask/
|
||||
├── shopdb/ # Flask application
|
||||
│ ├── core/
|
||||
│ │ ├── api/ # REST API endpoints
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Validation schemas
|
||||
│ │ └── services/ # Business logic
|
||||
│ ├── plugins/ # Plugin system
|
||||
│ └── utils/ # Shared utilities
|
||||
├── frontend/ # Vue 3 application
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ ├── router/ # Route definitions
|
||||
│ │ └── stores/ # Pinia stores
|
||||
│ └── public/ # Static assets
|
||||
├── plugins/ # Bundled and external plugins
|
||||
├── migrations/ # Alembic migration chain (flask db upgrade)
|
||||
├── scripts/ # Import and utility scripts
|
||||
└── tests/ # Test suite
|
||||
+-- shopdb/ # Flask application
|
||||
| +-- core/
|
||||
| | +-- api/ # REST API endpoints
|
||||
| | +-- models/ # SQLAlchemy models
|
||||
| | +-- schemas/ # Validation schemas
|
||||
| | `-- services/ # Business logic
|
||||
| +-- plugins/ # Plugin system
|
||||
| `-- utils/ # Shared utilities
|
||||
+-- frontend/ # Vue 3 application
|
||||
| +-- src/
|
||||
| | +-- api/ # API client
|
||||
| | +-- components/ # Reusable components
|
||||
| | +-- views/ # Page components
|
||||
| | +-- router/ # Route definitions
|
||||
| | `-- stores/ # Pinia stores
|
||||
| `-- public/ # Static assets
|
||||
+-- plugins/ # Bundled and external plugins
|
||||
+-- migrations/ # Alembic migration chain (flask db upgrade)
|
||||
+-- scripts/ # Import and utility scripts
|
||||
`-- tests/ # Test suite
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
@@ -98,7 +98,7 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12
|
||||
- Python 3.14
|
||||
- Node.js 18+
|
||||
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md)
|
||||
|
||||
@@ -148,11 +148,13 @@ pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# Edit .env with your database credentials and secrets.
|
||||
|
||||
export FLASK_APP=shopdb
|
||||
flask db upgrade
|
||||
flask plugin upgrade-all # per-plugin schema (ADR-008)
|
||||
flask seed permissions
|
||||
flask seed settings
|
||||
flask seed reference-data
|
||||
flask run
|
||||
flask run --port 5001 # MUST be 5001 - the frontend dev server proxies here
|
||||
|
||||
# Frontend (separate terminal)
|
||||
cd frontend
|
||||
@@ -162,15 +164,25 @@ npm run build # production build into frontend/dist (served by Flask)
|
||||
```
|
||||
|
||||
Complete first-run setup at `/setup`, or run `flask seed admin` for a headless
|
||||
admin account.
|
||||
admin account. The repo ships VS Code config in `.vscode/` (F5 debugs the
|
||||
backend; a "Dev site" task runs both servers). A fuller day-one walkthrough,
|
||||
including VS Code and troubleshooting, is the DEVELOPMENT-SETUP page in the
|
||||
project wiki.
|
||||
|
||||
To import a site's legacy data, use the HTTP import surface: an admin API
|
||||
token plus [docs/IMPORT-API.md](docs/IMPORT-API.md) drive the whole migration
|
||||
through documented endpoints (`X-Import-Mode` preserves original timestamps).
|
||||
`scripts/site_imports/wjf/` is the West Jefferson reference loader.
|
||||
|
||||
For the full per-site deployment runbook see [docs/DEPLOY.md](docs/DEPLOY.md);
|
||||
for every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
|
||||
### Which deployment route
|
||||
|
||||
| Target | Use |
|
||||
|---|---|
|
||||
| **Windows Server + IIS** (how sister sites run) | **[docs/INSTALL-WINDOWS.md](docs/INSTALL-WINDOWS.md)** - one installer `.exe`, offline, no manual IIS work. Day 2: [docs/OPERATE-WINDOWS.md](docs/OPERATE-WINDOWS.md) |
|
||||
| Linux / Docker, air-gapped | [docs/DEPLOY-AIRGAP.md](docs/DEPLOY-AIRGAP.md) |
|
||||
| Linux / Docker, connected | [docs/DEPLOY.md](docs/DEPLOY.md) |
|
||||
|
||||
For every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -212,7 +224,7 @@ Query parameters for list endpoints:
|
||||
|
||||
ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for plugin development guidelines.
|
||||
|
||||
The image bundles twelve plugins; only the ones a site installs are loaded:
|
||||
The image bundles thirteen plugins; only the ones a site installs are loaded:
|
||||
|
||||
- **computers** - Shopfloor PCs and workstations, collector fleet ingest
|
||||
- **employees** - Employee directory
|
||||
@@ -222,6 +234,7 @@ The image bundles twelve plugins; only the ones a site installs are loaded:
|
||||
- **knowledgebase** - Documentation and troubleshooting guides
|
||||
- **network** - Network devices and subnets
|
||||
- **notifications** - Shopfloor notifications and recognition feed
|
||||
- **printedparts** - 3D-printed part catalogue, kiosk issue tracking and stock alerts
|
||||
- **printers** - Extended printer management with Zabbix integration
|
||||
- **slides** - TV/kiosk slideshows
|
||||
- **usb** - CMMC USB check-in/out tracking
|
||||
|
||||
20
deploy/site-profile-universal.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"site": "universal",
|
||||
"plugins": [
|
||||
"computers",
|
||||
"employees",
|
||||
"geenforce",
|
||||
"knowledgebase",
|
||||
"machines",
|
||||
"measuringtools",
|
||||
"network",
|
||||
"notifications",
|
||||
"printedparts",
|
||||
"printers",
|
||||
"slides",
|
||||
"usb",
|
||||
"warranty"
|
||||
],
|
||||
"locked": [],
|
||||
"_comment": "The profile the released Windows installer is built from. Every bundled plugin that carries a manifest, so ONE exe serves any site: the wizard offers all of them and the operator ticks what that site uses. Plugins left unticked are never installed, and 'flask plugin prune-schema' drops their tables at provisioning (ADR-014). 'applications' is deliberately absent - it is manifest-less core and always ships. Build with: deploy/windows/installer/build-installer.sh deploy/site-profile-universal.json <repo>. Use site-profile.example.json instead only when a site genuinely needs a lean build; see ADR-013."
|
||||
}
|
||||
11
deploy/site-profile.example.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"site": "example-site",
|
||||
"plugins": [
|
||||
"machines",
|
||||
"computers",
|
||||
"printers",
|
||||
"network"
|
||||
],
|
||||
"locked": [],
|
||||
"_comment": "Declarative plugin selection for a site (ADR-013). Apply with: flask plugin apply-profile deploy/site-profile.example.json. 'plugins' is the set this site wants; their hard dependencies are pulled in automatically and everything installs + enables in dependency order (idempotent). Naming a plugin not on disk fails loudly. 'locked' is reserved for a future guard against removing a site-mandated plugin. apply-profile never removes plugins absent from the list - removal stays an explicit flask plugin uninstall. Run flask plugin upgrade-all after applying, then restart."
|
||||
}
|
||||
6
deploy/windows/installer/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Build output and staged payload - regenerable, and ~220MB.
|
||||
bundle/
|
||||
Output/
|
||||
# Generated by build-installer.sh from the staged bundle.
|
||||
plugins.iss
|
||||
version.iss
|
||||
247
deploy/windows/installer/README.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Windows installer
|
||||
|
||||
Builds a single self-contained `.exe` that installs ShopDB-Flask on an
|
||||
**air-gapped** Windows Server. Nothing here ever touches the network at install
|
||||
time: Python, the wheels, the SPA and (optionally) MySQL all ship inside it.
|
||||
|
||||
Lives with the application on purpose. The installer depends on app internals -
|
||||
`flask plugin` verbs, `site-profile.json`, `MOUNT_PATH`, the plugin registry -
|
||||
so a separate repo would drift out of step with the thing it installs.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `shopdb-preflight.ps1` | Stage 1. Read-only. Changes nothing, reports what this server is missing. |
|
||||
| `shopdb-install.ps1` | Stages 0 and 2-5 plus `uninstall`. All the actual work. |
|
||||
| `shopdb-admin.ps1` | Operator console installed alongside the app: status, restart, logs, backup, plugins. |
|
||||
| `ShopDBFlask.iss` | Inno Setup wizard. A thin wrapper - it collects input and runs the stages. |
|
||||
| `build-installer.sh` | Stages the bundle from a site profile. |
|
||||
| `make-branding.py` | Generates wizard artwork and the icon from `frontend/public/*.svg`. |
|
||||
| `*.bmp`, `shopdb.ico` | Generated artwork, committed so a Windows build box needs no Python. |
|
||||
| `build-installer.ps1` | The same build, natively on Windows. No Bash needed. |
|
||||
| `bundle-lock.json` | The exact third-party payload this installer ships. Reviewed by commit. |
|
||||
| `bundle-lock.ps1` | Creates and checks that lock. Also runs on the target server. |
|
||||
| `refresh-bundle-lock.ps1` | Regenerates the lock, after showing what changed. |
|
||||
| `verify_bundle_lock.py` | The same check for the Bash builder, so Linux needs no pwsh. |
|
||||
|
||||
## Building
|
||||
|
||||
Two builders, same result. Use whichever machine you are on - `build-installer.ps1`
|
||||
does the whole job natively so a Windows work PC needs no Bash.
|
||||
|
||||
```bash
|
||||
# Linux / WSL
|
||||
./build-installer.sh ../../site-profile.example.json
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
|
||||
```
|
||||
|
||||
Both stage the app tree, build the SPA twice, write `plugins.iss`, copy the
|
||||
installer scripts **from this directory**, and then verify the third-party
|
||||
payload against `bundle-lock.json`. They exit non-zero if it does not match.
|
||||
|
||||
The payload itself is added by hand, and no longer needs Windows to produce:
|
||||
|
||||
```
|
||||
bundle/wheels/ pip download -r requirements.txt --only-binary=:all: \
|
||||
--platform win_amd64 --python-version 314 \
|
||||
--implementation cp --abi cp314 -d wheels
|
||||
bundle/python/ python-3.14.x-amd64.exe
|
||||
bundle/httpplatformhandler/ httpPlatformHandler_amd64.msi
|
||||
bundle/urlrewrite/ rewrite_amd64.msi (client-IP rule; see below)
|
||||
bundle/vcredist/ VC_redist.x64.exe (MySQL will not install without it)
|
||||
bundle/mysql/ mysql-8.4.x-winx64.msi (bundled-database option only)
|
||||
bundle/mysqlclient/ mysql.exe, mysqldump.exe + libcrypto/libssl (backups)
|
||||
```
|
||||
|
||||
Then compile on Windows:
|
||||
|
||||
```
|
||||
iscc ShopDBFlask.iss
|
||||
```
|
||||
|
||||
**Inno Setup 6.6.0 or newer.** The wizard uses the built-in `windows11` custom
|
||||
style, which earlier versions reject. The script fails at compile time with that
|
||||
sentence rather than with a bare "WizardStyle is invalid".
|
||||
|
||||
The wheelhouse is **cp314-locked**. A different Python minor version means a
|
||||
different wheelhouse; the installer will not use a Python it did not install.
|
||||
|
||||
## What is pinned, and where
|
||||
|
||||
Three layers, because no one of them covers the whole problem.
|
||||
|
||||
| Layer | Covers | Enforced |
|
||||
|---|---|---|
|
||||
| `requirements.txt` sha256 per package | every wheel is genuinely what upstream published | `pip --require-hashes` at install; aborts on mismatch |
|
||||
| `bundle-lock.json` | the EXACT payload: wheels, Python installer, MSIs | both builders, and again on the server before anything runs |
|
||||
| git | the application tree | code review |
|
||||
|
||||
`--require-hashes` alone is not enough. pip lists every artifact of a pinned
|
||||
version - `cffi 2.1.0` has 100 hashes - so it proves the wheel is genuine, not
|
||||
that it is the wheel this bundle was built and tested with. It also ignores
|
||||
extra files in the wheelhouse, and says nothing about the Python installer or
|
||||
the MSIs, all of which run as SYSTEM on the target server.
|
||||
|
||||
So `bundle-lock.json` records an exact file set with a sha256 and a byte size
|
||||
each, and verification is **set equality**: a missing file, an unexpected extra
|
||||
file, or changed content all fail. There is no install-time override.
|
||||
|
||||
The lockfile is `--universal`, so one file serves Linux (dev, Docker, CI) and the
|
||||
Windows wheelhouse. A Linux-only resolve had silently omitted `colorama`, a
|
||||
win32-only dependency of `click` - which in hash-checking mode is a hard error
|
||||
rather than a quiet omission.
|
||||
|
||||
### Verifying the installer itself
|
||||
|
||||
`bundle-lock.json` is **inside** the thing it describes, so it proves the payload
|
||||
was not altered between build and install - not that the `.exe` you received is
|
||||
the one that was built. That needs something out of band. Two options, in order
|
||||
of preference:
|
||||
|
||||
1. **Authenticode-sign the `.exe`** with a GE code-signing certificate. Windows
|
||||
then shows a real publisher instead of "Unknown", which is also what stops an
|
||||
operator learning to click through the SmartScreen warning.
|
||||
2. **Publish a sha256 per release** through a different channel than the file
|
||||
itself, and have the receiving site check it:
|
||||
`Get-FileHash ShopDBFlask_Installer_*.exe -Algorithm SHA256`
|
||||
|
||||
Neither is wired up yet. Until one is, an installer is only as trustworthy as
|
||||
the share it arrived on.
|
||||
|
||||
### Changing what ships
|
||||
|
||||
```powershell
|
||||
.\refresh-bundle-lock.ps1 # show what changed, write nothing
|
||||
.\refresh-bundle-lock.ps1 -Yes # write it
|
||||
```
|
||||
|
||||
Then **commit `bundle-lock.json`**. That commit is the review - it is the only
|
||||
place a change to what runs as SYSTEM on a customer's server becomes visible to
|
||||
a human. `refresh-bundle-lock.ps1` refuses to overwrite an existing lock until
|
||||
you have seen the diff, for that reason.
|
||||
|
||||
To stage a bundle before its lock exists: `ALLOW_UNLOCKED=1` (Bash) or
|
||||
`-AllowUnlocked` (PowerShell). Bundles built that way must not be shipped.
|
||||
|
||||
Verifying a live server, months later and offline:
|
||||
|
||||
```powershell
|
||||
shopdb-admin.ps1 verify
|
||||
```
|
||||
|
||||
## Servers this installer did not build
|
||||
|
||||
It is built for greenfield: its own Python, its own venv, its own IIS objects.
|
||||
Its upgrade path assumes the thing being upgraded came out of a previous run.
|
||||
Two guards keep it from damaging a server that was deployed by hand.
|
||||
|
||||
**Python minor version.** An existing venv is reused, which is right for a repair
|
||||
or an upgrade. It is wrong when the venv belongs to a different Python - the
|
||||
wheelhouse is tagged for one minor version, so pip would die at the first
|
||||
compiled package, *after* Python was installed and the app tree replaced. The
|
||||
installer compares the two up front and stops with both version numbers.
|
||||
|
||||
**IIS objects.** Switching deployment method removes the other method's artifact,
|
||||
which is correct when the installer owns both and dangerous when it does not: a
|
||||
wrong `-MountAlias` would delete a live mount with no prompt. It now refuses
|
||||
unless there is a version stamp proving it made the install, or you pass
|
||||
`-AdoptExisting`. The refusal lists exactly what it would have removed.
|
||||
|
||||
An existing `web.config` is never overwritten in either case.
|
||||
|
||||
For the West Jefferson production server specifically, this is a **migration, not
|
||||
an upgrade** - prod runs Python 3.13 against a hand-built deployment, so it needs
|
||||
a deliberate window, a database backup, and web.config reconciled by hand.
|
||||
|
||||
## Bill of materials
|
||||
|
||||
Every build stages a CycloneDX 1.6 SBOM at `sbom.cdx.json`, inside the
|
||||
application tree, so it installs onto the server with the app. Both ecosystems,
|
||||
in one document:
|
||||
|
||||
- **Python** - every pin in `requirements.txt`, with the sha256 the installer
|
||||
enforces. Environment markers are ignored: a `sys_platform == 'win32'`
|
||||
dependency still installs on the target.
|
||||
- **npm** - every package in `frontend/package-lock.json`. Build-only packages
|
||||
are marked `scope: excluded` rather than dropped, so "not here" is
|
||||
distinguishable from "not looked for".
|
||||
|
||||
It ships to the server because an air-gapped site cannot be scanned from
|
||||
anywhere else. When a CVE lands, the answer is already on the box:
|
||||
|
||||
```powershell
|
||||
shopdb-admin.ps1 verify # counts, and which bundle this is
|
||||
shopdb-admin.ps1 verify -Path leaflet # is that component here, at what version
|
||||
```
|
||||
|
||||
Generated by `scripts/generate_sbom.py` from files that are already pinned and
|
||||
committed, so it is a translation rather than a scan - no network, no extra
|
||||
toolchain on the build box, and byte-identical output for the same inputs. It is
|
||||
deliberately not in `bundle-lock.json`: its provenance is git, not the payload.
|
||||
|
||||
## Client IP addresses
|
||||
|
||||
IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects
|
||||
from loopback. Without a rule, **every client reads as 127.0.0.1** - so the
|
||||
GE-Enforce IP allowlist, the dashboard's visitor-location lookup and per-host
|
||||
login rate limiting all stop working, silently.
|
||||
|
||||
The wizard asks, because the two answers are mutually exclusive:
|
||||
|
||||
- **Clients connect directly** (`-ClientIpSource direct`, the default) - installs
|
||||
URL Rewrite from the bundle and sets `X-Forwarded-For` from `REMOTE_ADDR`.
|
||||
Overwriting the header is what stops a client spoofing its own.
|
||||
- **A proxy sits in front** (`-ClientIpSource proxy`) - leaves the rule off.
|
||||
Behind ARR or a load balancer `REMOTE_ADDR` is the *proxy*, so applying the
|
||||
rule would discard the real client IP.
|
||||
|
||||
An existing `web.config` is never overwritten - it is the one file on a server
|
||||
that legitimately carries hand-edits. The installer reports what it found
|
||||
instead.
|
||||
|
||||
## Deployment methods
|
||||
|
||||
Chosen in the wizard, and the bundle carries a SPA build for each because Vite
|
||||
compiles the base path in - it cannot be switched at install time.
|
||||
|
||||
- **Its own site** on a port (default 8090).
|
||||
- **Subpath** under an existing site, e.g. `http://<server-fqdn>/shopdb/`. Needs
|
||||
no new DNS record. Three things must agree - the IIS application alias,
|
||||
`MOUNT_PATH` in `.env`, and the SPA's build-time base - so the alias is fixed
|
||||
per bundle (`SUBPATH_ALIAS`, default `shopdb`) and the installer refuses if the
|
||||
bundle's build does not match what was asked for.
|
||||
|
||||
Switching between methods removes the other one's IIS artifact and reconciles
|
||||
`MOUNT_PATH` and `CORS_ORIGINS`, so a server never ends up with both.
|
||||
|
||||
## Upgrades
|
||||
|
||||
Run a newer installer over an existing install. It:
|
||||
|
||||
- backs the database up first, **verifies** the dump is complete, and refuses to
|
||||
migrate if it cannot;
|
||||
- restores from that backup if migrations fail, and reports honestly that DDL
|
||||
the failed migration committed cannot be undone;
|
||||
- refuses to run a bundle older than what is installed;
|
||||
- keeps `.env` unless new credentials are supplied, and copies it aside first;
|
||||
- stops the app pool before replacing files, then starts it again.
|
||||
|
||||
Whether a run is an upgrade is decided by probing the **target database**, not by
|
||||
whether the app directory exists - a rebuilt server pointed at an existing
|
||||
database is an upgrade, and treating it as fresh would drop tables.
|
||||
|
||||
## Testing notes
|
||||
|
||||
Verified end to end on Windows Server 2025 against both a bundled MySQL 8.4 LTS and
|
||||
an existing MySQL 5.6: fresh install, upgrade, re-run idempotency, failure and
|
||||
rollback, uninstall, and both deployment methods including switching between
|
||||
them.
|
||||
|
||||
**Not yet verified:** a fully air-gapped run with the network disabled at the
|
||||
hypervisor, and any load in a real browser (all HTTP checks so far used curl,
|
||||
which sends no `Origin` header - so `CORS_ORIGINS` is untested in anger).
|
||||
1340
deploy/windows/installer/ShopDBFlask.iss
Normal file
301
deploy/windows/installer/build-installer.ps1
Normal file
@@ -0,0 +1,301 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage a lean per-site installer bundle on Windows, and verify its third-party
|
||||
payload against bundle-lock.json.
|
||||
|
||||
.DESCRIPTION
|
||||
The Windows equivalent of build-installer.sh, for a work PC with no Bash. It
|
||||
does the whole job natively - it does NOT shell out to build-site.sh - so the
|
||||
only tools needed are Python, Node and (to compile) Inno Setup.
|
||||
|
||||
What it does, in order:
|
||||
|
||||
1. Resolves the site's plugin closure from its profile.
|
||||
2. Builds the SPA twice, because Vite compiles the base path in and it
|
||||
therefore cannot be chosen at install time: once for /<alias> (subpath
|
||||
deployment) and once for / (its own site).
|
||||
3. Stages the backend tree: core, the chosen plugins only, and the runtime
|
||||
files a deployable tree needs.
|
||||
4. Writes plugins.iss so the wizard's plugin page matches the payload.
|
||||
5. Copies the installer scripts from THIS directory.
|
||||
6. Verifies wheels\, python\ and the MSIs against bundle-lock.json, and
|
||||
FAILS if the bundle is not exactly what the lock describes.
|
||||
|
||||
Step 6 is the point of the script. A bundle that does not match its lock is
|
||||
not shipped, and "the wheelhouse was missing" is caught here rather than by
|
||||
an operator halfway through installing on a server with no network.
|
||||
|
||||
.PARAMETER Profile
|
||||
Path to the site profile (see deploy\site-profile.example.json).
|
||||
|
||||
.PARAMETER RepoRoot
|
||||
The repository. Defaults to four levels up from this script, which is correct
|
||||
for a normal checkout.
|
||||
|
||||
.PARAMETER SkipFrontend
|
||||
Reuse the SPA builds already staged in the bundle. For iterating on the
|
||||
installer itself, where two Vite builds per run is most of the wall clock.
|
||||
Never use it for a bundle you intend to ship.
|
||||
|
||||
.PARAMETER AllowUnlocked
|
||||
Stage the bundle and report payload problems WITHOUT failing. For assembling
|
||||
a bundle before its lock exists. A bundle built this way must not be shipped;
|
||||
run refresh-bundle-lock.ps1, commit the lock, then build again without this.
|
||||
|
||||
.EXAMPLE
|
||||
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
|
||||
.\build-installer.ps1 -Profile C:\sites\wjf.json -SkipFrontend
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $Profile,
|
||||
[string] $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path,
|
||||
[string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'),
|
||||
[string] $SubpathAlias = 'shopdb',
|
||||
[switch] $SkipFrontend,
|
||||
[switch] $AllowUnlocked
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'bundle-lock.ps1')
|
||||
|
||||
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
||||
function Step { param($m) Write-Host ''; Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Die { param($m, $fix = '') Write-Host ''; Write-Host " $m" -ForegroundColor Red
|
||||
if ($fix) { Write-Host " $fix" -ForegroundColor Yellow }; exit 1 }
|
||||
|
||||
function Invoke-Tool {
|
||||
# Runs a build tool and stops on a non-zero exit. npm writes progress to
|
||||
# stderr on SUCCESS, so stderr alone must never be treated as failure.
|
||||
param([string] $Exe, [string[]] $Arguments, [string] $WorkDir, [string] $What)
|
||||
Push-Location $WorkDir
|
||||
try {
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $Exe @Arguments 2>&1 | ForEach-Object { Say " $_" 'DarkGray' }
|
||||
$code = $LASTEXITCODE
|
||||
$ErrorActionPreference = $prev
|
||||
if ($code -ne 0) { Die "$What failed (exit $code)" }
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Profile)) { Die "profile not found: $Profile" }
|
||||
if (-not (Test-Path $RepoRoot)) { Die "repo not found: $RepoRoot" }
|
||||
$Profile = (Resolve-Path $Profile).Path
|
||||
$RepoRoot = (Resolve-Path $RepoRoot).Path
|
||||
$AppOut = Join-Path $BundleRoot 'app'
|
||||
|
||||
$python = (Get-Command python -ErrorAction SilentlyContinue)
|
||||
if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue }
|
||||
if (-not $python) { Die 'Python not found on PATH' 'Install Python 3.14 and re-open the shell.' }
|
||||
$npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
|
||||
if (-not $npm -and -not $SkipFrontend) { Die 'npm not found on PATH' 'Install Node, or pass -SkipFrontend to reuse the staged SPA.' }
|
||||
|
||||
Say ''
|
||||
Say " repo : $RepoRoot"
|
||||
Say " profile : $Profile"
|
||||
Say " bundle : $BundleRoot"
|
||||
|
||||
# --- 1. plugin closure ------------------------------------------------------
|
||||
# Same resolver the Linux builder uses, so both produce the same set from one
|
||||
# profile instead of two implementations of the closure rules.
|
||||
Step 'Resolving plugin closure'
|
||||
$closure = (& $python.Source (Join-Path $RepoRoot 'scripts\resolve_plugin_closure.py') $Profile $RepoRoot)
|
||||
if ($LASTEXITCODE -ne 0 -or -not $closure) { Die 'could not resolve the plugin closure from the profile' }
|
||||
$closure = $closure.Trim()
|
||||
Say " $closure" 'White'
|
||||
|
||||
# --- 2. frontend ------------------------------------------------------------
|
||||
$frontend = Join-Path $RepoRoot 'frontend'
|
||||
$subStaged = Join-Path $BundleRoot 'spa-subpath'
|
||||
$rootStaged= Join-Path $BundleRoot 'spa-root'
|
||||
|
||||
if ($SkipFrontend) {
|
||||
if (-not (Test-Path $subStaged) -or -not (Test-Path $rootStaged)) {
|
||||
Die '-SkipFrontend was passed but no SPA build is staged' 'Run once without it.'
|
||||
}
|
||||
Say ''
|
||||
Say ' Reusing the staged SPA builds (-SkipFrontend). NOT shippable if the frontend changed.' 'Yellow'
|
||||
} else {
|
||||
Step "Building the SPA for /$SubpathAlias/"
|
||||
$env:SITE_PLUGINS = $closure
|
||||
$env:VITE_BASE_PATH = "/$SubpathAlias/"
|
||||
try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'subpath frontend build' }
|
||||
finally { Remove-Item Env:\VITE_BASE_PATH -ErrorAction SilentlyContinue }
|
||||
Remove-Item $subStaged -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item (Join-Path $frontend 'dist') $subStaged -Recurse -Force
|
||||
# The alias is fixed at BUILD time; the installer reads this and refuses to
|
||||
# publish under a different one rather than serving a page that cannot load
|
||||
# its own assets.
|
||||
Set-Content -Path (Join-Path $subStaged '.alias') -Value $SubpathAlias -Encoding ASCII
|
||||
|
||||
# Root build LAST, so frontend\dist is left in the state a developer expects.
|
||||
Step 'Building the SPA for /'
|
||||
try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'root frontend build' }
|
||||
finally { Remove-Item Env:\SITE_PLUGINS -ErrorAction SilentlyContinue }
|
||||
Remove-Item $rootStaged -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item (Join-Path $frontend 'dist') $rootStaged -Recurse -Force
|
||||
}
|
||||
|
||||
# --- 3. backend tree --------------------------------------------------------
|
||||
Step "Staging the application tree"
|
||||
Remove-Item $AppOut -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path (Join-Path $AppOut 'plugins') -Force | Out-Null
|
||||
|
||||
Copy-Item (Join-Path $RepoRoot 'shopdb') $AppOut -Recurse -Force
|
||||
foreach ($name in $closure.Split(',')) {
|
||||
$src = Join-Path $RepoRoot ('plugins\' + $name.Trim())
|
||||
if (-not (Test-Path $src)) { Die "plugin in the closure is not on disk: $name" }
|
||||
Copy-Item $src (Join-Path $AppOut 'plugins') -Recurse -Force
|
||||
}
|
||||
|
||||
# Runtime files a deployable tree needs beyond the Python packages. Without
|
||||
# these the tree imports but cannot be run or migrated.
|
||||
foreach ($f in @('wsgi.py', 'requirements.txt')) {
|
||||
Copy-Item (Join-Path $RepoRoot $f) $AppOut -Force
|
||||
}
|
||||
Copy-Item (Join-Path $RepoRoot 'migrations') $AppOut -Recurse -Force
|
||||
# ONLY web.config, not all of deploy\. The bundle is staged INSIDE deploy\, so
|
||||
# copying the whole tree would recurse into its own output, and the rest of
|
||||
# deploy\ is installer source that has no business on an application server.
|
||||
# shopdb-install.ps1 reads it from exactly this path.
|
||||
$cfgSrc = Join-Path $RepoRoot 'deploy\windows\web.config'
|
||||
if (Test-Path $cfgSrc) {
|
||||
New-Item -ItemType Directory -Path (Join-Path $AppOut 'deploy\windows') -Force | Out-Null
|
||||
Copy-Item $cfgSrc (Join-Path $AppOut 'deploy\windows') -Force
|
||||
}
|
||||
|
||||
# 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.
|
||||
Step 'Generating SBOM'
|
||||
& $python.Source (Join-Path $RepoRoot 'scripts\generate_sbom.py') $RepoRoot `
|
||||
-o (Join-Path $AppOut 'sbom.cdx.json') | ForEach-Object { Say " $_" 'White' }
|
||||
if ($LASTEXITCODE -ne 0) { Die 'SBOM generation failed' }
|
||||
|
||||
# Docs the running site serves, plus the runbooks an air-gapped server has no
|
||||
# other way to reach. Without openapi.json and llms.txt the self-hosted /api/docs
|
||||
# page is broken on every installed server.
|
||||
Step 'Staging docs'
|
||||
$docsOut = Join-Path $AppOut 'docs'
|
||||
New-Item -ItemType Directory -Path $docsOut -Force | Out-Null
|
||||
foreach ($doc in @('openapi.json', 'llms.txt', 'api-inventory.json',
|
||||
'INSTALL-WINDOWS.md', 'OPERATE-WINDOWS.md',
|
||||
'BACKUP-RESTORE.md', 'UPGRADE.md')) {
|
||||
$src = Join-Path $RepoRoot ('docs\' + $doc)
|
||||
if (Test-Path $src) { Copy-Item $src $docsOut -Force; Say " $doc" }
|
||||
}
|
||||
|
||||
# Stage the profile INTO the tree: `flask plugin apply-profile` at provisioning
|
||||
# reads the same profile the tree was staged from, so the installed plugin set
|
||||
# and the shipped plugin code cannot drift.
|
||||
Copy-Item $Profile (Join-Path $AppOut 'site-profile.json') -Force
|
||||
|
||||
# The installer expects frontend\dist and frontend\dist-subpath.
|
||||
$feOut = Join-Path $AppOut 'frontend'
|
||||
New-Item -ItemType Directory -Path $feOut -Force | Out-Null
|
||||
Copy-Item $rootStaged (Join-Path $feOut 'dist') -Recurse -Force
|
||||
Copy-Item $subStaged (Join-Path $feOut 'dist-subpath') -Recurse -Force
|
||||
|
||||
Get-ChildItem $AppOut -Recurse -Directory -Filter '__pycache__' -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem $AppOut -Recurse -File -Filter '*.pyc' -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# --- 4. plugins.iss ---------------------------------------------------------
|
||||
Step 'Writing plugins.iss'
|
||||
$shipped = (Get-ChildItem (Join-Path $AppOut 'plugins') -Directory | Select-Object -ExpandProperty Name) -join ','
|
||||
$aliasBuilt = ''
|
||||
$aliasFile = Join-Path $feOut 'dist-subpath\.alias'
|
||||
if (Test-Path $aliasFile) { $aliasBuilt = (Get-Content $aliasFile -TotalCount 1).Trim() }
|
||||
@"
|
||||
; GENERATED by build-installer.ps1 - do not edit.
|
||||
; The plugins present in bundle\app\plugins at build time.
|
||||
#define AvailablePlugins "$shipped"
|
||||
; The alias the subpath SPA was built for, or empty if this bundle has no
|
||||
; subpath build - in which case the wizard must not offer that option.
|
||||
#define SubpathAlias "$aliasBuilt"
|
||||
"@ | Set-Content -Path (Join-Path $PSScriptRoot 'plugins.iss') -Encoding ASCII
|
||||
Say " $shipped" 'White'
|
||||
|
||||
# The product version, read from the code rather than restated in the .iss.
|
||||
Step 'Writing version.iss'
|
||||
$initText = Get-Content (Join-Path $RepoRoot 'shopdb\__init__.py') -Raw
|
||||
if ($initText -notmatch "(?m)^__version__\s*=\s*'([^']+)'") { Die 'could not read __version__ from shopdb\__init__.py' }
|
||||
$appVersion = $Matches[1]
|
||||
@"
|
||||
; GENERATED by build-installer.ps1 from shopdb/__init__.py - do not edit.
|
||||
#define AppVersion "$appVersion"
|
||||
"@ | Set-Content -Path (Join-Path $PSScriptRoot 'version.iss') -Encoding ASCII
|
||||
Say " $appVersion" 'White'
|
||||
|
||||
# --- 5. installer scripts ---------------------------------------------------
|
||||
# From THIS directory, which is the reviewed copy under version control. They
|
||||
# used to be copied from a downloads folder, so the logic that shipped was not
|
||||
# the logic that was committed and the build only worked on one machine.
|
||||
Step 'Copying installer scripts'
|
||||
foreach ($f in @('shopdb-install.ps1', 'shopdb-preflight.ps1', 'bundle-lock.ps1')) {
|
||||
$src = Join-Path $PSScriptRoot $f
|
||||
if (-not (Test-Path $src)) { Die "installer script missing from the repo: $f" }
|
||||
Copy-Item $src $BundleRoot -Force
|
||||
Say " $f"
|
||||
}
|
||||
|
||||
# --- 6. payload verification ------------------------------------------------
|
||||
Step 'Verifying the third-party payload against bundle-lock.json'
|
||||
$lockPath = Join-Path $PSScriptRoot 'bundle-lock.json'
|
||||
$lock = Read-BundleLock $lockPath
|
||||
if (-not $lock) {
|
||||
$msg = "no bundle-lock.json at $lockPath"
|
||||
if ($AllowUnlocked) { Say " $msg - continuing because -AllowUnlocked was passed" 'Yellow' }
|
||||
else {
|
||||
Die $msg @'
|
||||
Add the wheels and installers to the bundle, then:
|
||||
.\refresh-bundle-lock.ps1 (review the list)
|
||||
.\refresh-bundle-lock.ps1 -Yes (write it)
|
||||
and commit bundle-lock.json. Pass -AllowUnlocked to stage a bundle without one -
|
||||
it must not be shipped.
|
||||
'@
|
||||
}
|
||||
} else {
|
||||
$problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock
|
||||
if ($problems.Count -eq 0) {
|
||||
Say (" payload matches the lock ({0}, {1})" -f `
|
||||
(Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'Green'
|
||||
# Ships WITH the bundle: the installer re-checks the payload on the
|
||||
# target server before running any of it, so tampering between build and
|
||||
# install is caught too.
|
||||
Copy-Item $lockPath $BundleRoot -Force
|
||||
} else {
|
||||
Say ''
|
||||
foreach ($p in $problems) { Say " $p" 'Red' }
|
||||
Say ''
|
||||
if ($AllowUnlocked) {
|
||||
Say ' -AllowUnlocked: continuing anyway. DO NOT SHIP this bundle.' 'Yellow'
|
||||
} else {
|
||||
Die ("{0} payload problem(s) - the bundle is not what the lock describes" -f $problems.Count) @'
|
||||
Either the payload is wrong (fix the bundle) or it changed on purpose (run
|
||||
refresh-bundle-lock.ps1, read the diff, and commit the new lock).
|
||||
'@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- summary ----------------------------------------------------------------
|
||||
Write-Host ''
|
||||
Say " Bundle staged at: $BundleRoot" 'White'
|
||||
foreach ($d in @('app', 'wheels', 'python', 'httpplatformhandler', 'urlrewrite', 'vcredist', 'mysqlclient', 'mysql')) {
|
||||
$p = Join-Path $BundleRoot $d
|
||||
if (Test-Path $p) {
|
||||
$mb = ((Get-ChildItem $p -Recurse -File | Measure-Object Length -Sum).Sum / 1MB)
|
||||
Say (" {0,-22} {1,8:N1} MB" -f $d, $mb)
|
||||
} else {
|
||||
Say (" {0,-22} {1}" -f $d, 'absent') 'DarkGray'
|
||||
}
|
||||
}
|
||||
Write-Host ''
|
||||
Say ' Compile with: iscc ShopDBFlask.iss' 'White'
|
||||
Say ' (Inno Setup 6.6.0 or newer - the wizard uses the windows11 custom style.)' 'DarkGray'
|
||||
Write-Host ''
|
||||
141
deploy/windows/installer/build-installer.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
# Stage a lean per-site bundle next to ShopDBFlask.iss, ready for Inno Setup.
|
||||
#
|
||||
# The bundle is built FOR ONE SITE from its plugin profile (ADR-013): plugins the
|
||||
# site did not choose are absent from the payload entirely. Build one installer
|
||||
# per site, not one universal installer.
|
||||
#
|
||||
# Usage: build-installer.sh <site-profile.json> [repo-path]
|
||||
#
|
||||
# The wheelhouse cannot be built here. Wheels are cp314 win_amd64 and must be
|
||||
# produced ON Windows with the matching Python:
|
||||
# pip download -r requirements.txt -d wheels --only-binary=:all:
|
||||
# Copy that wheels\ directory in before compiling.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROFILE="${1:?usage: build-installer.sh <site-profile.json> [repo-path]}"
|
||||
REPO="${2:-$HOME/projects/shopdb-flask}"
|
||||
BUNDLE="$HERE/bundle"
|
||||
|
||||
[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; }
|
||||
[ -d "$REPO" ] || { echo "repo not found: $REPO"; exit 1; }
|
||||
|
||||
echo "==> Staging lean app tree from $PROFILE"
|
||||
rm -rf "$BUNDLE/app"
|
||||
bash "$REPO/scripts/build-site.sh" "$PROFILE" "$BUNDLE/app"
|
||||
|
||||
# build-site.sh emits the SPA as frontend-dist; the installer's web.config and
|
||||
# static route expect frontend\dist.
|
||||
if [ -d "$BUNDLE/app/frontend-dist" ]; then
|
||||
mkdir -p "$BUNDLE/app/frontend"
|
||||
rm -rf "$BUNDLE/app/frontend/dist"
|
||||
mv "$BUNDLE/app/frontend-dist" "$BUNDLE/app/frontend/dist"
|
||||
fi
|
||||
# The /shopdb-based build, used when the operator picks the subpath deployment.
|
||||
if [ -d "$BUNDLE/app/frontend-dist-subpath" ]; then
|
||||
rm -rf "$BUNDLE/app/frontend/dist-subpath"
|
||||
mv "$BUNDLE/app/frontend-dist-subpath" "$BUNDLE/app/frontend/dist-subpath"
|
||||
fi
|
||||
|
||||
# Tell the .iss which plugins this bundle actually carries, so the wizard's
|
||||
# plugin page always matches the payload instead of a hand-maintained list.
|
||||
echo "==> Writing plugins.iss"
|
||||
PLUGINS=$(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ',' | sed 's/,$//')
|
||||
# The subpath SPA is built with its base path compiled in, so whether the wizard
|
||||
# can OFFER a subpath install is a property of the bundle, not a runtime choice.
|
||||
SUBPATH_ALIAS_BUILT=""
|
||||
if [ -f "$BUNDLE/app/frontend/dist-subpath/.alias" ]; then
|
||||
SUBPATH_ALIAS_BUILT="$(cat "$BUNDLE/app/frontend/dist-subpath/.alias")"
|
||||
fi
|
||||
|
||||
cat > "$HERE/plugins.iss" <<EOF
|
||||
; GENERATED by build-installer.sh - do not edit.
|
||||
; The plugins present in bundle\\app\\plugins at build time.
|
||||
#define AvailablePlugins "$PLUGINS"
|
||||
; The alias the subpath SPA was built for, or empty if this bundle has no
|
||||
; subpath build - in which case the wizard must not offer that option.
|
||||
#define SubpathAlias "$SUBPATH_ALIAS_BUILT"
|
||||
EOF
|
||||
echo " $PLUGINS"
|
||||
|
||||
# The product version, read from the code rather than restated here. A hardcoded
|
||||
# AppVersion in the .iss had drifted two minor versions from shopdb/__init__.py.
|
||||
echo "==> Writing version.iss"
|
||||
APPVERSION=$(sed -n "s/^__version__ = '\\(.*\\)'/\\1/p" "$REPO/shopdb/__init__.py" | head -1)
|
||||
[ -n "$APPVERSION" ] || { echo "could not read __version__ from shopdb/__init__.py"; exit 1; }
|
||||
cat > "$HERE/version.iss" <<EOF
|
||||
; GENERATED by build-installer.sh from shopdb/__init__.py - do not edit.
|
||||
#define AppVersion "$APPVERSION"
|
||||
EOF
|
||||
echo " $APPVERSION"
|
||||
|
||||
# From THIS directory, which is the reviewed copy under version control. These
|
||||
# used to be copied from $HOME/Downloads, so the installer logic that shipped was
|
||||
# not the logic that was committed, and the build only worked on one machine.
|
||||
echo "==> Copying installer scripts"
|
||||
mkdir -p "$BUNDLE"
|
||||
for f in shopdb-install.ps1 shopdb-preflight.ps1 bundle-lock.ps1; do
|
||||
[ -f "$HERE/$f" ] || { echo "installer script missing from the repo: $f"; exit 1; }
|
||||
cp "$HERE/$f" "$BUNDLE/"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Bundle staged at: $BUNDLE"
|
||||
for d in app wheels python httpplatformhandler urlrewrite vcredist mysqlclient mysql; do
|
||||
if [ -d "$BUNDLE/$d" ]; then
|
||||
printf ' %-20s %s\n' "$d" "$(du -sh "$BUNDLE/$d" | cut -f1)"
|
||||
else
|
||||
printf ' %-20s absent\n' "$d"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " plugins shipped: $(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ' ')"
|
||||
|
||||
# --- payload verification ---------------------------------------------------
|
||||
# The third-party payload is the part git does not record: the wheels, the Python
|
||||
# installer and the MSIs that run as SYSTEM on the target server. It must be
|
||||
# EXACTLY what bundle-lock.json describes - no missing file, no stale extra wheel
|
||||
# left over from a previous build, no changed content - or this is not a bundle
|
||||
# anyone reviewed. Previously a missing wheelhouse printed MISSING and the script
|
||||
# still exited 0, so an empty bundle compiled into a shippable installer and the
|
||||
# failure surfaced on an air-gapped server with no way to fix it.
|
||||
#
|
||||
# ALLOW_UNLOCKED=1 downgrades this to a warning, for assembling a bundle before
|
||||
# its lock exists. A bundle built that way must not be shipped.
|
||||
echo ""
|
||||
echo "==> Verifying the third-party payload against bundle-lock.json"
|
||||
if python3 "$HERE/verify_bundle_lock.py" "$BUNDLE" "$HERE/bundle-lock.json"; then
|
||||
echo " payload matches the lock"
|
||||
# Ships WITH the bundle: the installer re-checks the payload on the target
|
||||
# server before running any of it, so tampering between build and install is
|
||||
# caught too.
|
||||
cp "$HERE/bundle-lock.json" "$BUNDLE/"
|
||||
elif [ "${ALLOW_UNLOCKED:-0}" = "1" ]; then
|
||||
echo ""
|
||||
echo " ALLOW_UNLOCKED=1: continuing anyway. DO NOT SHIP this bundle."
|
||||
else
|
||||
echo ""
|
||||
echo " The bundle is not what the lock describes."
|
||||
echo ""
|
||||
echo " Add the missing pieces by hand:"
|
||||
echo " wheels/ pip download -r requirements.txt --only-binary=:all: \\"
|
||||
echo " --platform win_amd64 --python-version 314 \\"
|
||||
echo " --implementation cp --abi cp314 -d wheels"
|
||||
echo " python/ python-3.14.x-amd64.exe"
|
||||
echo " httpplatformhandler/ httpPlatformHandler_amd64.msi"
|
||||
echo " urlrewrite/ rewrite_amd64.msi (client-IP rule; see README)"
|
||||
echo " vcredist/ VC_redist.x64.exe (MySQL requires it)"
|
||||
echo " mysql/ mysql-8.4.x-winx64.msi (bundled-database option only)"
|
||||
echo ""
|
||||
echo " If the payload changed ON PURPOSE, regenerate and COMMIT the lock:"
|
||||
echo " pwsh ./refresh-bundle-lock.ps1 # review the diff"
|
||||
echo " pwsh ./refresh-bundle-lock.ps1 -Yes # write it"
|
||||
echo ""
|
||||
echo " To stage a bundle before its lock exists: ALLOW_UNLOCKED=1 $0 ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Then compile on Windows: iscc ShopDBFlask.iss"
|
||||
echo "(Inno Setup 6.6.0 or newer - the wizard uses the windows11 custom style.)"
|
||||
240
deploy/windows/installer/bundle-lock.json
Normal file
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"generated": "2026-08-04T23:11:22Z",
|
||||
"pythontag": "cp314",
|
||||
"platform": "win_amd64",
|
||||
"note": "Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.",
|
||||
"payloads": {
|
||||
"httpplatformhandler": {
|
||||
"files": {
|
||||
"httpPlatformHandler_amd64.msi": {
|
||||
"sha256": "90f8d4905a0ab4f2c95223b3c79e2807a0b74507747d240e43c4302e8db4b5ef",
|
||||
"size": 557056
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"python": {
|
||||
"files": {
|
||||
"python-3.14.6-amd64.exe": {
|
||||
"sha256": "14b3e9a710a3fcf0bd9b55ab6b60412bd91227563f813fc49040cabc0209e0bd",
|
||||
"size": 30774112
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"vcredist": {
|
||||
"files": {
|
||||
"VC_redist.x64.exe": {
|
||||
"sha256": "cc0ff0eb1dc3f5188ae6300faef32bf5beeba4bdd6e8e445a9184072096b713b",
|
||||
"size": 25635768
|
||||
}
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
"mysql": {
|
||||
"files": {
|
||||
"mysql-8.4.6-winx64.msi": {
|
||||
"sha256": "868885b2e221409f0170729bb30b2b2ce83440a1a4c735d19b3c225b51045762",
|
||||
"size": 135081984
|
||||
}
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
"wheels": {
|
||||
"files": {
|
||||
"email_validator-2.3.0-py3-none-any.whl": {
|
||||
"sha256": "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4",
|
||||
"size": 35604
|
||||
},
|
||||
"cachelib-0.13.0-py3-none-any.whl": {
|
||||
"sha256": "8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516",
|
||||
"size": 20914
|
||||
},
|
||||
"flask_caching-2.4.0-py3-none-any.whl": {
|
||||
"sha256": "d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486",
|
||||
"size": 28727
|
||||
},
|
||||
"typing_extensions-4.15.0-py3-none-any.whl": {
|
||||
"sha256": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548",
|
||||
"size": 44614
|
||||
},
|
||||
"greenlet-3.5.0-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033",
|
||||
"size": 239835
|
||||
},
|
||||
"charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f",
|
||||
"size": 159634
|
||||
},
|
||||
"colorama-0.4.6-py2.py3-none-any.whl": {
|
||||
"sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6",
|
||||
"size": 25335
|
||||
},
|
||||
"Flask_Migrate-4.1.0-py3-none-any.whl": {
|
||||
"sha256": "24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d",
|
||||
"size": 21237
|
||||
},
|
||||
"markupsafe-3.0.3-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581",
|
||||
"size": 15341
|
||||
},
|
||||
"flask_marshmallow-1.5.0-py3-none-any.whl": {
|
||||
"sha256": "99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6",
|
||||
"size": 12161
|
||||
},
|
||||
"tzdata-2026.3-py2.py3-none-any.whl": {
|
||||
"sha256": "dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931",
|
||||
"size": 348168
|
||||
},
|
||||
"python_dotenv-1.2.2-py3-none-any.whl": {
|
||||
"sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
|
||||
"size": 22101
|
||||
},
|
||||
"dnspython-2.8.0-py3-none-any.whl": {
|
||||
"sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af",
|
||||
"size": 331094
|
||||
},
|
||||
"pymysql-1.1.3-py3-none-any.whl": {
|
||||
"sha256": "8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289",
|
||||
"size": 45356
|
||||
},
|
||||
"packaging-26.3-py3-none-any.whl": {
|
||||
"sha256": "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c",
|
||||
"size": 129956
|
||||
},
|
||||
"flask_jwt_extended-4.7.3-py2.py3-none-any.whl": {
|
||||
"sha256": "905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb",
|
||||
"size": 22698
|
||||
},
|
||||
"urllib3-2.7.0-py3-none-any.whl": {
|
||||
"sha256": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897",
|
||||
"size": 131087
|
||||
},
|
||||
"marshmallow-4.3.0-py3-none-any.whl": {
|
||||
"sha256": "46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46",
|
||||
"size": 49148
|
||||
},
|
||||
"waitress-3.0.2-py3-none-any.whl": {
|
||||
"sha256": "c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e",
|
||||
"size": 56232
|
||||
},
|
||||
"idna-3.13-py3-none-any.whl": {
|
||||
"sha256": "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3",
|
||||
"size": 68629
|
||||
},
|
||||
"flask-3.1.3-py3-none-any.whl": {
|
||||
"sha256": "f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c",
|
||||
"size": 103424
|
||||
},
|
||||
"werkzeug-3.1.8-py3-none-any.whl": {
|
||||
"sha256": "63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50",
|
||||
"size": 226459
|
||||
},
|
||||
"flask_sqlalchemy-3.1.1-py3-none-any.whl": {
|
||||
"sha256": "4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0",
|
||||
"size": 25125
|
||||
},
|
||||
"marshmallow_sqlalchemy-1.5.0-py3-none-any.whl": {
|
||||
"sha256": "3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258",
|
||||
"size": 16582
|
||||
},
|
||||
"alembic-1.18.4-py3-none-any.whl": {
|
||||
"sha256": "a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a",
|
||||
"size": 263893
|
||||
},
|
||||
"tabulate-0.10.0-py3-none-any.whl": {
|
||||
"sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3",
|
||||
"size": 39814
|
||||
},
|
||||
"cffi-2.1.0-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb",
|
||||
"size": 187937
|
||||
},
|
||||
"jinja2-3.1.6-py3-none-any.whl": {
|
||||
"sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67",
|
||||
"size": 134899
|
||||
},
|
||||
"cryptography-50.0.0-cp311-abi3-win_amd64.whl": {
|
||||
"sha256": "bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30",
|
||||
"size": 3840395
|
||||
},
|
||||
"certifi-2026.4.22-py3-none-any.whl": {
|
||||
"sha256": "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a",
|
||||
"size": 135707
|
||||
},
|
||||
"sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5",
|
||||
"size": 2144204
|
||||
},
|
||||
"blinker-1.9.0-py3-none-any.whl": {
|
||||
"sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc",
|
||||
"size": 8458
|
||||
},
|
||||
"pycparser-3.0-py3-none-any.whl": {
|
||||
"sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992",
|
||||
"size": 48172
|
||||
},
|
||||
"flask_cors-6.0.2-py3-none-any.whl": {
|
||||
"sha256": "e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a",
|
||||
"size": 13257
|
||||
},
|
||||
"requests-2.33.1-py3-none-any.whl": {
|
||||
"sha256": "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a",
|
||||
"size": 64947
|
||||
},
|
||||
"pyjwt-2.12.1-py3-none-any.whl": {
|
||||
"sha256": "28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
|
||||
"size": 29726
|
||||
},
|
||||
"click-8.3.3-py3-none-any.whl": {
|
||||
"sha256": "a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613",
|
||||
"size": 110502
|
||||
},
|
||||
"mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49",
|
||||
"size": 18197576
|
||||
},
|
||||
"mako-1.3.12-py3-none-any.whl": {
|
||||
"sha256": "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9",
|
||||
"size": 78521
|
||||
},
|
||||
"itsdangerous-2.2.0-py3-none-any.whl": {
|
||||
"sha256": "c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef",
|
||||
"size": 16234
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"urlrewrite": {
|
||||
"files": {
|
||||
"rewrite_amd64_en-US.msi": {
|
||||
"sha256": "37342ff2f585f263f34f48e9de59eb1051d61015a8e967dbde4075716230a32a",
|
||||
"size": 6078464
|
||||
}
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
"mysqlclient": {
|
||||
"files": {
|
||||
"libcrypto-3-x64.dll": {
|
||||
"sha256": "e84ec61fc1b07a3c899ae39c1dc3f38591cdd310c043a388e7ab40a6828aa297",
|
||||
"size": 5187216
|
||||
},
|
||||
"mysqldump.exe": {
|
||||
"sha256": "329410b7a8ae3d68d38e6f5b781610770854d3f11b975060e231dae62c20dc46",
|
||||
"size": 7142528
|
||||
},
|
||||
"mysql.exe": {
|
||||
"sha256": "bd6880253c1853fc17b1e19129d6e5d9f9c86a2dccf2c7c9e6b878a10baa9770",
|
||||
"size": 7177336
|
||||
},
|
||||
"libssl-3-x64.dll": {
|
||||
"sha256": "e24fcd6c8ec31a14c97f520b5b960ed360c55ddf0935040eff297e511697633e",
|
||||
"size": 788616
|
||||
}
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
261
deploy/windows/installer/bundle-lock.ps1
Normal file
@@ -0,0 +1,261 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Hash manifest for the installer's third-party payload: create it, and check a
|
||||
bundle against it.
|
||||
|
||||
.DESCRIPTION
|
||||
Dot-source this. It defines two functions and runs nothing on its own:
|
||||
|
||||
New-BundleLock hash a staged bundle and return the lock object
|
||||
Test-BundleLock compare a staged bundle against a lock, return problems
|
||||
|
||||
WHAT IT COVERS, and why pip's own hash checking is not enough.
|
||||
|
||||
requirements.txt carries a sha256 for every wheel, so pip refuses an artifact
|
||||
upstream did not publish. Three gaps remain, and all three are what actually
|
||||
goes wrong with a hand-assembled offline bundle:
|
||||
|
||||
1. pip lists EVERY artifact of a pinned version - cffi 2.1.0 alone has 100
|
||||
hashes. It proves the wheel is genuine, not that it is the wheel this
|
||||
bundle was built and tested with.
|
||||
2. pip ignores extra files in the wheelhouse. A stale wheel left behind by
|
||||
a previous build sits there unnoticed until a resolve picks it up.
|
||||
3. pip says nothing about the rest of the payload - the Python installer,
|
||||
the HttpPlatformHandler MSI, URL Rewrite, MySQL. Those are executables
|
||||
that run as SYSTEM on the target server and were, until this file, the
|
||||
only unverified thing the installer would run.
|
||||
|
||||
So the lock records an exact file set with a sha256 and a byte size each, and
|
||||
verification is SET EQUALITY: a missing file, an unexpected extra file, or a
|
||||
changed file all fail. Nothing is skipped and nothing is "close enough".
|
||||
|
||||
The app tree is deliberately NOT covered. It is built from the repository on
|
||||
every run and changes with every commit; hashing it would make the lock churn
|
||||
constantly and train everyone to regenerate it without reading it. Git is the
|
||||
record for the app tree. This file is the record for everything that comes
|
||||
from outside the repository.
|
||||
|
||||
.NOTES
|
||||
Stock Windows PowerShell 5.1, and also runs under pwsh on Linux so the Bash
|
||||
builder can call the same checker.
|
||||
#>
|
||||
|
||||
# Payload directories under the bundle root. 'required' means the installer
|
||||
# cannot work without it; the optional ones are per-deployment choices, and an
|
||||
# absent optional directory is fine. A PRESENT directory is always checked in
|
||||
# full, optional or not.
|
||||
$script:BundlePayloads = @(
|
||||
@{ Name = 'wheels'; Required = $true; What = 'Python wheels for the offline install' }
|
||||
@{ Name = 'python'; Required = $true; What = 'the Python installer' }
|
||||
@{ Name = 'httpplatformhandler'; Required = $true; What = 'the IIS module that launches waitress' }
|
||||
@{ Name = 'urlrewrite'; Required = $false; What = 'IIS URL Rewrite, for the client-IP rule' }
|
||||
@{ Name = 'mysqlclient'; Required = $false; What = 'mysql/mysqldump, for backups against a remote database' }
|
||||
@{ Name = 'vcredist'; Required = $false; What = 'the Visual C++ runtime MySQL requires' }
|
||||
@{ Name = 'mysql'; Required = $false; What = 'MySQL, for the bundled-database option' }
|
||||
)
|
||||
|
||||
function Get-JsonProperty {
|
||||
# shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
|
||||
# that does not exist on a PSCustomObject THROWS instead of returning $null.
|
||||
# A truncated or hand-edited bundle-lock.json would therefore blow up with
|
||||
# "Property 'payloads' cannot be found" rather than saying what is wrong with
|
||||
# the lock. Every read of parsed JSON goes through here.
|
||||
param($Object, [string] $Name, $Default = $null)
|
||||
if ($null -eq $Object) { return $Default }
|
||||
$prop = $Object.PSObject.Properties[$Name]
|
||||
if ($null -eq $prop) { return $Default }
|
||||
return $prop.Value
|
||||
}
|
||||
|
||||
function Get-FileDigest {
|
||||
param([string] $Path)
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
$stream = [System.IO.File]::OpenRead($Path)
|
||||
try { return (-join ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') })) }
|
||||
finally { $stream.Dispose(); $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Get-PayloadFiles {
|
||||
<#
|
||||
Every file in the directory, keyed by its path RELATIVE to that directory
|
||||
with forward slashes, so a lock generated on Windows reads the same from
|
||||
the Bash builder.
|
||||
|
||||
The root comes from Get-Item, NOT Resolve-Path, and the prefix is checked
|
||||
before it is trimmed. Both matter, and a real install proved it:
|
||||
|
||||
Inno extracts the bundle under C:\Users\ADMINI~1\AppData\Local\Temp\... -
|
||||
an 8.3 SHORT path. Resolve-Path kept that short form while Get-ChildItem
|
||||
returned the long one (Administrator), so the root was five characters
|
||||
shorter than the prefix it was slicing off. Every relative path came out
|
||||
mangled - 'wheels/heels/flask.whl' - and the verifier reported all 96 files
|
||||
as simultaneously missing and unexpected. The payload was fine; the
|
||||
comparison was not.
|
||||
|
||||
Get-Item and Get-ChildItem go through the same provider, so their path
|
||||
forms agree. The StartsWith guard means that if they ever disagree again
|
||||
this fails loudly instead of inventing paths.
|
||||
#>
|
||||
param([string] $Dir)
|
||||
$out = @{}
|
||||
if (-not (Test-Path $Dir)) { return $out }
|
||||
$rootItem = Get-Item -LiteralPath $Dir
|
||||
$root = $rootItem.FullName.TrimEnd('\', '/')
|
||||
foreach ($f in (Get-ChildItem -LiteralPath $rootItem.FullName -Recurse -File)) {
|
||||
if (-not $f.FullName.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw ("cannot place '{0}' beneath '{1}' - path forms disagree (8.3 short name?)" -f $f.FullName, $root)
|
||||
}
|
||||
$rel = $f.FullName.Substring($root.Length).TrimStart('\', '/').Replace('\', '/')
|
||||
$out[$rel] = @{ sha256 = (Get-FileDigest $f.FullName); size = $f.Length }
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
function New-BundleLock {
|
||||
<#
|
||||
Hash a staged bundle. The caller writes the result to bundle-lock.json;
|
||||
this returns the object so a caller can diff it against the committed lock
|
||||
before overwriting anything.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $BundleRoot,
|
||||
[string] $PythonTag = 'cp314',
|
||||
[string] $Platform = 'win_amd64'
|
||||
)
|
||||
$payloads = @{}
|
||||
foreach ($p in $script:BundlePayloads) {
|
||||
$dir = Join-Path $BundleRoot $p.Name
|
||||
if (-not (Test-Path $dir)) {
|
||||
if ($p.Required) { throw ("payload directory is missing: {0} ({1})" -f $p.Name, $p.What) }
|
||||
continue
|
||||
}
|
||||
$files = Get-PayloadFiles $dir
|
||||
if ($files.Count -eq 0 -and $p.Required) {
|
||||
throw ("payload directory is empty: {0} ({1})" -f $p.Name, $p.What)
|
||||
}
|
||||
$payloads[$p.Name] = @{ required = $p.Required; files = $files }
|
||||
}
|
||||
return [ordered]@{
|
||||
schema = 1
|
||||
generated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
pythontag = $PythonTag
|
||||
platform = $Platform
|
||||
note = 'Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.'
|
||||
payloads = $payloads
|
||||
}
|
||||
}
|
||||
|
||||
function Test-BundleLock {
|
||||
<#
|
||||
Compare a staged bundle against a lock. Returns an array of problem
|
||||
strings - EMPTY means the bundle is exactly what the lock describes.
|
||||
|
||||
Returning problems rather than throwing is deliberate: an operator fixing a
|
||||
wheelhouse wants the whole list at once, not one failure per rebuild.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $BundleRoot,
|
||||
[Parameter(Mandatory = $true)] $Lock
|
||||
)
|
||||
$problems = @()
|
||||
$payloads = Get-JsonProperty $Lock 'payloads'
|
||||
# ,@(...) for the same reason as the final return: a bare one-element array
|
||||
# unrolls to a string, and the caller's .Count then measures the wrong thing.
|
||||
if ($null -eq $payloads) { return ,@('bundle-lock.json has no "payloads" section') }
|
||||
|
||||
foreach ($p in $script:BundlePayloads) {
|
||||
$name = $p.Name
|
||||
$dir = Join-Path $BundleRoot $name
|
||||
$present = Test-Path $dir
|
||||
$entry = Get-JsonProperty $payloads $name
|
||||
$locked = ($null -ne $entry)
|
||||
|
||||
if (-not $locked) {
|
||||
# Not in the lock at all. An unlocked directory that exists is a
|
||||
# payload nobody reviewed, which is exactly what this is here to stop.
|
||||
if ($present) { $problems += "$name/ is present but is not in bundle-lock.json - regenerate the lock" }
|
||||
elseif ($p.Required) { $problems += "$name/ is required but is in neither the bundle nor the lock" }
|
||||
continue
|
||||
}
|
||||
if (-not $present) {
|
||||
if ($p.Required -or (Get-JsonProperty $entry 'required' $false)) {
|
||||
$problems += "$name/ is in the lock but missing from the bundle ($($p.What))"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
$expected = @{}
|
||||
$lockedFiles = Get-JsonProperty $entry 'files'
|
||||
if ($null -ne $lockedFiles) {
|
||||
foreach ($prop in $lockedFiles.PSObject.Properties) { $expected[$prop.Name] = $prop.Value }
|
||||
}
|
||||
$actual = Get-PayloadFiles $dir
|
||||
|
||||
# Sorted so the report reads the same way twice, and so it matches the
|
||||
# order verify_bundle_lock.py produces.
|
||||
foreach ($rel in ($expected.Keys | Sort-Object)) {
|
||||
if (-not $actual.ContainsKey($rel)) { $problems += "$name/$rel is in the lock but missing from the bundle"; continue }
|
||||
if ($actual[$rel].sha256 -ne $expected[$rel].sha256) {
|
||||
$problems += "$name/$rel does NOT match the lock (expected sha256 $($expected[$rel].sha256.Substring(0,12))..., got $($actual[$rel].sha256.Substring(0,12))...)"
|
||||
} elseif ([int64] $actual[$rel].size -ne [int64] $expected[$rel].size) {
|
||||
# Cannot happen for a matching sha256, so it means the lock itself
|
||||
# was hand-edited. Say so rather than passing it.
|
||||
$problems += "$name/$rel size disagrees with the lock - the lock has been edited by hand"
|
||||
}
|
||||
}
|
||||
foreach ($rel in ($actual.Keys | Sort-Object)) {
|
||||
if (-not $expected.ContainsKey($rel)) { $problems += "$name/$rel is in the bundle but NOT in the lock (unexpected extra file)" }
|
||||
}
|
||||
}
|
||||
$problems += Test-WheelhouseCoversRequirements -BundleRoot $BundleRoot
|
||||
# The comma keeps this an ARRAY through the return. Without it PowerShell
|
||||
# unrolls an empty result to $null and a single result to a bare string, and
|
||||
# every caller that asks for .Count then behaves differently depending on how
|
||||
# many problems there happen to be.
|
||||
return ,$problems
|
||||
}
|
||||
|
||||
function Test-WheelhouseCoversRequirements {
|
||||
<#
|
||||
The lock records what IS in the wheelhouse, not what the application NEEDS.
|
||||
Without this an incomplete wheelhouse gets locked, blessed, and shipped,
|
||||
and the install fails on an air-gapped server.
|
||||
|
||||
Not hypothetical: assembling the wheelhouse anywhere other than Windows
|
||||
silently omits colorama, a win32-only dependency of click, because pip
|
||||
evaluates environment markers against the machine doing the downloading
|
||||
rather than the machine being targeted. Markers are therefore IGNORED here
|
||||
- a requirement guarded by sys_platform == 'win32' is precisely the one
|
||||
that has to be present.
|
||||
#>
|
||||
param([Parameter(Mandatory = $true)] [string] $BundleRoot)
|
||||
$wheels = Join-Path $BundleRoot 'wheels'
|
||||
$reqs = Join-Path $BundleRoot 'app\requirements.txt'
|
||||
if (-not (Test-Path $wheels) -or -not (Test-Path $reqs)) { return @() }
|
||||
|
||||
$have = @(Get-ChildItem $wheels -File -ErrorAction SilentlyContinue | ForEach-Object { $_.Name.ToLower() })
|
||||
$problems = @()
|
||||
$pins = @{}
|
||||
foreach ($line in (Get-Content $reqs)) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith('#')) { continue }
|
||||
if ($trimmed -match '^([A-Za-z0-9._-]+)==([^\s;\\]+)') {
|
||||
# PEP 427 wheel filename form: runs of non-alphanumerics become one _.
|
||||
$pins[([regex]::Replace($Matches[1], '[^A-Za-z0-9.]+', '_')).ToLower()] = $Matches[2]
|
||||
}
|
||||
}
|
||||
foreach ($name in ($pins.Keys | Sort-Object)) {
|
||||
$prefix = "$name-$($pins[$name])-"
|
||||
if (-not ($have | Where-Object { $_.StartsWith($prefix) })) {
|
||||
$problems += ("wheels/ has no wheel for {0}=={1}, which requirements.txt pins " +
|
||||
"(a marked-out dependency still installs on Windows)") -f $name, $pins[$name]
|
||||
}
|
||||
}
|
||||
return $problems
|
||||
}
|
||||
|
||||
function Read-BundleLock {
|
||||
param([Parameter(Mandatory = $true)] [string] $Path)
|
||||
if (-not (Test-Path $Path)) { return $null }
|
||||
return (Get-Content $Path -Raw | ConvertFrom-Json)
|
||||
}
|
||||
159
deploy/windows/installer/make-branding.py
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Inno Setup wizard artwork from the app's own brand assets.
|
||||
|
||||
Everything here is derived from frontend/public/*.svg so the installer and the
|
||||
running application are visibly the same product. Nothing is redrawn by hand.
|
||||
|
||||
Inno stretches artwork to fit and does not resample well, so render at the exact
|
||||
sizes it asks for and supply the 125%/250% variants for high-DPI displays.
|
||||
|
||||
WizardImageFile 164x314, 192x386, 384x772
|
||||
WizardSmallImageFile 55x55, 64x64, 138x138
|
||||
SetupIconFile .ico with 16/24/32/48/64/128/256
|
||||
|
||||
Usage: python3 make-branding.py [output-dir]
|
||||
"""
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cairosvg
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ASSETS = Path.home() / "projects/shopdb-flask/frontend/public"
|
||||
OUT = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)
|
||||
|
||||
# Sampled from the application's own palette so the installer does not look like
|
||||
# a different product wearing the same badge.
|
||||
NAVY = (10, 34, 74) # deep base
|
||||
BLUE = (16, 74, 150) # GE blue
|
||||
CYAN = (0, 158, 224) # accent
|
||||
WHITE = (255, 255, 255)
|
||||
MUTED = (176, 197, 226)
|
||||
|
||||
|
||||
def render_svg(name, width=None, height=None):
|
||||
png = cairosvg.svg2png(url=str(ASSETS / name), output_width=width, output_height=height)
|
||||
return Image.open(io.BytesIO(png)).convert("RGBA")
|
||||
|
||||
|
||||
def recolour(img, colour):
|
||||
"""Replace RGB while keeping the alpha mask. Source marks are dark-on-light;
|
||||
on a dark panel they must be inverted or they disappear."""
|
||||
solid = Image.new("RGBA", img.size, colour + (255,))
|
||||
solid.putalpha(img.getchannel("A"))
|
||||
return solid
|
||||
|
||||
|
||||
def font(size, bold=False):
|
||||
for path in (
|
||||
f"/usr/share/fonts/truetype/dejavu/DejaVuSans{'-Bold' if bold else ''}.ttf",
|
||||
f"/usr/share/fonts/truetype/liberation/LiberationSans{'-Bold' if bold else '-Regular'}.ttf",
|
||||
):
|
||||
if Path(path).exists():
|
||||
return ImageFont.truetype(path, size)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def vertical_gradient(size, top, bottom):
|
||||
w, h = size
|
||||
img = Image.new("RGB", size)
|
||||
draw = ImageDraw.Draw(img)
|
||||
for y in range(h):
|
||||
t = y / max(1, h - 1)
|
||||
# Ease the ramp so the middle does not look flat.
|
||||
t = t * t * (3 - 2 * t)
|
||||
draw.line(
|
||||
[(0, y), (w, y)],
|
||||
fill=tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)),
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
def banner(w, h):
|
||||
img = vertical_gradient((w, h), BLUE, NAVY)
|
||||
draw = ImageDraw.Draw(img)
|
||||
k = w / 164.0 # scale factor from the 100% design
|
||||
|
||||
# Faint diagonal wash: stops the flat area under the text reading as empty.
|
||||
glow = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||
gd = ImageDraw.Draw(glow)
|
||||
gd.polygon([(0, int(h * 0.52)), (w, int(h * 0.30)), (w, h), (0, h)],
|
||||
fill=(255, 255, 255, 10))
|
||||
img = Image.alpha_composite(img.convert("RGBA"), glow).convert("RGB")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
margin = int(22 * k)
|
||||
|
||||
# GE Aerospace wordmark at the top, above the product name - the corporate
|
||||
# mark leads, the product sits under it. (Previously the bare monogram was
|
||||
# here and the wordmark was stranded at the bottom.)
|
||||
mark_w = w - (margin * 2)
|
||||
mark = render_svg("ge-aerospace-logo.svg", mark_w, int(mark_w * 32 / 138))
|
||||
mark = recolour(mark, WHITE)
|
||||
img.paste(mark, (margin, int(34 * k)), mark)
|
||||
|
||||
# Product name, directly beneath it.
|
||||
y = int(34 * k) + mark.height + int(30 * k)
|
||||
draw.text((margin, y), "ShopDB", font=font(int(26 * k), bold=True), fill=WHITE)
|
||||
y += int(31 * k)
|
||||
|
||||
# Hairline rule, then the descriptor. Cheap way to look considered.
|
||||
draw.rectangle([margin, y, margin + int(30 * k), y + max(1, int(2 * k))], fill=CYAN)
|
||||
y += int(14 * k)
|
||||
for line in ("Asset management", "for the shop floor"):
|
||||
draw.text((margin, y), line, font=font(int(10.5 * k)), fill=MUTED)
|
||||
y += int(15 * k)
|
||||
|
||||
# Accent bar flush to the bottom edge.
|
||||
bar = max(2, int(4 * k))
|
||||
draw.rectangle([0, h - bar, w, h], fill=CYAN)
|
||||
return img
|
||||
|
||||
|
||||
def small(size):
|
||||
"""Header mark on every page after the welcome page. White plate so it sits
|
||||
correctly on the wizard's own header, in light or dark mode."""
|
||||
img = Image.new("RGB", (size, size), WHITE)
|
||||
m = int(size * 0.80)
|
||||
mono = recolour(render_svg("ge-monogram.svg", m, m), BLUE)
|
||||
off = (size - m) // 2
|
||||
img.paste(mono, (off, off), mono)
|
||||
return img
|
||||
|
||||
|
||||
def icon(path):
|
||||
"""Installer icon. Rounded navy tile with the monogram, so it reads at 16px
|
||||
instead of turning into mush."""
|
||||
base = 256
|
||||
img = Image.new("RGBA", (base, base), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rounded_rectangle([0, 0, base - 1, base - 1], radius=int(base * 0.22), fill=BLUE + (255,))
|
||||
d.rounded_rectangle([0, 0, base - 1, int(base * 0.5)], radius=int(base * 0.22),
|
||||
fill=(30, 96, 175, 255))
|
||||
d.rounded_rectangle([0, int(base * 0.3), base - 1, base - 1], radius=int(base * 0.22),
|
||||
fill=BLUE + (255,))
|
||||
m = int(base * 0.62)
|
||||
mono = recolour(render_svg("ge-monogram.svg", m, m), WHITE)
|
||||
img.paste(mono, ((base - m) // 2, (base - m) // 2), mono)
|
||||
img.save(path, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64),
|
||||
(128, 128), (256, 256)])
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
made = []
|
||||
for w, h, name in [(164, 314, "wizard-image.bmp"),
|
||||
(192, 386, "wizard-image@125.bmp"),
|
||||
(384, 772, "wizard-image@250.bmp")]:
|
||||
banner(w, h).save(OUT / name, "BMP"); made.append((name, f"{w}x{h}"))
|
||||
for s, name in [(55, "wizard-small.bmp"), (64, "wizard-small@125.bmp"),
|
||||
(138, "wizard-small@250.bmp")]:
|
||||
small(s).save(OUT / name, "BMP"); made.append((name, f"{s}x{s}"))
|
||||
icon(OUT / "shopdb.ico"); made.append(("shopdb.ico", "multi-size"))
|
||||
for name, dims in made:
|
||||
print(f" {name:<26} {dims:<10} {(OUT / name).stat().st_size // 1024} KB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
110
deploy/windows/installer/refresh-bundle-lock.ps1
Normal file
@@ -0,0 +1,110 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Regenerate bundle-lock.json from the staged bundle, after showing what would
|
||||
change.
|
||||
|
||||
.DESCRIPTION
|
||||
Run this on the machine that assembled the wheelhouse, once the bundle holds
|
||||
the payload you intend to ship:
|
||||
|
||||
bundle\wheels\ the wheels, built with the matching Python
|
||||
bundle\python\ the Python installer
|
||||
bundle\httpplatformhandler\ the IIS module MSI
|
||||
bundle\urlrewrite\ URL Rewrite MSI (optional)
|
||||
bundle\mysql\ MySQL MSI (optional)
|
||||
|
||||
Then COMMIT the resulting bundle-lock.json. That commit is the review: it is
|
||||
the only place a change to what runs as SYSTEM on a customer's server becomes
|
||||
visible to a human. A lock regenerated and committed without reading the diff
|
||||
provides nothing, so this refuses to overwrite an existing lock until you
|
||||
have seen the change and passed -Yes.
|
||||
|
||||
.EXAMPLE
|
||||
.\refresh-bundle-lock.ps1 # show the diff, write nothing
|
||||
.\refresh-bundle-lock.ps1 -Yes # write it
|
||||
|
||||
.NOTES
|
||||
Building the wheelhouse itself no longer requires Windows. From any machine:
|
||||
pip download -r requirements.txt -d wheels --only-binary=:all: `
|
||||
--platform win_amd64 --python-version 314 --implementation cp --abi cp314
|
||||
Do it wherever you like; this script records what came out.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'),
|
||||
[string] $LockPath = (Join-Path $PSScriptRoot 'bundle-lock.json'),
|
||||
[string] $PythonTag = 'cp314',
|
||||
[string] $Platform = 'win_amd64',
|
||||
[switch] $Yes
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'bundle-lock.ps1')
|
||||
|
||||
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
||||
|
||||
if (-not (Test-Path $BundleRoot)) {
|
||||
Say "bundle not found: $BundleRoot" 'Red'
|
||||
Say 'Stage it first with build-installer.ps1 (or build-installer.sh), then add' 'Yellow'
|
||||
Say 'the wheels and installers by hand.' 'Yellow'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Say ''
|
||||
Say " Hashing $BundleRoot" 'Cyan'
|
||||
$new = New-BundleLock -BundleRoot $BundleRoot -PythonTag $PythonTag -Platform $Platform
|
||||
|
||||
foreach ($name in $new.payloads.Keys) {
|
||||
Say (" {0,-22} {1,4} files" -f $name, $new.payloads[$name].files.Count)
|
||||
}
|
||||
|
||||
$old = Read-BundleLock $LockPath
|
||||
if (-not $old) {
|
||||
Say ''
|
||||
Say ' No existing lock - this will be the first one.' 'Yellow'
|
||||
} else {
|
||||
# Diff by file, per payload, so the operator sees exactly which artifacts
|
||||
# changed rather than "the lock is different".
|
||||
Say ''
|
||||
Say ' Changes against the committed lock:' 'Cyan'
|
||||
$changes = 0
|
||||
foreach ($name in $new.payloads.Keys) {
|
||||
$oldFiles = @{}
|
||||
if ($old.payloads.PSObject.Properties.Name -contains $name) {
|
||||
foreach ($p in $old.payloads.$name.files.PSObject.Properties) { $oldFiles[$p.Name] = $p.Value.sha256 }
|
||||
}
|
||||
$newFiles = $new.payloads[$name].files
|
||||
foreach ($rel in ($newFiles.Keys | Sort-Object)) {
|
||||
if (-not $oldFiles.ContainsKey($rel)) { Say " + $name/$rel" 'Green'; $changes++ }
|
||||
elseif ($oldFiles[$rel] -ne $newFiles[$rel].sha256) { Say " ~ $name/$rel (content changed)" 'Yellow'; $changes++ }
|
||||
}
|
||||
foreach ($rel in ($oldFiles.Keys | Sort-Object)) {
|
||||
if (-not $newFiles.ContainsKey($rel)) { Say " - $name/$rel" 'Red'; $changes++ }
|
||||
}
|
||||
}
|
||||
foreach ($p in $old.payloads.PSObject.Properties.Name) {
|
||||
if (-not $new.payloads.Contains($p)) { Say " - $p/ (whole payload gone)" 'Red'; $changes++ }
|
||||
}
|
||||
if ($changes -eq 0) {
|
||||
Say ' none - the bundle already matches the lock' 'Green'
|
||||
exit 0
|
||||
}
|
||||
Say ''
|
||||
Say (" {0} change(s)." -f $changes) 'White'
|
||||
}
|
||||
|
||||
if (-not $Yes) {
|
||||
Say ''
|
||||
Say ' Nothing written. Read the list above, then re-run with -Yes.' 'Yellow'
|
||||
Say ' Commit the resulting bundle-lock.json - that commit IS the review.' 'Yellow'
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ConvertTo-Json defaults to a depth of 2, which silently flattens the per-file
|
||||
# entries into "System.Collections.Hashtable" strings and produces a lock that
|
||||
# verifies against nothing.
|
||||
$new | ConvertTo-Json -Depth 8 | Set-Content -Path $LockPath -Encoding UTF8
|
||||
Say ''
|
||||
Say " Written: $LockPath" 'Green'
|
||||
Say ' Commit it.' 'Green'
|
||||
1045
deploy/windows/installer/shopdb-admin.ps1
Normal file
2733
deploy/windows/installer/shopdb-install.ps1
Normal file
562
deploy/windows/installer/shopdb-preflight.ps1
Normal file
@@ -0,0 +1,562 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
ShopDB-Flask installer - Stage 1: read-only preflight.
|
||||
|
||||
.DESCRIPTION
|
||||
Discovers everything the installer needs to know about this box and reports
|
||||
it. Makes NO changes: no installs, no config edits, no service restarts.
|
||||
Safe to run on a production server.
|
||||
|
||||
Written for stock Windows PowerShell 5.1 (Windows Server ships it). No
|
||||
pwsh-only syntax, no external modules, no network access.
|
||||
|
||||
.PARAMETER SitePort
|
||||
The port the ShopDB site will listen on. Default 8090 (the runbook's example;
|
||||
the classic ASP site keeps 8080).
|
||||
|
||||
.PARAMETER AppRoot
|
||||
Intended install directory. Default C:\shopdb-flask.
|
||||
|
||||
.PARAMETER Json
|
||||
Emit machine-readable JSON instead of the human report. Later installer
|
||||
stages consume this.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1
|
||||
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1 -Json > preflight.json
|
||||
|
||||
.NOTES
|
||||
Exit 0 = no blocking problems. Exit 1 = at least one FAIL.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int] $SitePort = 8090,
|
||||
[string] $AppRoot = 'C:\shopdb-flask',
|
||||
# Needed so the port check can tell OUR site apart from a stranger's.
|
||||
[string] $SiteName = 'shopdb-flask',
|
||||
[switch] $Json,
|
||||
# Machine-readable output for a GUI caller: one record per line,
|
||||
# STATUS|AREA|CHECK|DETAIL|FIX
|
||||
# The console rendering below aligns columns with padding spaces, which only
|
||||
# works in a fixed-width font at console width. A GUI must do its own layout,
|
||||
# so give it DATA and let it decide - do not make it parse a formatted table.
|
||||
# (-Json exists too, but Inno's Pascal Script has no JSON parser.)
|
||||
[switch] $Delimited
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- result collection -------------------------------------------------------
|
||||
# Every check appends one record. Status is PASS / WARN / FAIL / INFO.
|
||||
$script:Results = New-Object System.Collections.ArrayList
|
||||
$script:IisPresent = $false
|
||||
|
||||
function Add-Result {
|
||||
param(
|
||||
[string] $Area,
|
||||
[string] $Check,
|
||||
[ValidateSet('PASS','WARN','FAIL','INFO','SKIP')] [string] $Status,
|
||||
[string] $Detail,
|
||||
[string] $Fix = ''
|
||||
)
|
||||
$null = $script:Results.Add([PSCustomObject]@{
|
||||
Area = $Area
|
||||
Check = $Check
|
||||
Status = $Status
|
||||
Detail = $Detail
|
||||
Fix = $Fix
|
||||
})
|
||||
}
|
||||
|
||||
# Wrap a check so one failure cannot abort the whole run. On an unfamiliar box
|
||||
# an unexpected exception is itself a finding, not a crash.
|
||||
function Invoke-Check {
|
||||
param([string] $Area, [string] $Check, [scriptblock] $Body)
|
||||
try { & $Body }
|
||||
catch {
|
||||
Add-Result $Area $Check 'WARN' "check could not run: $($_.Exception.Message)" `
|
||||
'Report this output; the installer needs to handle this box shape.'
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 1. Operator context
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'System' 'Elevation' {
|
||||
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$adm = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if ($adm) { Add-Result 'System' 'Elevation' 'PASS' 'running as Administrator' }
|
||||
else {
|
||||
Add-Result 'System' 'Elevation' 'FAIL' 'not elevated' `
|
||||
'Re-run PowerShell as Administrator. IIS and service changes require it.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'System' 'Windows version' {
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
$name = $os.Caption
|
||||
$ver = $os.Version
|
||||
# ProductType: 1 = workstation, 2 = domain controller, 3 = server
|
||||
$isServer = ($os.ProductType -ne 1)
|
||||
$detail = "$name (build $ver), $(if ($isServer) {'Server'} else {'Client'})"
|
||||
|
||||
$supported = $false
|
||||
if ($isServer -and [version]$ver -ge [version]'10.0.17763') { $supported = $true } # 2019+
|
||||
if (-not $isServer -and [version]$ver -ge [version]'10.0.19045') { $supported = $true } # Win10 22H2+
|
||||
|
||||
if ($supported) { Add-Result 'System' 'Windows version' 'PASS' $detail }
|
||||
else {
|
||||
Add-Result 'System' 'Windows version' 'FAIL' $detail `
|
||||
'Supported: Windows Server 2019/2022+, or Windows 10 22H2 / 11 Pro+.'
|
||||
}
|
||||
|
||||
# Client SKUs must be Pro/Enterprise/Education for IIS.
|
||||
if (-not $isServer -and $name -match 'Home') {
|
||||
Add-Result 'System' 'Windows edition' 'FAIL' 'Windows Home edition' `
|
||||
'IIS is not available on Home editions. Pro or higher is required.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'System' 'Architecture' {
|
||||
if ([Environment]::Is64BitOperatingSystem) {
|
||||
Add-Result 'System' 'Architecture' 'PASS' '64-bit'
|
||||
} else {
|
||||
Add-Result 'System' 'Architecture' 'FAIL' '32-bit' `
|
||||
'The bundled Python and wheels are 64-bit (win_amd64) only.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'System' 'PowerShell version' {
|
||||
$v = $PSVersionTable.PSVersion
|
||||
Add-Result 'System' 'PowerShell version' 'INFO' "$v"
|
||||
if ($v.Major -lt 5) {
|
||||
Add-Result 'System' 'PowerShell version' 'FAIL' "$v" `
|
||||
'PowerShell 5.1 or later is required.'
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 2. Disk and ports
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'Disk' 'Free space' {
|
||||
$drive = (Split-Path -Qualifier $AppRoot)
|
||||
$d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$drive'"
|
||||
if ($null -eq $d) {
|
||||
Add-Result 'Disk' 'Free space' 'FAIL' "drive $drive not found" `
|
||||
"Choose an -AppRoot on an existing volume."
|
||||
return
|
||||
}
|
||||
$freeGB = [math]::Round($d.FreeSpace / 1GB, 1)
|
||||
$detail = "$freeGB GB free on $drive"
|
||||
if ($freeGB -ge 5) { Add-Result 'Disk' 'Free space' 'PASS' $detail }
|
||||
else { Add-Result 'Disk' 'Free space' 'FAIL' $detail 'At least 5 GB is required.' }
|
||||
}
|
||||
|
||||
Invoke-Check 'Disk' 'AppRoot' {
|
||||
if (Test-Path $AppRoot) {
|
||||
$existing = @(Get-ChildItem $AppRoot -Force -ErrorAction SilentlyContinue)
|
||||
if ($existing.Count -gt 0) {
|
||||
$hasEnv = Test-Path (Join-Path $AppRoot '.env')
|
||||
if ($hasEnv) {
|
||||
# An existing install is the NORMAL state for an upgrade. Reporting
|
||||
# it as a warning makes a routine update look like a problem.
|
||||
$ver = ''
|
||||
$vf = Join-Path $AppRoot '.installed-version'
|
||||
if (Test-Path $vf) { $ver = ' version ' + (Get-Content $vf -TotalCount 1).Trim() }
|
||||
Add-Result 'Disk' 'AppRoot' 'INFO' `
|
||||
"existing ShopDB-Flask install found$ver - it will be upgraded in place, and your settings and database are kept"
|
||||
} else {
|
||||
Add-Result 'Disk' 'AppRoot' 'WARN' "$AppRoot exists and is not empty" `
|
||||
'Confirm this directory is safe to install into.'
|
||||
}
|
||||
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot exists and is empty" }
|
||||
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot does not exist yet" }
|
||||
}
|
||||
|
||||
function Test-PortFree {
|
||||
param([int] $Port)
|
||||
# Get-NetTCPConnection is the reliable listener check on Server 2012R2+.
|
||||
try {
|
||||
$listening = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)
|
||||
return ($listening.Count -eq 0)
|
||||
} catch {
|
||||
# Fall back to a bind attempt if the cmdlet is unavailable.
|
||||
try {
|
||||
$l = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Any, $Port)
|
||||
$l.Start(); $l.Stop(); return $true
|
||||
} catch { return $false }
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'Network' 'Site port' {
|
||||
if (Test-PortFree $SitePort) {
|
||||
Add-Result 'Network' 'Site port' 'PASS' "TCP $SitePort is free"
|
||||
} else {
|
||||
$owner = ''
|
||||
try {
|
||||
$c = Get-NetTCPConnection -State Listen -LocalPort $SitePort -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { $owner = " (pid $($c.OwningProcess): $((Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).ProcessName))" }
|
||||
} catch { }
|
||||
# Is the listener OUR OWN site? On a reinstall or upgrade the port is held
|
||||
# by the very application being upgraded, and blocking on that makes the
|
||||
# installer refuse to update anything it previously installed.
|
||||
$ours = $false
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction SilentlyContinue
|
||||
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||
if ($site) {
|
||||
foreach ($b in $site.bindings.Collection) {
|
||||
# ${} is required: "$SitePort:" parses as a DRIVE-qualified variable.
|
||||
if ($b.bindingInformation -match ":${SitePort}:") { $ours = $true }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
if ($ours) {
|
||||
Add-Result 'Network' 'Site port' 'INFO' `
|
||||
"TCP $SitePort is used by the existing $SiteName site - this will be upgraded in place"
|
||||
} else {
|
||||
# WARN, not FAIL. This check runs before the operator has reached the
|
||||
# Address page, so it is testing the DEFAULT port, not necessarily
|
||||
# the one they intend to use. Blocking here would refuse an install
|
||||
# over a conflict the very next page lets them resolve.
|
||||
Add-Result 'Network' 'Site port' 'WARN' "TCP $SitePort is in use$owner" `
|
||||
"Pick a different port on the Address page later in this wizard, or stop whatever is holding it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 3. IIS
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'IIS' 'Installed' {
|
||||
$svc = Get-Service -Name W3SVC -ErrorAction SilentlyContinue
|
||||
$script:IisPresent = ($null -ne $svc)
|
||||
if ($null -eq $svc) {
|
||||
Add-Result 'IIS' 'Installed' 'FAIL' 'W3SVC service not found' `
|
||||
'Install IIS. Server: Install-WindowsFeature Web-Server -IncludeManagementTools. Client: enable Internet Information Services in Windows Features.'
|
||||
return
|
||||
}
|
||||
Add-Result 'IIS' 'Installed' 'PASS' "W3SVC present, status $($svc.Status)"
|
||||
if ($svc.Status -ne 'Running') {
|
||||
Add-Result 'IIS' 'Running' 'WARN' "W3SVC is $($svc.Status)" 'Start-Service W3SVC'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'WebAdministration module' {
|
||||
$m = Get-Module -ListAvailable -Name WebAdministration
|
||||
if ($m) { Add-Result 'IIS' 'WebAdministration module' 'PASS' 'available' }
|
||||
else {
|
||||
Add-Result 'IIS' 'WebAdministration module' 'FAIL' 'not available' `
|
||||
'Install the IIS management tools (Web-Mgmt-Console / IIS Management Scripts and Tools).'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'HttpPlatformHandler' {
|
||||
# The handler registers itself as a global module. Check the module list.
|
||||
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
|
||||
if (-not (Test-Path $appcmd)) {
|
||||
Add-Result 'IIS' 'HttpPlatformHandler' 'SKIP' 'appcmd.exe not present (IIS not installed)' `
|
||||
'Re-run this preflight after installing IIS.'
|
||||
return
|
||||
}
|
||||
$modules = & $appcmd list module 2>$null
|
||||
if ($modules -match 'httpPlatformHandler') {
|
||||
Add-Result 'IIS' 'HttpPlatformHandler' 'PASS' 'installed'
|
||||
} else {
|
||||
# NOT a blocker: the MSI is in the bundle and stage 4 installs it. This
|
||||
# was a FAIL, which - once the preflight page started blocking on any
|
||||
# failure - stopped the wizard dead over something the installer was
|
||||
# about to do by itself, with no way forward but to go and install it by
|
||||
# hand. Nothing the installer SUPPLIES may be a blocker.
|
||||
Add-Result 'IIS' 'HttpPlatformHandler' 'INFO' 'not installed yet' `
|
||||
'The installer installs it from the bundle. No action needed.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'Locked config sections' {
|
||||
# The authoritative source is applicationHost.config. `appcmd list config
|
||||
# /section:X` prints the section CONTENTS, not its lock state, so grepping
|
||||
# that output silently reports every section as unlocked.
|
||||
$cfg = Join-Path $env:windir 'system32\inetsrv\config\applicationHost.config'
|
||||
if (-not (Test-Path $cfg)) {
|
||||
Add-Result 'IIS' 'Locked config sections' 'SKIP' 'applicationHost.config not found'
|
||||
return
|
||||
}
|
||||
foreach ($name in @('handlers','httpPlatform')) {
|
||||
$line = Select-String -Path $cfg -Pattern ('<section name="' + $name + '"') |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $line) {
|
||||
# httpPlatform is registered by the HttpPlatformHandler MSI. Before
|
||||
# that, `appcmd unlock config /section:system.webServer/httpPlatform`
|
||||
# fails with "Unknown config section".
|
||||
Add-Result 'IIS' "Section $name" 'SKIP' 'section not registered yet' `
|
||||
'Install HttpPlatformHandler FIRST; only then can this section be unlocked.'
|
||||
} elseif ($line.Line -match 'overrideModeDefault="Deny"') {
|
||||
Add-Result 'IIS' "Section $name" 'WARN' 'locked (overrideModeDefault="Deny")' `
|
||||
"Installer must run: appcmd unlock config /section:system.webServer/$name (else IIS 500.19)"
|
||||
} else {
|
||||
Add-Result 'IIS' "Section $name" 'PASS' 'not locked'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'URL Rewrite module' {
|
||||
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
|
||||
if (-not (Test-Path $appcmd)) {
|
||||
Add-Result 'IIS' 'URL Rewrite module' 'SKIP' 'IIS not installed; cannot check'
|
||||
return
|
||||
}
|
||||
$modules = & $appcmd list module 2>$null
|
||||
if ($modules -match 'RewriteModule') {
|
||||
Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (the X-Forwarded-For rule can be enabled)'
|
||||
} else {
|
||||
# Not a FAIL: it is only needed for -ClientIpSource direct, and the
|
||||
# installer carries the MSI and installs it itself. Worth reporting
|
||||
# because without the rule IIS sends no X-Forwarded-For at all, so every
|
||||
# client reads as 127.0.0.1 and the GE-Enforce IP allowlist, the
|
||||
# visitor-location lookup and per-host login rate limiting go quiet.
|
||||
Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' `
|
||||
'The installer installs it from the bundle when -ClientIpSource is direct. Behind a reverse proxy that already sets X-Forwarded-For, use -ClientIpSource proxy and leave it out.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'Existing sites' {
|
||||
if (-not $script:IisPresent) {
|
||||
Add-Result 'IIS' 'Existing sites' 'SKIP' 'IIS not installed'
|
||||
return
|
||||
}
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction Stop
|
||||
$sites = @(Get-Website)
|
||||
if ($sites.Count -eq 0) { Add-Result 'IIS' 'Existing sites' 'INFO' 'none' ; return }
|
||||
$desc = ($sites | ForEach-Object {
|
||||
$b = ($_.bindings.Collection | ForEach-Object { $_.bindingInformation }) -join ','
|
||||
"$($_.Name) [$($_.State)] $b"
|
||||
}) -join '; '
|
||||
Add-Result 'IIS' 'Existing sites' 'INFO' $desc
|
||||
# Adoption sites typically run the classic ASP shopdb here already.
|
||||
if ($desc -match '8080') {
|
||||
Add-Result 'IIS' 'Classic ASP site' 'INFO' 'a site is bound on 8080 (likely the classic ASP shopdb)' `
|
||||
'Install ShopDB as a separate site on its own port; do not disturb this one.'
|
||||
}
|
||||
} catch {
|
||||
Add-Result 'IIS' 'Existing sites' 'WARN' "could not enumerate: $($_.Exception.Message)" ''
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 4. MySQL (detect BEFORE offering bundled vs existing)
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'MySQL' 'Service' {
|
||||
$svcs = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^MySQL' -or $_.DisplayName -match 'MySQL' })
|
||||
if ($svcs.Count -eq 0) {
|
||||
Add-Result 'MySQL' 'Service' 'INFO' 'no MySQL service found' `
|
||||
'Bundled MySQL 8.4 LTS is the appropriate choice on this box.'
|
||||
return
|
||||
}
|
||||
foreach ($s in $svcs) {
|
||||
Add-Result 'MySQL' 'Service' 'WARN' "$($s.Name) ($($s.DisplayName)) is $($s.Status)" `
|
||||
'MySQL already present. Default to the EXISTING-server option; installing bundled MySQL will collide on port 3306.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'MySQL' 'Port 3306' {
|
||||
if (Test-PortFree 3306) {
|
||||
Add-Result 'MySQL' 'Port 3306' 'INFO' 'nothing listening on 3306'
|
||||
} else {
|
||||
Add-Result 'MySQL' 'Port 3306' 'WARN' 'something is listening on 3306' `
|
||||
'Bundled MySQL cannot use the default port. Use the existing server, or pick another port.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'MySQL' 'Backup client' {
|
||||
# mysqldump is what takes the mandatory pre-upgrade backup. Without it every
|
||||
# upgrade skips the backup - and skips it AFTER the application pool has been
|
||||
# stopped and the tree replaced, so the site is down and there is nothing to
|
||||
# restore from. A site whose database is on another server typically has no
|
||||
# MySQL client installed here at all, which is exactly the case that needs it.
|
||||
$names = @('mysqldump.exe')
|
||||
$found = ''
|
||||
foreach ($root in @((Join-Path $PSScriptRoot 'mysqlclient'),
|
||||
(Join-Path $AppRoot 'mysqlclient'),
|
||||
'C:\MySQL84', 'C:\Program Files\MySQL', 'C:\mysql56\bin',
|
||||
'C:\Program Files (x86)\MySQL')) {
|
||||
if (-not (Test-Path $root)) { continue }
|
||||
$hit = Get-ChildItem $root -Filter $names[0] -Recurse -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($hit) { $found = $hit.FullName; break }
|
||||
}
|
||||
if ($found) {
|
||||
Add-Result 'MySQL' 'Backup client' 'PASS' "mysqldump found ($found)"
|
||||
} else {
|
||||
Add-Result 'MySQL' 'Backup client' 'WARN' 'mysqldump not found on this server' `
|
||||
'Needed for the automatic pre-upgrade backup and for "shopdb-admin.ps1 backup". A first install works without it; upgrades will not be protected. It is not on this server yet - the installer stages its own copy, so this normally resolves itself during installation.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'MySQL' 'Version and config' {
|
||||
# Find mysqld.exe via the service binary path; read the version and locate my.ini.
|
||||
$svc = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.PathName -match 'mysqld' } | Select-Object -First 1
|
||||
if ($null -eq $svc) { return }
|
||||
|
||||
$path = $svc.PathName
|
||||
$exe = ''
|
||||
if ($path -match '"([^"]+mysqld[^"]*)"') { $exe = $matches[1] }
|
||||
elseif ($path -match '(\S+mysqld\S*)') { $exe = $matches[1] }
|
||||
|
||||
$ver = ''
|
||||
if ($exe -and (Test-Path $exe)) {
|
||||
try { $ver = (& $exe --version 2>$null | Out-String).Trim() } catch { }
|
||||
}
|
||||
if ($ver) { Add-Result 'MySQL' 'Version' 'INFO' $ver }
|
||||
|
||||
# my.ini path is passed as --defaults-file in the service command line.
|
||||
$ini = ''
|
||||
if ($path -match '--defaults-file="?([^"]+\.ini)"?') { $ini = $matches[1] }
|
||||
if ($ini -and (Test-Path $ini)) {
|
||||
Add-Result 'MySQL' 'Config file' 'INFO' $ini
|
||||
|
||||
# MySQL 5.6 needs three flags or `flask db upgrade` dies with error 1071.
|
||||
$is56 = ($ver -match '\b5\.6\.')
|
||||
if ($is56) {
|
||||
$content = Get-Content $ini -Raw
|
||||
$need = @('innodb_file_per_table','innodb_file_format','innodb_large_prefix')
|
||||
$missing = @()
|
||||
foreach ($k in $need) { if ($content -notmatch $k) { $missing += $k } }
|
||||
if ($missing.Count -eq 0) {
|
||||
# Present in the FILE is not the same as ACTIVE. MySQL must be
|
||||
# restarted for them to take effect, and the app's own
|
||||
# `flask db-utils preflight` queries the live server - trust that.
|
||||
Add-Result 'MySQL' '5.6 index flags' 'WARN' 'all three present in my.ini' `
|
||||
'Present in the file only. They take effect after a MySQL RESTART, which interrupts the classic ASP app. Confirm with SHOW VARIABLES or flask db-utils preflight.'
|
||||
} else {
|
||||
# WARN, not FAIL. This inspects the LOCAL MySQL, which may not be the
|
||||
# database the operator is about to install against - a bundled 8.4,
|
||||
# or a remote server. Blocking the wizard here refused an install
|
||||
# over a server that had nothing to do with it. Stage 3 runs
|
||||
# 'flask db-utils preflight' against the database actually chosen,
|
||||
# which is the check that can genuinely block.
|
||||
Add-Result 'MySQL' '5.6 index flags' 'WARN' ("missing: " + ($missing -join ', ')) `
|
||||
"Add to [mysqld] in $ini and restart MySQL, or 'flask db upgrade' fails with error 1071. NOTE: restarting interrupts the classic ASP app."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 5. Python (detect, but the installer uses its OWN bundled interpreter)
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'Python' 'On PATH' {
|
||||
$cmd = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($null -eq $cmd) {
|
||||
Add-Result 'Python' 'On PATH' 'INFO' 'no python on PATH' `
|
||||
'Expected. The installer supplies its own interpreter.'
|
||||
return
|
||||
}
|
||||
$v = ''
|
||||
try { $v = (& $cmd.Source --version 2>&1 | Out-String).Trim() } catch { }
|
||||
$detail = "$v at $($cmd.Source)"
|
||||
|
||||
# A per-user install under %LOCALAPPDATA% is unreadable by the IIS app-pool
|
||||
# identity. That produces a 500 with an empty HttpPlatform log.
|
||||
if ($cmd.Source -like "$env:LOCALAPPDATA*") {
|
||||
Add-Result 'Python' 'On PATH' 'WARN' "$detail (PER-USER install)" `
|
||||
'The IIS app-pool identity cannot read %LOCALAPPDATA%. The installer must install Python for ALL USERS and use absolute paths.'
|
||||
} elseif ($cmd.Source -like '*WindowsApps*') {
|
||||
Add-Result 'Python' 'On PATH' 'WARN' "$detail (Microsoft Store)" `
|
||||
'Store Python misbehaves under service identities. The installer will not use it.'
|
||||
} else {
|
||||
Add-Result 'Python' 'On PATH' 'INFO' $detail `
|
||||
'Not used by the installer, but a manual `flask` command later would resolve to this interpreter.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'Python' 'Registered installs' {
|
||||
$found = @()
|
||||
foreach ($hive in @('HKLM:\SOFTWARE\Python\PythonCore','HKCU:\SOFTWARE\Python\PythonCore')) {
|
||||
if (Test-Path $hive) {
|
||||
foreach ($k in Get-ChildItem $hive -ErrorAction SilentlyContinue) {
|
||||
$ip = Join-Path $k.PSPath 'InstallPath'
|
||||
if (Test-Path $ip) {
|
||||
$loc = (Get-ItemProperty $ip -ErrorAction SilentlyContinue).'(default)'
|
||||
$scope = if ($hive -like 'HKLM*') { 'all-users' } else { 'per-user' }
|
||||
$found += "$($k.PSChildName) ($scope) $loc"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($found.Count -eq 0) { Add-Result 'Python' 'Registered installs' 'INFO' 'none' }
|
||||
else { Add-Result 'Python' 'Registered installs' 'INFO' ($found -join '; ') }
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Report
|
||||
# =============================================================================
|
||||
|
||||
$fails = @($script:Results | Where-Object { $_.Status -eq 'FAIL' })
|
||||
$warns = @($script:Results | Where-Object { $_.Status -eq 'WARN' })
|
||||
$skips = @($script:Results | Where-Object { $_.Status -eq 'SKIP' })
|
||||
|
||||
if ($Delimited) {
|
||||
# Data only. No padding, no colour, no alignment - the caller lays it out.
|
||||
# Pipes are stripped from field values so the record can be split naively.
|
||||
foreach ($r in $script:Results) {
|
||||
$fix = ''
|
||||
if ($r.Fix) { $fix = $r.Fix }
|
||||
$fields = @($r.Status, $r.Area, $r.Check, $r.Detail, $fix) | ForEach-Object {
|
||||
([string]$_) -replace '\|', '/' -replace '\s*\r?\n\s*', ' '
|
||||
}
|
||||
Write-Output ($fields -join '|')
|
||||
}
|
||||
} elseif ($Json) {
|
||||
[PSCustomObject]@{
|
||||
Timestamp = (Get-Date).ToString('s')
|
||||
Computer = $env:COMPUTERNAME
|
||||
SitePort = $SitePort
|
||||
AppRoot = $AppRoot
|
||||
Failures = $fails.Count
|
||||
Warnings = $warns.Count
|
||||
Skipped = $skips.Count
|
||||
Results = $script:Results
|
||||
} | ConvertTo-Json -Depth 5
|
||||
} else {
|
||||
Write-Host ''
|
||||
Write-Host 'ShopDB-Flask preflight' -ForegroundColor Cyan
|
||||
Write-Host (" host {0} site port {1} approot {2}" -f $env:COMPUTERNAME, $SitePort, $AppRoot)
|
||||
Write-Host ''
|
||||
$area = ''
|
||||
foreach ($r in $script:Results) {
|
||||
if ($r.Area -ne $area) { $area = $r.Area; Write-Host "[$area]" -ForegroundColor White }
|
||||
$colour = 'Gray'
|
||||
if ($r.Status -eq 'PASS') { $colour = 'Green' }
|
||||
if ($r.Status -eq 'WARN') { $colour = 'Yellow' }
|
||||
if ($r.Status -eq 'FAIL') { $colour = 'Red' }
|
||||
if ($r.Status -eq 'SKIP') { $colour = 'DarkGray' }
|
||||
Write-Host (" {0,-5} {1,-28} {2}" -f $r.Status, $r.Check, $r.Detail) -ForegroundColor $colour
|
||||
if ($r.Fix -and $r.Status -ne 'PASS' -and $r.Status -ne 'INFO' -and $r.Status -ne 'SKIP') {
|
||||
Write-Host (" -> {0}" -f $r.Fix) -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
Write-Host ''
|
||||
if ($fails.Count -eq 0) {
|
||||
Write-Host "No blocking problems. $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "$($fails.Count) blocking problem(s), $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Red
|
||||
}
|
||||
if ($skips.Count -gt 0) {
|
||||
Write-Host " Skipped checks were NOT verified. Re-run once their prerequisite is installed." -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
if ($fails.Count -gt 0) { exit 1 } else { exit 0 }
|
||||
BIN
deploy/windows/installer/shopdb.ico
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
deploy/windows/installer/swatch-bad.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
deploy/windows/installer/swatch-ok.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
deploy/windows/installer/swatch-warn.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
130
deploy/windows/installer/tests/test-install-state.ps1
Normal file
@@ -0,0 +1,130 @@
|
||||
# Tests the installer's durable install record.
|
||||
#
|
||||
# The record answers one question: "is this a re-run of MY install?". Getting it
|
||||
# wrong is destructive in one direction - answering "first provisioning" for a
|
||||
# server holding real data lets prune-schema --force loose on it - and a dead
|
||||
# end in the other, which is what it was built to fix.
|
||||
#
|
||||
# Run directly: pwsh -File test-install-state.ps1
|
||||
# pytest runs it through tests/test_installer_state.py wherever pwsh exists.
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Installer = Join-Path (Split-Path -Parent $Here) 'shopdb-install.ps1'
|
||||
if (-not (Test-Path $Installer)) { throw "installer not found at $Installer" }
|
||||
|
||||
$Sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ('shopdb-state-' + [System.Guid]::NewGuid().ToString('N'))
|
||||
|
||||
# Load ONLY the state block, so the rest of the installer does not run. Bounded
|
||||
# by the same markers the installer uses, and it fails loudly if either moves
|
||||
# rather than silently testing nothing.
|
||||
$lines = Get-Content $Installer
|
||||
$start = ($lines | Select-String -Pattern '^\$script:StateFile ' | Select-Object -First 1)
|
||||
$end = ($lines | Select-String -Pattern '^function Invoke-Native' | Select-Object -First 1)
|
||||
if (-not $start -or -not $end) { throw 'could not locate the install-record block in shopdb-install.ps1' }
|
||||
$block = $lines[($start.LineNumber - 1)..($end.LineNumber - 2)] -join "`n"
|
||||
if ($block -notmatch 'function Test-FirstProvisioning') { throw 'extracted block does not contain the state functions' }
|
||||
|
||||
# Stand-ins for the surrounding script.
|
||||
$script:Created = New-Object System.Collections.ArrayList
|
||||
function Write-Log { param($m, $l = 'INFO') }
|
||||
function Protect-File { param([string] $Path, [switch] $Directory) }
|
||||
function Test-SamePath {
|
||||
param([string] $A, [string] $B)
|
||||
if (-not $A) { return -not $B }
|
||||
return ($A.TrimEnd('\', '/').ToLowerInvariant() -eq $B.TrimEnd('\', '/').ToLowerInvariant())
|
||||
}
|
||||
if (-not $env:ProgramData) { $env:ProgramData = [System.IO.Path]::GetTempPath() }
|
||||
|
||||
Invoke-Expression $block
|
||||
|
||||
$script:Failures = 0
|
||||
function Check {
|
||||
param([string] $Name, $Expected, $Actual)
|
||||
if ($Expected -eq $Actual) {
|
||||
Write-Host (" PASS " + $Name)
|
||||
} else {
|
||||
Write-Host (" FAIL {0}: expected [{1}], got [{2}]" -f $Name, $Expected, $Actual)
|
||||
$script:Failures++
|
||||
}
|
||||
}
|
||||
|
||||
function Reset-Case {
|
||||
param([switch] $Stamped)
|
||||
if (Test-Path $Sandbox) { Remove-Item $Sandbox -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $Sandbox -Force | Out-Null
|
||||
$script:AppRoot = $Sandbox
|
||||
$script:StateFile = Join-Path $Sandbox 'install-state.json'
|
||||
$script:InstallState = $null
|
||||
if ($Stamped) { Set-Content (Join-Path $Sandbox '.installed-version') '0.7.0' }
|
||||
}
|
||||
|
||||
Write-Host 'brand new server'
|
||||
Reset-Case
|
||||
Initialize-InstallState
|
||||
Check 'first provisioning' $true (Test-FirstProvisioning)
|
||||
Check 'record written' $true (Test-Path $script:StateFile)
|
||||
|
||||
Write-Host 'retry of a failed first install'
|
||||
$script:InstallState = $null
|
||||
Initialize-InstallState
|
||||
Check 'still first provisioning' $true (Test-FirstProvisioning)
|
||||
|
||||
Write-Host 'genuine upgrade after a completed install'
|
||||
Complete-InstallState
|
||||
$script:InstallState = $null
|
||||
Initialize-InstallState
|
||||
Check 'no longer first provisioning' $false (Test-FirstProvisioning)
|
||||
|
||||
Write-Host 'install predating the record - must take the safe side'
|
||||
Reset-Case -Stamped
|
||||
Initialize-InstallState
|
||||
Check 'treated as established' $false (Test-FirstProvisioning)
|
||||
|
||||
Write-Host 'record naming a different directory'
|
||||
Reset-Case
|
||||
Initialize-InstallState
|
||||
$other = Get-Content $script:StateFile -Raw | ConvertFrom-Json
|
||||
$other.approot = 'C:\somewhere-else'
|
||||
($other | ConvertTo-Json -Depth 6) | Set-Content $script:StateFile
|
||||
$script:InstallState = $null
|
||||
Check 'foreign record rejected' $null (Get-InstallState)
|
||||
|
||||
Write-Host 'corrupt record'
|
||||
Reset-Case
|
||||
Set-Content $script:StateFile '{ this is not json'
|
||||
$script:InstallState = $null
|
||||
Check 'corrupt record rejected' $null (Get-InstallState)
|
||||
Reset-Case
|
||||
Set-Content $script:StateFile '{ this is not json'
|
||||
Initialize-InstallState
|
||||
Check 'recovers and rewrites' $true ($null -ne $script:InstallState)
|
||||
|
||||
Write-Host 'created items survive a crash'
|
||||
Reset-Case
|
||||
Initialize-InstallState
|
||||
Track 'site' 'shopdb-flask'
|
||||
Track 'apppool' 'shopdbflask'
|
||||
Track 'site' 'shopdb-flask'
|
||||
$script:InstallState = $null
|
||||
Initialize-InstallState
|
||||
Check 'site remembered' 'shopdb-flask' ((Get-CreatedItems 'site') -join ',')
|
||||
Check 'apppool remembered' 'shopdbflask' ((Get-CreatedItems 'apppool') -join ',')
|
||||
# A zero-length array returned from a function unrolls to $null, and $null.Count
|
||||
# is fatal under StrictMode. This is the check that catches losing the comma.
|
||||
Check 'unknown kind is an empty array' 0 ((Get-CreatedItems 'firewall')).Count
|
||||
|
||||
Write-Host 'no record at all'
|
||||
$script:InstallState = $null
|
||||
Check 'empty list, not null' 0 ((Get-CreatedItems 'site')).Count
|
||||
Check 'not first provisioning' $false (Test-FirstProvisioning)
|
||||
|
||||
if (Test-Path $Sandbox) { Remove-Item $Sandbox -Recurse -Force }
|
||||
if ($script:Failures -gt 0) {
|
||||
Write-Host ("{0} check(s) failed" -f $script:Failures)
|
||||
exit 1
|
||||
}
|
||||
Write-Host 'all checks passed'
|
||||
exit 0
|
||||
171
deploy/windows/installer/verify_bundle_lock.py
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check a staged installer bundle against bundle-lock.json.
|
||||
|
||||
Prints one line per problem and exits non-zero if there are any. Exits 0 only
|
||||
when the bundle's third-party payload is EXACTLY what the lock describes: no
|
||||
missing file, no unexpected extra file, no changed content.
|
||||
|
||||
Why this exists alongside bundle-lock.ps1, which does the same job:
|
||||
|
||||
- bundle-lock.ps1 is canonical. It runs at INSTALL time on the target server,
|
||||
where PowerShell is the only thing guaranteed to be present - Python is not
|
||||
installed until stage 2, and verifying the payload after running part of it
|
||||
would defeat the purpose.
|
||||
- This file lets the Linux builder (build-installer.sh) do the same check
|
||||
without adding pwsh as a build dependency.
|
||||
|
||||
The two are kept honest by tests/test_bundle_lock.py, which runs BOTH against
|
||||
the same fixtures and fails if they disagree.
|
||||
|
||||
Usage: verify_bundle_lock.py <bundle-root> <bundle-lock.json>
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Must match $script:BundlePayloads in bundle-lock.ps1.
|
||||
PAYLOADS = [
|
||||
('wheels', True, 'Python wheels for the offline install'),
|
||||
('python', True, 'the Python installer'),
|
||||
('httpplatformhandler', True, 'the IIS module that launches waitress'),
|
||||
('urlrewrite', False, 'IIS URL Rewrite, for the client-IP rule'),
|
||||
('mysqlclient', False, 'mysql/mysqldump, for backups against a remote database'),
|
||||
('vcredist', False, 'the Visual C++ runtime MySQL requires'),
|
||||
('mysql', False, 'MySQL, for the bundled-database option'),
|
||||
]
|
||||
|
||||
|
||||
def digest(path):
|
||||
sha = hashlib.sha256()
|
||||
with open(path, 'rb') as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b''):
|
||||
sha.update(chunk)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def payload_files(directory):
|
||||
"""Every file under the directory, keyed by forward-slashed relative path."""
|
||||
found = {}
|
||||
if not os.path.isdir(directory):
|
||||
return found
|
||||
for root, _dirs, files in os.walk(directory):
|
||||
for name in files:
|
||||
full = os.path.join(root, name)
|
||||
rel = os.path.relpath(full, directory).replace(os.sep, '/')
|
||||
found[rel] = {'sha256': digest(full), 'size': os.path.getsize(full)}
|
||||
return found
|
||||
|
||||
|
||||
def normalize(name):
|
||||
"""PEP 427 wheel filename form: runs of non-alphanumerics become one _."""
|
||||
return re.sub(r'[^A-Za-z0-9.]+', '_', name).lower()
|
||||
|
||||
|
||||
def requirement_pins(requirements_path):
|
||||
"""Every 'name==version' pinned in a lockfile, including marked-out ones.
|
||||
|
||||
Markers are deliberately IGNORED. A requirement guarded by
|
||||
sys_platform == 'win32' is exactly the case that must be present, because the
|
||||
target is Windows and the wheelhouse is usually assembled somewhere else.
|
||||
"""
|
||||
pins = {}
|
||||
with open(requirements_path) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line)
|
||||
if match:
|
||||
pins[normalize(match.group(1))] = match.group(2)
|
||||
return pins
|
||||
|
||||
|
||||
def check_wheelhouse_covers_requirements(bundle_root):
|
||||
"""The lock records what IS in the wheelhouse, not what the app NEEDS.
|
||||
|
||||
Without this, an incomplete wheelhouse gets locked and blessed, and the
|
||||
install fails on an air-gapped server. That is not hypothetical: assembling
|
||||
the wheelhouse on Linux silently omits colorama, a win32-only dependency of
|
||||
click, because pip evaluates environment markers against the machine doing
|
||||
the downloading rather than the machine being targeted.
|
||||
"""
|
||||
wheels = os.path.join(bundle_root, 'wheels')
|
||||
requirements = os.path.join(bundle_root, 'app', 'requirements.txt')
|
||||
if not os.path.isdir(wheels) or not os.path.exists(requirements):
|
||||
return []
|
||||
have = os.listdir(wheels)
|
||||
problems = []
|
||||
for name, version in sorted(requirement_pins(requirements).items()):
|
||||
prefix = '%s-%s-' % (name, version)
|
||||
if not any(f.lower().startswith(prefix) for f in have):
|
||||
problems.append(
|
||||
'wheels/ has no wheel for %s==%s, which requirements.txt pins '
|
||||
'(a marked-out dependency still installs on Windows)' % (name, version))
|
||||
return problems
|
||||
|
||||
|
||||
def verify(bundle_root, lock):
|
||||
problems = []
|
||||
locked = lock.get('payloads')
|
||||
if not locked:
|
||||
return ['bundle-lock.json has no "payloads" section']
|
||||
|
||||
for name, required, what in PAYLOADS:
|
||||
directory = os.path.join(bundle_root, name)
|
||||
present = os.path.isdir(directory)
|
||||
if name not in locked:
|
||||
if present:
|
||||
problems.append(
|
||||
'%s/ is present but is not in bundle-lock.json - regenerate the lock' % name)
|
||||
elif required:
|
||||
problems.append(
|
||||
'%s/ is required but is in neither the bundle nor the lock' % name)
|
||||
continue
|
||||
if not present:
|
||||
if required or locked[name].get('required'):
|
||||
problems.append('%s/ is in the lock but missing from the bundle (%s)' % (name, what))
|
||||
continue
|
||||
|
||||
expected = locked[name].get('files', {})
|
||||
actual = payload_files(directory)
|
||||
for rel, want in sorted(expected.items()):
|
||||
got = actual.get(rel)
|
||||
if got is None:
|
||||
problems.append('%s/%s is in the lock but missing from the bundle' % (name, rel))
|
||||
elif got['sha256'] != want['sha256']:
|
||||
problems.append(
|
||||
'%s/%s does NOT match the lock (expected sha256 %s..., got %s...)'
|
||||
% (name, rel, want['sha256'][:12], got['sha256'][:12]))
|
||||
elif int(got['size']) != int(want['size']):
|
||||
# Impossible for a matching sha256, so the lock was hand-edited.
|
||||
problems.append(
|
||||
'%s/%s size disagrees with the lock - the lock has been edited by hand'
|
||||
% (name, rel))
|
||||
for rel in sorted(actual):
|
||||
if rel not in expected:
|
||||
problems.append(
|
||||
'%s/%s is in the bundle but NOT in the lock (unexpected extra file)'
|
||||
% (name, rel))
|
||||
problems.extend(check_wheelhouse_covers_requirements(bundle_root))
|
||||
return problems
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit('usage: verify_bundle_lock.py <bundle-root> <bundle-lock.json>')
|
||||
bundle_root, lock_path = sys.argv[1], sys.argv[2]
|
||||
if not os.path.exists(lock_path):
|
||||
print('no bundle-lock.json at %s' % lock_path)
|
||||
return 1
|
||||
with open(lock_path) as fh:
|
||||
lock = json.load(fh)
|
||||
problems = verify(bundle_root, lock)
|
||||
for problem in problems:
|
||||
print(problem)
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
BIN
deploy/windows/installer/wizard-image.bmp
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
deploy/windows/installer/wizard-image@125.bmp
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
deploy/windows/installer/wizard-image@250.bmp
Normal file
|
After Width: | Height: | Size: 869 KiB |
BIN
deploy/windows/installer/wizard-small.bmp
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
deploy/windows/installer/wizard-small@125.bmp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
deploy/windows/installer/wizard-small@250.bmp
Normal file
|
After Width: | Height: | Size: 56 KiB |
422
deploy/windows/shopdb-diagnose.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Collect everything needed to diagnose a ShopDB-Flask stage 5 failure.
|
||||
|
||||
Stage 5 is the smoke test: the installer asks IIS for the site and expects 200.
|
||||
When it does not get one, the cause is always in one of four places, and this
|
||||
script reads all four in one pass so the answer arrives in a single round trip:
|
||||
|
||||
1. What IIS actually answers, and with which status code and error page.
|
||||
2. Whether the config sections httpPlatformHandler needs are unlocked.
|
||||
3. Whether the app-pool identity can read the app and run its venv.
|
||||
4. Whether the app itself imports and starts.
|
||||
|
||||
Run it ON THE SERVER, as Administrator:
|
||||
|
||||
C:\\Python314\\python.exe shopdb-diagnose.py
|
||||
|
||||
It writes shopdb-diagnose-<timestamp>.txt next to itself and prints the path.
|
||||
Send that file back.
|
||||
|
||||
SECRETS: the report never contains them. Values from .env (database password,
|
||||
SECRET_KEY, JWT_SECRET_KEY) are read first, then scrubbed out of every section
|
||||
of the report before it is written, including command output and tracebacks
|
||||
that might quote them.
|
||||
|
||||
Standard library only, so it runs on the bundled runtime or any system Python.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
APP_ROOT = os.environ.get('SHOPDB_APPROOT', r'C:\shopdb-flask')
|
||||
ALIAS = 'shopdb'
|
||||
TIMEOUT = 25
|
||||
|
||||
# Filled from .env, then scrubbed from the whole report.
|
||||
SECRETS = []
|
||||
|
||||
WINDIR = os.environ.get('WINDIR', r'C:\Windows')
|
||||
# Sysnative gives a 32-bit process the real 64-bit System32. Harmless on 64-bit.
|
||||
APPCMD_CANDIDATES = [
|
||||
os.path.join(WINDIR, 'Sysnative', 'inetsrv', 'appcmd.exe'),
|
||||
os.path.join(WINDIR, 'System32', 'inetsrv', 'appcmd.exe'),
|
||||
]
|
||||
|
||||
|
||||
def find_appcmd():
|
||||
for path in APPCMD_CANDIDATES:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
class Report(object):
|
||||
def __init__(self):
|
||||
self.chunks = []
|
||||
|
||||
def head(self, title):
|
||||
self.chunks.append('\n' + '=' * 72 + '\n' + title + '\n' + '=' * 72)
|
||||
|
||||
def line(self, text=''):
|
||||
self.chunks.append(str(text))
|
||||
|
||||
def block(self, title, body):
|
||||
self.chunks.append('\n--- %s ---' % title)
|
||||
if body is None or str(body).strip() == '':
|
||||
self.chunks.append('(no output)')
|
||||
else:
|
||||
self.chunks.append(str(body).rstrip())
|
||||
|
||||
def text(self):
|
||||
return '\n'.join(self.chunks) + '\n'
|
||||
|
||||
|
||||
def run(cmd, timeout=60):
|
||||
"""Run a command, return combined output. Never raises."""
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, shell=False)
|
||||
out, _ = proc.communicate(timeout=timeout)
|
||||
text = out.decode('utf-8', 'replace') if out else ''
|
||||
return '[exit %s]\n%s' % (proc.returncode, text)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return '[TIMED OUT after %ss]' % timeout
|
||||
except Exception as exc:
|
||||
return '[could not run: %s]' % exc
|
||||
|
||||
|
||||
def powershell(script, timeout=90):
|
||||
exe = os.path.join(WINDIR, 'Sysnative', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
||||
if not os.path.isfile(exe):
|
||||
exe = 'powershell.exe'
|
||||
return run([exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
||||
timeout=timeout)
|
||||
|
||||
|
||||
def load_secrets():
|
||||
"""Read .env so its secret VALUES can be scrubbed from the report."""
|
||||
env_path = os.path.join(APP_ROOT, '.env')
|
||||
found = {}
|
||||
if not os.path.isfile(env_path):
|
||||
return found, None
|
||||
try:
|
||||
with open(env_path, 'r', encoding='utf-8', errors='replace') as handle:
|
||||
raw = handle.read()
|
||||
except Exception as exc:
|
||||
return found, '[could not read .env: %s]' % exc
|
||||
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
key, value = line.split('=', 1)
|
||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||
found[key] = value
|
||||
if not value:
|
||||
continue
|
||||
upper = key.upper()
|
||||
if 'SECRET' in upper or 'PASSWORD' in upper or 'TOKEN' in upper or 'KEY' in upper:
|
||||
SECRETS.append(value)
|
||||
if upper == 'DATABASE_URL':
|
||||
# mysql+pymysql://user:PASSWORD@host/db -- the password only.
|
||||
match = re.match(r'^[^:]+://([^:@/]+):([^@]+)@', value)
|
||||
if match:
|
||||
SECRETS.append(match.group(2))
|
||||
return found, raw
|
||||
|
||||
|
||||
def scrub(text):
|
||||
"""Remove every known secret value from the report."""
|
||||
for secret in SECRETS:
|
||||
if secret and len(secret) >= 4:
|
||||
text = text.replace(secret, '<REDACTED>')
|
||||
# Catch a password inside any URL that did not come from .env.
|
||||
text = re.sub(r'(://[^:@/\s]+:)[^@\s]+(@)', r'\1<REDACTED>\2', text)
|
||||
return text
|
||||
|
||||
|
||||
def http_probe(url):
|
||||
"""Fetch a URL, returning status, headers and body even for an error page."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
started = time.time()
|
||||
try:
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'shopdb-diagnose'})
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
||||
body = response.read(4000).decode('utf-8', 'replace')
|
||||
return {'status': response.status, 'reason': response.reason,
|
||||
'headers': dict(response.headers), 'body': body,
|
||||
'seconds': time.time() - started}
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = ''
|
||||
try:
|
||||
body = exc.read(4000).decode('utf-8', 'replace')
|
||||
except Exception:
|
||||
pass
|
||||
return {'status': exc.code, 'reason': exc.reason,
|
||||
'headers': dict(exc.headers or {}), 'body': body,
|
||||
'seconds': time.time() - started}
|
||||
except Exception as exc:
|
||||
return {'status': None, 'reason': '%s: %s' % (type(exc).__name__, exc),
|
||||
'headers': {}, 'body': '', 'seconds': time.time() - started}
|
||||
|
||||
|
||||
def summarise_iis_error(body):
|
||||
"""Pull the meaningful bits out of an IIS error page."""
|
||||
if not body:
|
||||
return None
|
||||
import html
|
||||
|
||||
# style and script blocks first: their contents survive plain tag stripping
|
||||
# and drag CSS into the summary.
|
||||
flat = re.sub(r'(?is)<(script|style)[^>]*>.*?</\1>', ' ', body)
|
||||
flat = re.sub(r'(?s)<!--.*?-->', ' ', flat)
|
||||
flat = re.sub(r'(?s)<[^>]*>', ' ', flat)
|
||||
# Any unterminated tag left by the 4000-byte body truncation.
|
||||
flat = re.sub(r'(?s)<[^>]*$', ' ', flat)
|
||||
flat = html.unescape(flat)
|
||||
flat = re.sub(r'\s+', ' ', flat).strip()
|
||||
|
||||
hints = []
|
||||
for pattern in (r'\b\d{3}\.\d+\b', r'0x[0-9a-fA-F]{8}',
|
||||
r'Error Code[^.]{0,80}', r'Config (?:Error|File)[^.]{0,120}',
|
||||
r'Requested URL[^.]{0,120}', r'Physical Path[^.]{0,120}'):
|
||||
for match in re.findall(pattern, flat):
|
||||
cleaned = re.sub(r'\s+', ' ', match).strip(' :-')
|
||||
if cleaned and cleaned not in hints:
|
||||
hints.append(cleaned)
|
||||
return {'flat': flat[:1200], 'hints': hints}
|
||||
|
||||
|
||||
def main():
|
||||
report = Report()
|
||||
stamp = datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||
|
||||
env_values, env_raw = load_secrets()
|
||||
|
||||
report.line('ShopDB-Flask stage 5 diagnostic')
|
||||
report.line('generated %s' % datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
|
||||
report.line('host %s' % socket.gethostname())
|
||||
report.line('app root %s' % APP_ROOT)
|
||||
report.line('python %s' % sys.version.replace('\n', ' '))
|
||||
report.line('process is %d-bit' % (64 if sys.maxsize > 2 ** 32 else 32))
|
||||
|
||||
# ---------------------------------------------------------------- 1. HTTP
|
||||
report.head('1. WHAT IIS ANSWERS')
|
||||
report.line('This is the single most important section. The status code names')
|
||||
report.line('the fault: 500.19 = config locked, 502.3/503 = the app did not')
|
||||
report.line('start, 404 = application or handler mapping missing.')
|
||||
|
||||
hostname = socket.gethostname()
|
||||
targets = [
|
||||
'http://localhost/%s/' % ALIAS,
|
||||
'http://127.0.0.1/%s/' % ALIAS,
|
||||
'http://[::1]/%s/' % ALIAS,
|
||||
'http://%s/%s/' % (hostname, ALIAS),
|
||||
'http://localhost/',
|
||||
]
|
||||
for url in targets:
|
||||
result = http_probe(url)
|
||||
report.line('\n%s' % url)
|
||||
report.line(' status : %s %s' % (result['status'], result['reason']))
|
||||
report.line(' time : %.1fs' % result['seconds'])
|
||||
server = result['headers'].get('Server')
|
||||
if server:
|
||||
report.line(' server : %s' % server)
|
||||
summary = summarise_iis_error(result['body'])
|
||||
if summary and summary['hints']:
|
||||
report.line(' hints : %s' % ' | '.join(summary['hints'][:8]))
|
||||
if summary and summary['flat']:
|
||||
report.line(' body : %s' % summary['flat'][:600])
|
||||
|
||||
# localhost resolving to ::1 first has bitten this install before.
|
||||
report.block('name resolution for localhost', run(
|
||||
['nslookup', 'localhost'], timeout=20))
|
||||
|
||||
# ------------------------------------------------------------- 2. IIS state
|
||||
report.head('2. IIS STATE')
|
||||
appcmd = find_appcmd()
|
||||
if not appcmd:
|
||||
report.line('appcmd.exe NOT FOUND - is the IIS role installed?')
|
||||
else:
|
||||
report.line('appcmd: %s' % appcmd)
|
||||
report.block('sites', run([appcmd, 'list', 'sites']))
|
||||
report.block('applications', run([appcmd, 'list', 'apps']))
|
||||
report.block('app pools', run([appcmd, 'list', 'apppools']))
|
||||
report.block('worker processes (empty means nothing is running)',
|
||||
run([appcmd, 'list', 'wp']))
|
||||
report.block('modules: httpPlatformHandler present?',
|
||||
run([appcmd, 'list', 'modules']))
|
||||
# overrideMode tells us whether the unlock actually took effect.
|
||||
#
|
||||
# allowedServerVariables is in this list because it caused a 500.52 that
|
||||
# the first two sections could not explain: it is Deny by default, so an
|
||||
# <allowedServerVariables> block in the app's web.config is rejected
|
||||
# before httpPlatformHandler runs. Checking only the sections we unlock
|
||||
# would have missed the one we do not.
|
||||
for section in ('system.webServer/handlers',
|
||||
'system.webServer/httpPlatform',
|
||||
'system.webServer/rewrite/allowedServerVariables',
|
||||
'system.webServer/rewrite/rules'):
|
||||
report.block('lock state of %s' % section,
|
||||
run([appcmd, 'list', 'config', '/section:%s' % section,
|
||||
'/text:*']))
|
||||
|
||||
report.block('W3SVC / WAS services', powershell(
|
||||
"Get-Service W3SVC,WAS | Format-Table Name,Status,StartType -AutoSize | Out-String"))
|
||||
report.block('listeners on port 80', powershell(
|
||||
"Get-NetTCPConnection -LocalPort 80 -State Listen -EA SilentlyContinue | "
|
||||
"Format-Table LocalAddress,LocalPort,OwningProcess -AutoSize | Out-String"))
|
||||
report.block('app pool detail', powershell(
|
||||
"Import-Module WebAdministration -EA SilentlyContinue; "
|
||||
"Get-Item IIS:\\AppPools\\shopdbflask -EA SilentlyContinue | "
|
||||
"Select-Object name,state,managedRuntimeVersion,enable32BitAppOnWin64,"
|
||||
"@{n='identity';e={$_.processModel.identityType}} | Format-List | Out-String"))
|
||||
|
||||
# --------------------------------------------------------- 3. app + config
|
||||
report.head('3. APPLICATION AND CONFIG')
|
||||
|
||||
web_config = os.path.join(APP_ROOT, 'web.config')
|
||||
if os.path.isfile(web_config):
|
||||
try:
|
||||
with open(web_config, 'r', encoding='utf-8', errors='replace') as handle:
|
||||
report.block('web.config', handle.read())
|
||||
except Exception as exc:
|
||||
report.block('web.config', '[could not read: %s]' % exc)
|
||||
else:
|
||||
report.block('web.config', 'MISSING at %s' % web_config)
|
||||
|
||||
if env_raw is None:
|
||||
report.block('.env', 'MISSING at %s' % os.path.join(APP_ROOT, '.env'))
|
||||
else:
|
||||
# Keys and non-secret values only. Secret values are scrubbed anyway.
|
||||
lines = []
|
||||
for key in sorted(env_values):
|
||||
upper = key.upper()
|
||||
secretish = ('SECRET' in upper or 'PASSWORD' in upper
|
||||
or 'TOKEN' in upper or 'KEY' in upper
|
||||
or upper == 'DATABASE_URL')
|
||||
if secretish:
|
||||
lines.append('%s = <set, %d chars>' % (key, len(env_values[key])))
|
||||
else:
|
||||
lines.append('%s = %s' % (key, env_values[key]))
|
||||
report.block('.env (secret values withheld)', '\n'.join(lines))
|
||||
|
||||
venv_python = os.path.join(APP_ROOT, 'venv', 'Scripts', 'python.exe')
|
||||
report.line('\nvenv python exists: %s' % os.path.isfile(venv_python))
|
||||
if os.path.isfile(venv_python):
|
||||
# The exact failure stage 3 used to hit. Proves the app imports.
|
||||
report.block('venv: import shopdb', run(
|
||||
[venv_python, '-c',
|
||||
'import shopdb; print("import OK"); '
|
||||
'app = shopdb.create_app(); print("create_app OK")'], timeout=120))
|
||||
report.block('venv: waitress present', run(
|
||||
[venv_python, '-c', 'import waitress; print(waitress.__version__)'],
|
||||
timeout=60))
|
||||
|
||||
# What httpPlatformHandler is told to launch, and whether it exists.
|
||||
if os.path.isfile(web_config):
|
||||
try:
|
||||
with open(web_config, 'r', encoding='utf-8', errors='replace') as handle:
|
||||
raw = handle.read()
|
||||
match = re.search(r'processPath\s*=\s*"([^"]+)"', raw)
|
||||
args = re.search(r'arguments\s*=\s*"([^"]*)"', raw)
|
||||
if match:
|
||||
path = os.path.expandvars(match.group(1))
|
||||
report.line('\nhttpPlatform processPath : %s' % match.group(1))
|
||||
report.line(' resolved : %s' % path)
|
||||
report.line(' exists : %s' % os.path.isfile(path))
|
||||
if args:
|
||||
report.line('httpPlatform arguments : %s' % args.group(1))
|
||||
except Exception as exc:
|
||||
report.line('[could not parse web.config: %s]' % exc)
|
||||
|
||||
# ------------------------------------------------------------- 4. app logs
|
||||
report.head('4. APPLICATION LOGS')
|
||||
log_dir = os.path.join(APP_ROOT, 'logs')
|
||||
if not os.path.isdir(log_dir):
|
||||
report.line('MISSING: %s' % log_dir)
|
||||
else:
|
||||
entries = []
|
||||
for name in sorted(os.listdir(log_dir)):
|
||||
full = os.path.join(log_dir, name)
|
||||
try:
|
||||
entries.append((os.path.getmtime(full), full, name,
|
||||
os.path.getsize(full)))
|
||||
except OSError:
|
||||
pass
|
||||
if not entries:
|
||||
report.line('%s is EMPTY.' % log_dir)
|
||||
report.line('No stdout log at all means httpPlatformHandler never')
|
||||
report.line('launched python - look at the pool identity and ACLs.')
|
||||
for _, full, name, size in sorted(entries, reverse=True)[:5]:
|
||||
if size == 0:
|
||||
report.block('%s (0 bytes)' % name,
|
||||
'EMPTY - python was launched but wrote nothing.')
|
||||
continue
|
||||
try:
|
||||
with open(full, 'r', encoding='utf-8', errors='replace') as handle:
|
||||
tail = handle.readlines()[-60:]
|
||||
report.block('%s (%d bytes, last 60 lines)' % (name, size),
|
||||
''.join(tail))
|
||||
except Exception as exc:
|
||||
report.block(name, '[could not read: %s]' % exc)
|
||||
|
||||
# ---------------------------------------------------------------- 5. ACLs
|
||||
report.head('5. PERMISSIONS')
|
||||
report.line('The pool runs as "IIS AppPool\\shopdbflask". It needs RX on the')
|
||||
report.line('tree, Modify on logs and instance, and Read on .env.')
|
||||
for target in (APP_ROOT, log_dir, os.path.join(APP_ROOT, '.env'),
|
||||
os.path.join(APP_ROOT, 'venv', 'Scripts')):
|
||||
if os.path.exists(target):
|
||||
report.block('icacls %s' % target, run(['icacls', target], timeout=40))
|
||||
|
||||
# ------------------------------------------------------------ 6. event log
|
||||
report.head('6. EVENT LOG')
|
||||
report.block('recent application errors', powershell(
|
||||
"Get-WinEvent -FilterHashtable @{LogName='Application';"
|
||||
"StartTime=(Get-Date).AddHours(-6)} -EA SilentlyContinue | "
|
||||
"Where-Object { $_.ProviderName -match 'HttpPlatform|IIS|W3SVC|WAS|\\.NET' "
|
||||
"-or $_.LevelDisplayName -eq 'Error' } | Select-Object -First 25 "
|
||||
"TimeCreated,ProviderName,LevelDisplayName,Message | Format-List | Out-String",
|
||||
timeout=180))
|
||||
report.block('system log: WAS / W3SVC', powershell(
|
||||
"Get-WinEvent -FilterHashtable @{LogName='System';"
|
||||
"StartTime=(Get-Date).AddHours(-6)} -EA SilentlyContinue | "
|
||||
"Where-Object { $_.ProviderName -match 'WAS|W3SVC|HTTP' } | "
|
||||
"Select-Object -First 20 TimeCreated,ProviderName,LevelDisplayName,Message | "
|
||||
"Format-List | Out-String", timeout=180))
|
||||
|
||||
# ------------------------------------------------------------------ write
|
||||
body = scrub(report.text())
|
||||
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'shopdb-diagnose-%s.txt' % stamp)
|
||||
try:
|
||||
with open(out_path, 'w', encoding='utf-8') as handle:
|
||||
handle.write(body)
|
||||
except Exception:
|
||||
out_path = os.path.join(os.environ.get('TEMP', r'C:\Windows\Temp'),
|
||||
'shopdb-diagnose-%s.txt' % stamp)
|
||||
with open(out_path, 'w', encoding='utf-8') as handle:
|
||||
handle.write(body)
|
||||
|
||||
print('')
|
||||
print('Report written to:')
|
||||
print(' %s' % out_path)
|
||||
print('')
|
||||
print('%d secret value(s) were scrubbed from it.' % len(SECRETS))
|
||||
print('Send that file back.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -11,7 +11,7 @@
|
||||
- HttpPlatformHandler IIS module installed
|
||||
(https://www.iis.net/downloads/microsoft/httpplatformhandler)
|
||||
- URL Rewrite module installed (only for the optional X-Forwarded-For rule)
|
||||
- Python 3.12 + a venv at APP_ROOT\venv with requirements.txt + waitress
|
||||
- Python 3.14 + a venv at APP_ROOT\venv with requirements.txt + waitress
|
||||
- Secrets live in APP_ROOT\.env (wsgi.py load_dotenv() reads it). Keep them
|
||||
OUT of this file. Lock .env ACLs to the app pool identity + admins.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<httpPlatform
|
||||
processPath="C:\shopdb-flask\venv\Scripts\waitress-serve.exe"
|
||||
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for wsgi:app"
|
||||
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for --trusted-proxy-count=1 wsgi:app"
|
||||
stdoutLogEnabled="true"
|
||||
stdoutLogFile="C:\shopdb-flask\logs\httpplatform"
|
||||
startupTimeLimit="120"
|
||||
@@ -49,20 +49,46 @@
|
||||
</httpPlatform>
|
||||
|
||||
<!--
|
||||
OPTIONAL: forward the real client IP so audit logs and the kiosk
|
||||
visitor-location feature (IP -> business unit) see the caller, not the
|
||||
loopback that HttpPlatformHandler connects from.
|
||||
Forward the real client IP, so the audit log, the kiosk visitor-location
|
||||
feature (IP -> business unit), the GE-Enforce IP allowlist and per-host
|
||||
login rate limiting all see the caller rather than the loopback address
|
||||
HttpPlatformHandler connects from.
|
||||
|
||||
This block is COMMENTED OUT by default because it needs the URL Rewrite
|
||||
module; with it uncommented but URL Rewrite not installed, IIS returns
|
||||
HTTP 500.19 ("configuration section not well-formed / cannot be read").
|
||||
Install URL Rewrite (https://www.iis.net/downloads/microsoft/url-rewrite)
|
||||
and then uncomment the <rewrite> block below to enable it.
|
||||
IIS does not set X-Forwarded-For on its own. Without the rule below there
|
||||
is no such header at all, and every client looks like 127.0.0.1 - so the
|
||||
allowlist and the visitor-location lookup silently stop working.
|
||||
|
||||
ONLY CORRECT WHEN IIS IS DIRECTLY EXPOSED. It overwrites the header with
|
||||
REMOTE_ADDR, which is what stops a client spoofing its own X-Forwarded-For.
|
||||
Behind a reverse proxy (ARR, a load balancer) REMOTE_ADDR is the PROXY, so
|
||||
this rule would destroy the real client IP - there, leave it disabled and
|
||||
let the proxy set the header.
|
||||
|
||||
It ships DISABLED because it needs the URL Rewrite module; enabled without
|
||||
it, IIS returns HTTP 500.19 ("configuration section not well-formed").
|
||||
|
||||
There is deliberately NO <allowedServerVariables> block below. Setting a
|
||||
server variable requires that variable to be allowed, but the section
|
||||
system.webServer/rewrite/allowedServerVariables ships with
|
||||
overrideModeDefault="Deny", so declaring it in an application's own
|
||||
web.config is refused outright: IIS answered 500.52 with error 0x80070021,
|
||||
"this configuration section cannot be used at this path", BEFORE it ever
|
||||
reached httpPlatformHandler - so python was never launched and the stdout
|
||||
log stayed empty, which looks like an application fault and is not one.
|
||||
|
||||
The installer instead allows the single variable at server level, which
|
||||
grants exactly HTTP_X_FORWARDED_FOR rather than unlocking the section and
|
||||
letting every site on the machine declare arbitrary server variables.
|
||||
|
||||
The installer handles both: -ClientIpSource direct installs URL Rewrite
|
||||
from the bundle, allows the variable, and enables this; -ClientIpSource
|
||||
proxy leaves it alone. By hand: install URL Rewrite, run
|
||||
appcmd set config /section:system.webServer/rewrite/allowedServerVariables ^
|
||||
/+"[name='HTTP_X_FORWARDED_FOR']" /commit:apphost
|
||||
then delete the two marker lines below.
|
||||
-->
|
||||
<!-- SHOPDB-CLIENTIP-BEGIN
|
||||
<rewrite>
|
||||
<allowedServerVariables>
|
||||
<add name="HTTP_X_FORWARDED_FOR" />
|
||||
</allowedServerVariables>
|
||||
<rules>
|
||||
<rule name="Set X-Forwarded-For" stopProcessing="false">
|
||||
<match url=".*" />
|
||||
@@ -73,7 +99,42 @@
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
-->
|
||||
SHOPDB-CLIENTIP-END -->
|
||||
|
||||
</system.webServer>
|
||||
|
||||
<!--
|
||||
Installer downloads: serve /installers/* as IIS static files instead of
|
||||
forwarding them to Flask. The handler above is path="*", so without this a
|
||||
request for /installers/Foo.exe goes to waitress, which has no such route
|
||||
(SPA fallback), and large binaries would stream through a Python thread.
|
||||
|
||||
This <location> clears the httpPlatformHandler for that one subpath and puts
|
||||
the static file handler back, so IIS serves the bytes directly (kernel-mode,
|
||||
range/resume, no Python thread held).
|
||||
|
||||
Requires a physical folder at APP_ROOT\installers (the site's physical path
|
||||
is APP_ROOT). Drop the installer binaries there, e.g. robocopy them from the
|
||||
classic wwwroot\installers. The stored installpath 'installers/Foo.exe' then
|
||||
resolves to <mount>/installers/Foo.exe (e.g. /shopdb/installers/Foo.exe).
|
||||
|
||||
.exe/.msi are given an explicit MIME map; if the parent site has a Request
|
||||
Filtering rule that denies executable extensions, also allow them there.
|
||||
-->
|
||||
<location path="installers">
|
||||
<system.webServer>
|
||||
<handlers>
|
||||
<clear />
|
||||
<add name="StaticFile" path="*" verb="*"
|
||||
modules="StaticFileModule" resourceType="File"
|
||||
requireAccess="Read" />
|
||||
</handlers>
|
||||
<staticContent>
|
||||
<remove fileExtension=".exe" />
|
||||
<mimeMap fileExtension=".exe" mimeType="application/octet-stream" />
|
||||
<remove fileExtension=".msi" />
|
||||
<mimeMap fileExtension=".msi" mimeType="application/octet-stream" />
|
||||
</staticContent>
|
||||
</system.webServer>
|
||||
</location>
|
||||
</configuration>
|
||||
|
||||
90
docker-compose.airgap.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
# shopdb-flask AIR-GAPPED single-site stack.
|
||||
#
|
||||
# For a site with NO internet. Nothing is built or pulled here: the images are
|
||||
# built on a connected box (scripts/build-offline-bundle.ps1), shipped as a
|
||||
# tarball, and `docker load`ed at the site. This file only RUNS pre-loaded
|
||||
# images. See docs/DEPLOY-AIRGAP.md for the full runbook.
|
||||
#
|
||||
# Differences from docker-compose.yml (the connected/build template):
|
||||
# - api uses `image:` (a loaded image), never `build: .` (build needs the net).
|
||||
# - NO ./plugins bind mount. The image already carries every plugin baked in;
|
||||
# binding a host ./plugins (which does not exist at an image-only site) would
|
||||
# mask the baked plugins with an empty dir and load ZERO plugins.
|
||||
# - a one-shot `migrate` service runs db upgrade + plugin upgrade-all + seed
|
||||
# BEFORE api starts, so `up -d` alone brings up a working site (no manual
|
||||
# `docker compose exec ... flask db upgrade` to forget).
|
||||
#
|
||||
# Usage at the site:
|
||||
# docker load < shopdb-stack-<version>.tar.gz
|
||||
# cp .env.example .env # then edit: secrets, CORS_ORIGINS, IMAGE_TAG
|
||||
# docker compose -f docker-compose.airgap.yml up -d
|
||||
# docker compose -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>
|
||||
|
||||
# Shared application environment, reused by the migrate one-shot and the api
|
||||
# service so the two never drift. A YAML anchor, not a container.
|
||||
x-app-env: &app-env
|
||||
FLASK_APP: wsgi.py
|
||||
FLASK_ENV: production
|
||||
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ZABBIX_URL: ${ZABBIX_URL:-}
|
||||
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
||||
MYSQL_DATABASE: shopdb_flask
|
||||
MYSQL_USER: shopdb
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
ports:
|
||||
- "127.0.0.1:${MYSQL_PORT:-3306}:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# One-shot schema + seed. Runs to completion and exits; api waits for it.
|
||||
# Every step is idempotent, so it is safe to run on every `up`.
|
||||
migrate:
|
||||
image: shopdb-flask:${IMAGE_TAG:-0.7.0}
|
||||
restart: "no"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
<<: *app-env
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >
|
||||
flask db upgrade &&
|
||||
flask plugin upgrade-all &&
|
||||
flask seed permissions &&
|
||||
flask seed settings &&
|
||||
flask seed reference-data
|
||||
|
||||
api:
|
||||
image: shopdb-flask:${IMAGE_TAG:-0.7.0}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
<<: *app-env
|
||||
ports:
|
||||
- "${API_PORT:-5001}:5001"
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
133
docs/API-REFERENCE.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# API reference (index)
|
||||
|
||||
This page is an index and a pointer, not a full specification. It answers three
|
||||
questions: what endpoints exist, who calls them, and what auth they require. The
|
||||
detailed request and response shapes live in the live generated docs and in the
|
||||
per-surface contract docs linked from each table below.
|
||||
|
||||
## Live and generated docs
|
||||
|
||||
The repo ships hosted, generated API docs. Start there:
|
||||
|
||||
- **Interactive spec:** `GET /api/docs` - a self-hosted Redoc page over the
|
||||
generated OpenAPI spec. The Redoc bundle is vendored under
|
||||
`shopdb/core/api/staticdocs/`, so it renders fully offline on the air-gapped
|
||||
prod box (no CDN).
|
||||
- **Raw spec:** `GET /api/docs/openapi.json` - OpenAPI 3.1, roughly 238 paths and
|
||||
362 operations. Generated by `scripts/gen_openapi.py` from
|
||||
`docs/api-inventory.json`; regenerate after any API change.
|
||||
- **LLM / agent entry point:** `GET /api/docs/llms.txt` - a concise API guide
|
||||
following the llms.txt convention, plus a read-only MCP server
|
||||
(`mcp/shopdb_mcp.py`, built with `FastMCP.from_openapi` over the same spec).
|
||||
The MCP server exposes a curated set of GET endpoints as tools for an agent to
|
||||
query the asset database over HTTPS with a scoped read token; it never runs on
|
||||
the prod box. Set it up on a work PC with
|
||||
`pxe-images/github/setup-mcp.cmd`.
|
||||
|
||||
The docs blueprint is `shopdb/core/api/docs.py` (a core blueprint, always
|
||||
mounted regardless of which plugins are staged into a site build).
|
||||
|
||||
Contract docs (linked per table below) hold the deep semantics: field mappings,
|
||||
idempotency rules, rotation, error envelopes, staged rollout. This page only
|
||||
routes you to the right one.
|
||||
|
||||
---
|
||||
|
||||
## 1. Fleet and client contracts (unauthenticated or token)
|
||||
|
||||
These are the endpoints the shopfloor PC fleet, kiosks, displays, and printer
|
||||
installers call. They are consumed by machines, not by the interactive UI, and
|
||||
they authenticate with a scoped service token or nothing at all.
|
||||
|
||||
| Endpoint | Auth | Purpose | Contract doc |
|
||||
|---|---|---|---|
|
||||
| `GET /api/geenforce/manifest?pctype=<scope>` | `geenforce.fetch` service token (`X-API-Key` or Bearer PAT) | Serve the current published manifest for a PC-type scope. ETag / 304 supported. | GE-ENFORCE-CLIENT.md, geenforce-api-cutover.md |
|
||||
| `GET /api/geenforce/payload/<sha256>` | `geenforce.fetch` service token | Serve a payload blob (installer) by content hash so share-less PCs pull over HTTPS instead of SMB. Rate limited and size capped; the sha256 is the integrity guarantee. | GE-ENFORCE-CLIENT.md, geenforce-api-cutover.md |
|
||||
| `POST /api/geenforce/report` | `geenforce.report` service token | Record one PC's enforcement cycle: applied manifest version plus per-entry self-heal outcomes. | GE-ENFORCE-CLIENT.md, geenforce-api-cutover.md |
|
||||
| `POST /api/collector/<plugin>` | `X-API-Key` env key or a `collector.ingest` managed token | Generic idempotent inventory upsert; the PC fleet targets `/api/collector/computers`. | COLLECTOR-INTEGRATION.md |
|
||||
| `POST /api/collector/pc`, `/apps`, `/heartbeat`, `/bulk` | `X-API-Key` or `collector.ingest` token | Legacy computers-only collector paths (predate ADR-006); deprecated in favor of `/api/collector/computers`. | COLLECTOR-INTEGRATION.md |
|
||||
| `GET /api/collector/status` | `X-API-Key` or `collector.ingest` token | Collector liveness and endpoint list. | COLLECTOR-INTEGRATION.md |
|
||||
| `GET /api/printers/install-list` | optional JWT (anonymous fleet or logged-in browser) | Flat list of network printers with floor-map positions for the signed installer. `?format=text` returns a pipe-delimited variant. | PRINTER-INSTALLER.md |
|
||||
| `GET /api/printers/pc-default?machine=NNNN` | optional JWT | The PC's default printer by machine (asset) number, via the `defaultprinter` relationship. `?format=text` supported. | PRINTER-INSTALLER.md |
|
||||
| `GET /api/printers/install-batch?printerids=1,2,3` | optional JWT | Generate a self-deleting Windows `.bat` that installs the selected printers. | PRINTER-INSTALLER.md |
|
||||
| `GET /api/dashboarddefaults/display-role?fqdn=<fqdn>` | public (none) | Resolve what a display PC should show (role `dashboard`/`lobby`/`partskiosk`, frontend path, business unit). FQDN-first, IP fallback. | GE-ENFORCE-DISPLAY.md |
|
||||
| `GET /api/dashboarddefaults/visitor-location?fqdn=<fqdn>` | public (none) | Resolve the business unit for a lobby display by FQDN (IP fallback). | GE-ENFORCE-DISPLAY.md |
|
||||
|
||||
The `geenforce.fetch` and `geenforce.report` scopes accept both `X-API-Key` and
|
||||
`Authorization: Bearer` transports, the same managed-token pattern the collector
|
||||
uses (see COLLECTOR-INTEGRATION.md for how to mint, deploy, and rotate a scoped
|
||||
token). A fetch token may be further resource-bound to specific scopes; a bound
|
||||
token is denied (403 on manifest, 404 on payload) anything outside its scopes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Import API
|
||||
|
||||
The import surface (an admin PAT plus `X-Import-Mode` to preserve legacy
|
||||
timestamps) lets a script load an entire legacy database through the same
|
||||
endpoints the UI uses. It is documented in full, per resource, in **IMPORT-API.md**
|
||||
and is not duplicated here. The dashboarddefaults import fields (FQDN-preferred
|
||||
keying) are covered there as well.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core UI API
|
||||
|
||||
Everything else is the core UI API: the endpoints the Vue frontend calls. As a
|
||||
rule these are JWT-authenticated (a login token or a managed Personal Access
|
||||
Token) and versioned by the plugin contract (`__contract_version__`, currently
|
||||
0.15.0). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**;
|
||||
sister sites should pin tight `core_version` ranges until the contract reaches
|
||||
1.0.
|
||||
|
||||
Two auth patterns dominate the reads:
|
||||
|
||||
- **Public (no token ever).** The endpoints below are reachable with no
|
||||
credential at all. This is the surface a firewall or deployment reviewer asks
|
||||
about, so it is enumerated in full.
|
||||
- **Optional JWT (`jwt_required(optional=True)`).** Nearly every core and plugin
|
||||
GET (list / detail / report / dashboard-summary) is optional-auth: it serves
|
||||
reads anonymously and only requires a JWT for writes. There are well over a
|
||||
hundred of these; rather than reprint them, enumerate them from the live spec
|
||||
at `/api/docs` (filter to the `GET` operations). All product reports
|
||||
(`/api/reports/*` and every plugin `.../report*`) are optional-auth by the same
|
||||
convention.
|
||||
|
||||
Every mutating endpoint (POST / PUT / PATCH / DELETE) requires a JWT and is
|
||||
gated by `require_role` or `require_permission`; none are public.
|
||||
|
||||
### Fully public endpoints (auth = none)
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `POST /api/auth/login` | Obtain a JWT. |
|
||||
| `GET /api/setup/needs-admin` | First-run check: does the instance have zero users. |
|
||||
| `POST /api/setup/create-admin` | First-run only; creates the first admin, then 403s forever. |
|
||||
| `GET /api/settings/map-blueprint/<filename>` | Serve the floor-map blueprint image. |
|
||||
| `GET /api/settings/branding/<filename>` | Serve site branding assets (logo, etc.). |
|
||||
| `GET /api/models/image/<filename>` | Serve a model image. |
|
||||
| `GET /api/dashboard/navigation` | Public navigation tree. |
|
||||
| `GET /api/dashboard/health` | Liveness / health probe. |
|
||||
| `GET /api/plugins/enabled` | List enabled plugins (no claims used). |
|
||||
| `GET /api/dashboarddefaults/display-role` | Display role resolution (see section 1). |
|
||||
| `GET /api/dashboarddefaults/visitor-location` | Lobby business-unit resolution (see section 1). |
|
||||
| `GET /api/employees/search`, `/lookup/<sso>`, `/lookup` | Employee directory lookups (kiosk / sign-in flows). |
|
||||
| `GET /api/employees/photo/<filename>` | Serve an employee photo. |
|
||||
| `GET /api/notifications` (and `/types`, `/<id>`, `/active`, `/calendar`, `/dashboard/summary`, `/employee/<sso>`, `/shopfloor`) | Read shop-floor notifications for the public display and kiosks. |
|
||||
| `GET /api/slides/feed` | Slide feed for the lobby display and screensaver. |
|
||||
| `GET /api/slides/img/<surface>/<filename>` | Serve a slide image. |
|
||||
| `GET /api/printedparts/image/<filename>` | Serve a printed-part image. |
|
||||
| `GET /api/printedparts/kiosk/item/<itemcode>` | Kiosk part lookup (deliberately open; a kiosk carries no JWT). |
|
||||
| `POST /api/printedparts/kiosk/take` | Kiosk part checkout (deliberately open, per decision record). |
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/adr/README.md` - architecture decision records index.
|
||||
- `docs/DEPLOY.md` - deployment; the public-endpoint inventory in section 3 above
|
||||
is the site-exposure surface a deploy reviewer needs.
|
||||
- `docs/PLUGINS.md` - the plugin catalog.
|
||||
- Contract docs: GE-ENFORCE-CLIENT.md, geenforce-api-cutover.md,
|
||||
COLLECTOR-INTEGRATION.md, PRINTER-INSTALLER.md, GE-ENFORCE-DISPLAY.md,
|
||||
IMPORT-API.md, CONTRACT-STABILITY.md.
|
||||
@@ -96,18 +96,42 @@ CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
tar xzf instance-2026-07-10.tar.gz # restores ./instance/
|
||||
```
|
||||
|
||||
The docker-compose api container reads `instance/` from the repo working
|
||||
directory; make sure it is present before starting `api`.
|
||||
The default `docker-compose.yml` does NOT bind-mount `instance/` into the api
|
||||
container (its only volume is `- ./plugins:/app/plugins:ro`, and the image never
|
||||
copies `instance/`), so the container's Flask instance path is an empty
|
||||
`/app/instance` and a restored host `./instance` is invisible to it. To make the
|
||||
restored `instance/` visible, add a bind mount to the api service before starting
|
||||
it:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
volumes:
|
||||
- ./plugins:/app/plugins:ro
|
||||
- ./instance:/app/instance
|
||||
```
|
||||
|
||||
Make sure `./instance` is present on the host before starting `api`.
|
||||
|
||||
### Step 4: Bring up the API and reconcile migrations
|
||||
|
||||
```bash
|
||||
docker compose up -d api
|
||||
docker compose exec api flask db upgrade
|
||||
docker compose exec api flask plugin upgrade-all
|
||||
```
|
||||
|
||||
For a non-docker deploy:
|
||||
|
||||
```bash
|
||||
flask db upgrade
|
||||
flask plugin upgrade-all
|
||||
```
|
||||
|
||||
`flask db upgrade` is a safety net: if the dump predates the current code, this
|
||||
applies any newer migrations. If the dump is at the same version it is a no-op.
|
||||
applies only the core Alembic chain. `flask plugin upgrade-all` then applies any
|
||||
newer per-plugin migrations (each bundled plugin owns its own chain, ADR-008);
|
||||
without it, plugin-owned tables stay un-migrated. If the dump is at the same
|
||||
version both are no-ops.
|
||||
|
||||
### Step 5: Verify
|
||||
|
||||
@@ -118,8 +142,40 @@ applies any newer migrations. If the dump is at the same version it is a no-op.
|
||||
- `curl -s -X POST -H "Content-Type: application/json" -d '{}' http://localhost:5001/api/auth/login | jq .`
|
||||
should return a `VALIDATION_ERROR`, not a 500.
|
||||
|
||||
## Windows sites (installer-built)
|
||||
|
||||
On a server installed from the Windows installer, everything above is wrapped by
|
||||
the operator console. Do not run mysqldump by hand:
|
||||
|
||||
```powershell
|
||||
cd C:\shopdb-flask
|
||||
.\shopdb-admin.ps1 backup # C:\ProgramData\ShopDB-Flask\backups
|
||||
.\shopdb-admin.ps1 backup D:\backups
|
||||
```
|
||||
|
||||
The dump is verified complete before it is reported as good; a truncated one is
|
||||
deleted rather than left to be discovered when it is needed. An upgrade takes its
|
||||
own backup automatically before touching the schema, and restores from it if a
|
||||
migration fails.
|
||||
|
||||
Two Windows-specific notes:
|
||||
|
||||
- The backup directory is locked to Administrators and SYSTEM, because a dump
|
||||
contains every row including user password hashes. Keep it that way.
|
||||
- `mysqldump` must be present. It ships with the bundled-database option; a site
|
||||
using a remote MySQL needs `mysqlclient\` in its installer bundle, or the
|
||||
pre-upgrade backup is skipped. `shopdb-admin.ps1 check` reports this.
|
||||
|
||||
Restoring is the standard `mysql < dump.sql`, then
|
||||
`.\shopdb-admin.ps1 restart`. Also restore `C:\shopdb-flask\instance\` if you are
|
||||
rebuilding a server - it holds uploaded branding and map blueprints, which the
|
||||
database does not.
|
||||
|
||||
See [OPERATE-WINDOWS.md](OPERATE-WINDOWS.md).
|
||||
|
||||
## See also
|
||||
|
||||
- [DEPLOY.md](DEPLOY.md) - first-time deploy
|
||||
- [INSTALL-WINDOWS.md](INSTALL-WINDOWS.md) - Windows Server install
|
||||
- [UPGRADE.md](UPGRADE.md) - upgrade procedure (back up first)
|
||||
- [CONFIG.md](CONFIG.md) - environment variables and Setting keys
|
||||
|
||||
@@ -78,9 +78,10 @@ suspended for it, so the token is contained to the collector API even though its
|
||||
owner is an admin - it cannot act with admin authority anywhere.
|
||||
|
||||
1. Settings > API Tokens > New Token.
|
||||
2. Click the **Collector service token** preset (pre-selects only
|
||||
`collector.ingest`), name it (e.g. `wj-fleet-collector`), optionally set an
|
||||
expiry, Create.
|
||||
2. Check **Restrict permissions**, then in the permissions grid tick only
|
||||
**Submit collector payloads (fleet reporting)** (the `collector.ingest`
|
||||
permission under the Collector category). Name it (e.g. `wj-fleet-collector`),
|
||||
optionally set an expiry, Create.
|
||||
3. Copy the `shopdb_pat_...` secret (shown once) and deploy it to the fleet the
|
||||
same way as the env key: the `collectorApiKey` field in per-site
|
||||
`site-config.json` (see "Delivering the API key to clients" below). The
|
||||
@@ -244,10 +245,12 @@ the PCs that use it (both render in the shared Relationships card).
|
||||
### pc-type mapping (configurable per site)
|
||||
|
||||
`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through
|
||||
`pctypemap_<pxetype>` settings (Settings > Collector PC Types).
|
||||
Defaults live in `plugins/computers/pctypemap.py` and are seeded on plugin
|
||||
install; edit per site in the UI. Unmapped pc-types produce a warning, not a
|
||||
failure.
|
||||
`pctypemap_<pxetype>` settings. The "Collector PC Types" settings page is
|
||||
retired (ADR-012): pc-type-to-Computer-Type handling now lives in GE-Enforce
|
||||
(each imaging PC type is a manifest scope with its own `computertypeid`). The
|
||||
built-in defaults in `plugins/computers/pctypemap.py` are still seeded on plugin
|
||||
install and the collector still reads them, so existing enrollment keeps
|
||||
working. Unmapped pc-types produce a warning, not a failure.
|
||||
|
||||
### Classic api.asp field mapping (for porting the PowerShell reporter)
|
||||
|
||||
|
||||
@@ -145,6 +145,14 @@ read back through the API.
|
||||
| `printer_hostname_template` | `Printer-{ip}.printer.geaerospace.net` | Printer hostname template. `{ip}` is the dash-separated IP address. |
|
||||
| `contact_email_domain` | `geaerospace.com` | Email domain appended to a support contact's SSO to build email (`sso@domain`) and Teams-chat links. Blank hides the contact action buttons. |
|
||||
| `dualpath_single_machine` | `true` | Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in the machines list, dashboard/report counts, and the floor map (the secondary bay is hidden). The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. `false` lists and counts both bays separately. |
|
||||
| `site_timezone` | `America/New_York` | IANA timezone for the site. Notification start/end times are entered and displayed in this zone (not the viewer's browser zone), and daily-reset notification expiry (`expirymode=dailytime`) is computed here. Editable in Settings > Site > Localization. Public-readable so kiosks/clients can resolve it. |
|
||||
|
||||
Notification times are stored and served in UTC; the frontend converts to
|
||||
`site_timezone` via `frontend/src/utils/datetime.js` (Intl-based, DST-safe).
|
||||
|
||||
Change note: notification times are now timezone-correct (stored UTC, shown in
|
||||
`site_timezone`); this fixes the prior offset bug where a 2:34 PM entry displayed
|
||||
as 6:34 PM.
|
||||
|
||||
### branding
|
||||
|
||||
@@ -319,6 +327,11 @@ One boolean key per search domain, keyed `search_<type>_enabled` (default
|
||||
`true`). Toggles whether a domain appears in global search results. The set is
|
||||
generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`.
|
||||
|
||||
Search terms are matched word-wise: a multi-word query returns rows containing
|
||||
EVERY word, each word anywhere in the searched fields, in any order ("CSF Roles"
|
||||
matches a row with "CSF" and "Roles" in different columns). Quoting does not
|
||||
force a contiguous phrase.
|
||||
|
||||
## Custom fields
|
||||
|
||||
Site-defined extra attributes per asset type (Settings > Custom Fields, table
|
||||
|
||||
@@ -8,11 +8,11 @@ the live code, not aspiration. The authoritative hook reference is
|
||||
|
||||
## Current version
|
||||
|
||||
The plugin contract is at **0.10.0**, declared in `shopdb/__init__.py` as
|
||||
The plugin contract is at **0.13.0**, declared in `shopdb/__init__.py` as
|
||||
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor
|
||||
bump is allowed to break the contract, and this project has used that latitude.
|
||||
|
||||
The product release version (`__version__`, currently 0.5.0) is a separate
|
||||
The product release version (`__version__`, currently 0.7.0) is a separate
|
||||
series with its own bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md).
|
||||
Do not pin against it for compatibility - pin against `__contract_version__`.
|
||||
|
||||
@@ -28,8 +28,13 @@ Recorded in the comment block in `shopdb/__init__.py`:
|
||||
| 0.7.0 | Added the four ADR-010 frontend-contribution hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`), consumed by the `GET /api/pluginui/*` endpoints | additive optional hooks (minor) |
|
||||
| 0.9.0 | Exposed the dualpath pair-resolution helpers on `shopdb.api` for the machines plugin | additive surface (minor) |
|
||||
| 0.10.0 | Added the `get_permissions` hook so plugins declare their own RBAC permissions; the catalog is resolved dynamically from core + enabled plugins | additive optional hook (minor) |
|
||||
| 0.11.0 | Added `service_token_authorized(scope)` to `shopdb.api` so a plugin's unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped managed service token without importing core token internals | additive surface (minor) |
|
||||
| 0.12.0 | Added the mailer helpers (`send_email`, `send_alert`) to `shopdb.api` | additive surface (minor) |
|
||||
| 0.13.0 | Added the `User` model to the `shopdb.api` surface | additive surface (minor) |
|
||||
|
||||
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, and 0.10.0. Earlier points
|
||||
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, and 0.11.0 (its
|
||||
last entry); the current `__contract_version__` 0.13.0 is ahead of the last documented comment
|
||||
entry. Earlier points
|
||||
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
|
||||
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
|
||||
0.2 baseline.
|
||||
|
||||
142
docs/CSV-IMPORT.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Loading a site's data from spreadsheets
|
||||
|
||||
For getting a new site's starting data in when you have a spreadsheet rather
|
||||
than a source database to script against. No developer needed.
|
||||
|
||||
If the site *does* have a source system worth reading, the HTTP import API is
|
||||
the better tool - see [IMPORT-ADOPTION.md](IMPORT-ADOPTION.md).
|
||||
|
||||
---
|
||||
|
||||
## The short version
|
||||
|
||||
```bash
|
||||
cd C:\shopdb-flask # or your install directory
|
||||
venv\Scripts\flask csv templates --out csv-templates
|
||||
```
|
||||
|
||||
Fill in the templates. Then:
|
||||
|
||||
```bash
|
||||
venv\Scripts\flask csv import --dir csv-templates
|
||||
```
|
||||
|
||||
That **checks only** and changes nothing. It tells you what it would create and
|
||||
what is wrong. When you are happy:
|
||||
|
||||
```bash
|
||||
venv\Scripts\flask csv import --dir csv-templates --commit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Write names, not numbers
|
||||
|
||||
This is the part that makes the difference. Every column that points at another
|
||||
table accepts the **name** of the thing:
|
||||
|
||||
```
|
||||
assetnumber,name,assettypeid,statusid,locationid
|
||||
CMM-01,Zeiss Contura,Measuring Tool,Active,Gage Lab
|
||||
MILL-07,Haas VF-2,Machine,Active,Bay 3
|
||||
```
|
||||
|
||||
`assettypeid` gets `Measuring Tool`. `locationid` gets `Gage Lab`. You never
|
||||
have to import a file, read back the numbers it generated, and paste them into
|
||||
the next one.
|
||||
|
||||
The column keeps its database name so it matches the rest of the system, but
|
||||
the value is whatever you actually know. Numeric ids still work if you have
|
||||
them - useful when re-importing something this system exported.
|
||||
|
||||
Names resolve **across files in the same run**, so `assets.csv` can reference a
|
||||
location that only exists because `locations.csv` was loaded moments earlier.
|
||||
|
||||
## Nothing is half-imported
|
||||
|
||||
Every row is checked before anything is written. If one row is wrong, nothing is
|
||||
written at all - you fix the file and run it again. A mistake on line 400 never
|
||||
leaves 399 rows loaded.
|
||||
|
||||
## Running it twice is safe
|
||||
|
||||
Each file is matched on a natural key - `assetnumber` for assets, `locationname`
|
||||
for locations, and so on. Re-importing an edited file **updates** those rows
|
||||
rather than creating second copies. Correcting a spreadsheet and re-running is
|
||||
the expected workflow, not a mistake.
|
||||
|
||||
## What the errors look like
|
||||
|
||||
```
|
||||
assets: 0 new, 0 updated, 1 problem(s)
|
||||
line 4, column 'locationid': nothing in locations is named 'Bay 9'
|
||||
- add it to locations.csv, or import that file first
|
||||
```
|
||||
|
||||
Line, column, value, and what to do. Not a foreign key constraint violation.
|
||||
|
||||
---
|
||||
|
||||
## What you can import
|
||||
|
||||
Fourteen tables, in the order the importer handles them. You only need the ones
|
||||
you have; skip any file you do not care about.
|
||||
|
||||
| Order | File | Matched on |
|
||||
|---|---|---|
|
||||
| 1 | `assetstatuses.csv` | `status` |
|
||||
| 2 | `assettypes.csv` | `assettype` |
|
||||
| 3 | `locationtypes.csv` | `locationtype` |
|
||||
| 4 | `modeltypes.csv` | `modeltype` |
|
||||
| 5 | `computertypes.csv` | `computertype` |
|
||||
| 6 | `machinetypes.csv` | `machinetype` |
|
||||
| 7 | `businessunits.csv` | `businessunit` |
|
||||
| 8 | `locations.csv` | `locationname` |
|
||||
| 9 | `vendors.csv` | `vendor` |
|
||||
| 10 | `models.csv` | `modelnumber` |
|
||||
| 11 | `operatingsystems.csv` | `osname` |
|
||||
| 12 | `assets.csv` | `assetnumber` |
|
||||
| 13 | `computers.csv` | `assetid` |
|
||||
| 14 | `machines.csv` | `assetid` |
|
||||
|
||||
`--dir` handles the order for you. Use `--file` with `--table` for one file.
|
||||
|
||||
**User accounts are deliberately not importable.** Passwords do not belong in a
|
||||
spreadsheet, in either direction. Create the first administrator through the
|
||||
first-run page and the rest in the application.
|
||||
|
||||
## The templates are generated, not maintained
|
||||
|
||||
`flask csv templates` builds them from the live database schema each time. Every
|
||||
column offered exists; every required one is marked; every foreign key says
|
||||
which file it refers to.
|
||||
|
||||
This matters because the alternative does not work. A hand-written template set
|
||||
was tried, and it had invented columns on seven of eleven tables and named a
|
||||
table that does not exist - while looking entirely plausible. Templates that are
|
||||
generated cannot drift from the schema, and a test fails the build if they ever
|
||||
do.
|
||||
|
||||
## Editing the files
|
||||
|
||||
- **UTF-8**, no BOM. Excel: "CSV UTF-8 (Comma delimited)".
|
||||
- Lines starting with `#` are ignored, so the notes and the example row in each
|
||||
template can stay where they are.
|
||||
- Booleans are `1` or `0`.
|
||||
- Dates are `YYYY-MM-DD` (`2026-08-04`). `YYYY-MM-DD HH:MM:SS` also works, as do
|
||||
`DD/MM/YYYY` and `MM/DD/YYYY`.
|
||||
- Leave a cell **empty** for "no value". Not `NULL`, not `N/A`.
|
||||
- Quote anything containing a comma: `"Bay 3, North"`.
|
||||
|
||||
## If it will not run
|
||||
|
||||
**"the 'assets' table does not exist in this database"** - the schema has not
|
||||
been created. Run `flask db upgrade` first, and check `DATABASE_URL` points at
|
||||
the site you meant.
|
||||
|
||||
**"unknown column(s): ..."** - a column that does not exist, usually from an
|
||||
older template. Regenerate with `flask csv templates`; the message lists what
|
||||
the table does accept.
|
||||
|
||||
**"required column(s) missing: ..."** - a column that must be present has been
|
||||
deleted from the header. Regenerate and copy your data across.
|
||||
149
docs/DEPLOY-AIRGAP.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Air-gapped Docker deploy
|
||||
|
||||
For a site with **no internet**. The site cannot `pip install`, `npm ci`, or
|
||||
`docker pull`, so nothing is built or pulled there. You build a fully
|
||||
self-contained image on a **connected** box, ship it as one tarball, and
|
||||
`docker load` + run it at the site.
|
||||
|
||||
This is the counterpart to `docker-compose.yml` (the connected/build template).
|
||||
The air-gapped stack uses `docker-compose.airgap.yml`, which differs in three
|
||||
ways that matter:
|
||||
|
||||
- **`image:` not `build: .`** - the site runs a loaded image; it never builds.
|
||||
- **No `./plugins` bind mount** - the image already carries every plugin baked
|
||||
in. Binding a host `./plugins` (which does not exist at an image-only site)
|
||||
would mask the baked plugins with an empty dir and load **zero** plugins.
|
||||
- **A one-shot `migrate` service** runs `db upgrade` + `plugin upgrade-all` +
|
||||
seed before `api` starts, so `up -d` alone brings up a working site.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build the bundle (connected box)
|
||||
|
||||
On a box with clean access to Docker Hub + PyPI + npm, from the repo root:
|
||||
|
||||
```powershell
|
||||
pwsh scripts/build-offline-bundle.ps1 -Version 0.7.0
|
||||
```
|
||||
|
||||
This builds `shopdb-flask:0.7.0` (frontend + all Python deps baked in), pulls
|
||||
`mysql:8.0`, and writes:
|
||||
|
||||
- `shopdb-stack-0.7.0.tar.gz` - both images in one archive
|
||||
- `shopdb-stack-0.7.0.tar.gz.sha256` - checksum to verify after transfer
|
||||
|
||||
The image is self-contained: **no pip/npm/registry access is needed at the
|
||||
site.**
|
||||
|
||||
### Building behind Zscaler
|
||||
|
||||
If the build box is itself behind GE Zscaler, the in-build `pip`/`npm` may fail
|
||||
with `CERTIFICATE_VERIFY_FAILED` - the build container has its own cert store
|
||||
and does not trust GE's re-signing root (host `PIP_CERT` / `NODE_EXTRA_CA_CERTS`
|
||||
do NOT carry into a `docker build`). Easiest: build from a box with clean
|
||||
internet (home, cloud, or CI). Otherwise the corp root CA must be trusted
|
||||
**inside** the build (a Dockerfile change to accept the CA - not wired today;
|
||||
see docs/DEVELOPMENT-SETUP.md section 0b for the all-roots PEM bundle).
|
||||
|
||||
---
|
||||
|
||||
## 2. Transfer + verify
|
||||
|
||||
Carry `shopdb-stack-0.7.0.tar.gz` (+ the `.sha256`), plus
|
||||
`docker-compose.airgap.yml` and `.env.example`, to the site on approved media.
|
||||
Verify the archive survived the trip:
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
(Get-FileHash shopdb-stack-0.7.0.tar.gz -Algorithm SHA256).Hash.ToLower()
|
||||
# compare against the .sha256 file
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux site
|
||||
sha256sum -c shopdb-stack-0.7.0.tar.gz.sha256
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Load + run (air-gapped site)
|
||||
|
||||
```bash
|
||||
# 1) Load both images into the local Docker.
|
||||
docker load -i shopdb-stack-0.7.0.tar.gz
|
||||
docker image ls | grep -E 'shopdb-flask|mysql' # confirm both present
|
||||
|
||||
# 2) Configure the site.
|
||||
cp .env.example .env
|
||||
# Edit .env - REQUIRED:
|
||||
# IMAGE_TAG=0.7.0 # MUST match the loaded image tag
|
||||
# MYSQL_ROOT_PASSWORD=... # strong, unique
|
||||
# MYSQL_PASSWORD=... # strong, unique (the app's db user)
|
||||
# SECRET_KEY=... # 32+ random bytes
|
||||
# JWT_SECRET_KEY=... # 32+ random bytes, different from SECRET_KEY
|
||||
# CORS_ORIGINS=https://shopdb.site.example # the site's browser origin(s)
|
||||
# Optional: API_PORT (default 5001), LOG_LEVEL, ZABBIX_URL/ZABBIX_TOKEN.
|
||||
|
||||
# 3) Bring it up. The migrate one-shot runs the schema + seed, then api starts.
|
||||
docker compose -f docker-compose.airgap.yml up -d
|
||||
|
||||
# 4) Watch the one-shot finish (it exits 0 when the schema + seed are done).
|
||||
docker compose -f docker-compose.airgap.yml logs -f migrate
|
||||
```
|
||||
|
||||
`IMAGE_TAG` in `.env` must equal the loaded tag (`0.7.0` here); otherwise compose
|
||||
looks for an image that was never loaded and `api`/`migrate` will not start.
|
||||
|
||||
---
|
||||
|
||||
## 4. Create the first admin
|
||||
|
||||
Seeding creates permissions, settings, and reference data, but not a login. Make
|
||||
one admin (choose the password; it is not automatable):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.airgap.yml exec api \
|
||||
flask seed admin --username <username> --email <email> --password <password>
|
||||
# Omit --password to have a strong one generated and printed once.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Verify
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.airgap.yml ps # db + api "running", migrate "exited (0)"
|
||||
docker compose -f docker-compose.airgap.yml logs api # gunicorn started, no tracebacks
|
||||
curl -sf http://localhost:${API_PORT:-5001}/api/dashboard/health # or browse the site origin
|
||||
```
|
||||
|
||||
Then log in at the site origin with the admin created in step 4.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading to a new version
|
||||
|
||||
Build a new bundle on the connected box (`-Version 0.8.0`), transfer, then at the
|
||||
site:
|
||||
|
||||
```bash
|
||||
docker load -i shopdb-stack-0.8.0.tar.gz
|
||||
# set IMAGE_TAG=0.8.0 in .env
|
||||
docker compose -f docker-compose.airgap.yml up -d
|
||||
```
|
||||
|
||||
The `migrate` one-shot re-runs `db upgrade` + `plugin upgrade-all` + seed (all
|
||||
idempotent) against the existing data before the new `api` starts. Back up first
|
||||
(see docs/BACKUP-RESTORE.md); the `db_data` volume persists across upgrades.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --- | --- |
|
||||
| `service api is not running` / `manifest ... not found` | The image was not loaded, or `IMAGE_TAG` in `.env` does not match a loaded image. `docker image ls`, fix `IMAGE_TAG`. Also: never use `docker-compose.yml` here - its `build: .` needs internet. |
|
||||
| Site loads but **no plugins / empty nav** | You used the wrong compose file. `docker-compose.yml` bind-mounts `./plugins` (absent here) over the baked plugins. Use `docker-compose.airgap.yml`. |
|
||||
| `api` never starts, `migrate` shows an error | Read `logs migrate`. A DB-connection error means `db` is not healthy yet (`logs db`) or `MYSQL_PASSWORD` in `.env` differs from what the `db` volume was first initialised with. A fresh site with a stale `db_data` volume needs the volume removed (`docker compose ... down -v` - DESTROYS data). |
|
||||
| `SECRET_KEY must be set` (and similar) on `up` | A required `.env` var is empty. Fill every REQUIRED key in step 3. |
|
||||
| Build fails on the connected box at `pip`/`npm` | Zscaler cert - see "Building behind Zscaler" above. |
|
||||
@@ -1,5 +1,13 @@
|
||||
# Deploy shopdb-flask to Windows IIS (MySQL 5.6)
|
||||
|
||||
> **Not the route for a new site.** Sister sites install from the Windows
|
||||
> installer - one `.exe`, no manual IIS work: **[INSTALL-WINDOWS.md](INSTALL-WINDOWS.md)**.
|
||||
>
|
||||
> This is the **manual** procedure for the West Jefferson server, which was built
|
||||
> by hand against its existing MySQL 5.6 and predates the installer. Keep it for
|
||||
> that box.
|
||||
|
||||
|
||||
Runbook for standing up a single-site instance on the production Windows Server
|
||||
that already runs the classic ASP shopdb, using IIS + HttpPlatformHandler +
|
||||
waitress, against the existing MySQL 5.6. This is the test-instance path; keep
|
||||
@@ -13,13 +21,13 @@ physical path must be `APP_ROOT` (where `wsgi.py` lives).
|
||||
|
||||
## 0. Prerequisites on the box
|
||||
|
||||
- Python 3.12 (same minor as dev). `py -3.12 --version` to confirm.
|
||||
- Python 3.14 (same minor as dev and CI). `py -3.14 --version` to confirm.
|
||||
- IIS with the **HttpPlatformHandler** module:
|
||||
https://www.iis.net/downloads/microsoft/httpplatformhandler
|
||||
- **URL Rewrite** module (only for the optional real-client-IP rule).
|
||||
- Network access to the MySQL 5.6 server.
|
||||
- If the box is air-gapped, you cannot `pip install` live. On the dev box run
|
||||
`pip download -r requirements.txt waitress -d wheels\` (on a matching
|
||||
`pip download -r requirements.txt -d wheels\` (on a matching
|
||||
Windows/Python target, or use `--platform` wheels), copy `wheels\` over, and
|
||||
install with `pip install --no-index --find-links wheels\ ...`.
|
||||
|
||||
@@ -39,15 +47,14 @@ Ship `frontend/dist` with the code (Node is not needed on the prod box).
|
||||
|
||||
```powershell
|
||||
cd C:\shopdb-flask
|
||||
py -3.12 -m venv venv
|
||||
py -3.14 -m venv venv
|
||||
venv\Scripts\python -m pip install --upgrade pip
|
||||
venv\Scripts\pip install -r requirements.txt
|
||||
venv\Scripts\pip install waitress
|
||||
```
|
||||
|
||||
The DB driver is `pymysql` (pure Python) so no C compiler / MySQL client libs
|
||||
are needed. `waitress` is the WSGI server (installed separately, same as the
|
||||
Docker image installs gunicorn separately).
|
||||
are needed. `waitress` is the WSGI server and ships in `requirements.txt`
|
||||
(unlike gunicorn, which the Docker image installs separately).
|
||||
|
||||
## 3. Prepare MySQL 5.6 (the utf8mb4 gotcha)
|
||||
|
||||
@@ -106,25 +113,44 @@ $env:FLASK_APP="shopdb"
|
||||
venv\Scripts\flask db upgrade
|
||||
venv\Scripts\flask seed reference-data
|
||||
|
||||
# Enable the plugins this site tracks (registry lives in the gitignored
|
||||
# instance/plugins.json, so a fresh box starts with none enabled):
|
||||
# Install the plugins this site tracks (registry lives in the gitignored
|
||||
# instance/plugins.json, so a fresh box starts with none installed). Run
|
||||
# `flask plugin list` to see the current bundled set; the 13 bundled plugins are
|
||||
# computers, employees, geenforce, knowledgebase, machines, measuringtools,
|
||||
# network, notifications, printedparts, printers, slides, usb, warranty. Install
|
||||
# only the ones this site wants:
|
||||
venv\Scripts\flask plugin list
|
||||
venv\Scripts\flask plugin install machines
|
||||
venv\Scripts\flask plugin install printers
|
||||
venv\Scripts\flask plugin install computers
|
||||
venv\Scripts\flask plugin install equipment
|
||||
venv\Scripts\flask plugin install network
|
||||
venv\Scripts\flask plugin install notifications
|
||||
venv\Scripts\flask plugin install printers
|
||||
venv\Scripts\flask plugin install usb
|
||||
venv\Scripts\flask plugin install knowledgebase
|
||||
venv\Scripts\flask plugin install slides
|
||||
venv\Scripts\flask plugin install employees
|
||||
venv\Scripts\flask plugin upgrade-all
|
||||
|
||||
# First admin (password is generated and printed once):
|
||||
venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example.com
|
||||
```
|
||||
|
||||
Cleaner than a hand list: declare the set once in a site profile and apply it:
|
||||
|
||||
```powershell
|
||||
venv\Scripts\flask plugin apply-profile deploy\site-profile.json # install + enable the chosen set, in dependency order
|
||||
venv\Scripts\flask plugin upgrade-all
|
||||
venv\Scripts\flask plugin prune-schema --yes --force # FIRST PROVISIONING ONLY - see the warning below
|
||||
```
|
||||
|
||||
(Alternatively copy the dev box's `instance/plugins.json` to `APP_ROOT\instance\`
|
||||
to reproduce the exact enabled set, then just run `flask plugin upgrade-all`.)
|
||||
to reproduce the exact set, then just run `flask plugin upgrade-all`.)
|
||||
|
||||
> **`prune-schema --force` is for first provisioning only.** It drops the tables
|
||||
> of plugins this site did not install *even when they hold rows*. On a site that
|
||||
> already has data, run `flask plugin prune-schema` with no flags first and read
|
||||
> what it says it would drop. Re-running with `--force` after a feature has been
|
||||
> used deletes that feature's records with no prompt and no backup.
|
||||
|
||||
## 6. Create the IIS site + web.config
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ shopdb-flask is single-tenant per ADR-004. Each adopting facility runs its own s
|
||||
- Docker 24+ and Docker Compose v2 (or equivalent container runtime)
|
||||
- A reverse proxy with TLS termination (nginx, traefik, Caddy, GE corporate LB) -- the framework does not terminate TLS itself
|
||||
- A MySQL backup destination (offsite recommended)
|
||||
- Access to the GE Aerospace Gitea or a clone of the repo
|
||||
- Access to the internal GE Aerospace git server, or a clone of the repo
|
||||
|
||||
## Step 1: Clone and configure
|
||||
|
||||
```bash
|
||||
git clone https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||
git clone <internal-git-server>/ge-aerospace/shopdb-flask.git
|
||||
cd shopdb-flask
|
||||
cp .env.example .env
|
||||
```
|
||||
@@ -73,6 +73,24 @@ any plugin-specific migrations added after the ownership cutover. Both commands
|
||||
are idempotent, so re-running them is safe. See ADR-008 for why plugin schema
|
||||
splits into per-plugin chains from the cutover forward.
|
||||
|
||||
**Lean sites (ADR-014):** the core chain creates every bundled plugin's tables,
|
||||
so a site that ships only some plugins still has the others' (empty) tables. To
|
||||
carry only core + chosen-plugin tables, prune the rest once, at initial
|
||||
provisioning, after the two commands above:
|
||||
|
||||
```bash
|
||||
docker compose exec api flask plugin prune-schema # dry-run, review
|
||||
docker compose exec api flask plugin prune-schema --yes --force
|
||||
```
|
||||
|
||||
It drops the tables of every plugin not installed on this site. `--force` is
|
||||
needed because the core chain seeds a few plugin reference tables (default
|
||||
access protocols, etc.); at first provisioning those hold only seeded defaults,
|
||||
before any site data. It refuses to drop a table that holds rows without
|
||||
`--force`, so it is safe to leave out of routine upgrades - run it only when
|
||||
provisioning a lean site or after deliberately removing a plugin. Installing a
|
||||
pruned plugin later recreates its tables automatically.
|
||||
|
||||
**Charset:** the schema is utf8mb4 (`utf8mb4_unicode_ci`). The docker-compose `db` service sets `--character-set-server=utf8mb4`, so the auto-created `shopdb_flask` database is utf8mb4. If you point at an external MySQL instead of the bundled container, create the database as utf8mb4 first, or it inherits the server default (often latin1) and the schema silently drifts:
|
||||
|
||||
```sql
|
||||
@@ -98,13 +116,15 @@ docker compose exec api flask seed reference-data
|
||||
- `seed settings` - writes the default Setting rows (branding, ServiceNow
|
||||
integration, floor-map placeholders, search toggles, site identity). A site
|
||||
overrides these later in Settings or the setup wizard.
|
||||
- `seed reference-data` - creates default `Vendor`, `Location`, `BusinessUnit`,
|
||||
`OperatingSystem`, `AssetStatus`, `RelationshipType` rows seeded with the
|
||||
platform contract values (`partof`, `controls`, `connectedto`).
|
||||
- `seed reference-data` - creates default `ModelType`, `AssetStatus`,
|
||||
`LocationType`, `CommunicationType`, `OperatingSystem`, `RelationshipType` rows
|
||||
seeded with the platform contract values (`partof`, `controls`, `connectedto`).
|
||||
(`Vendor`, `Location`, and `BusinessUnit` are not seeded here; they come from
|
||||
`seed demo`.)
|
||||
|
||||
## Step 5: Pick plugins to enable
|
||||
|
||||
The image bundles eleven plugins (computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded.
|
||||
The image bundles thirteen plugins (computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printedparts, printers, slides, usb, warranty). Only enabled plugins are loaded.
|
||||
|
||||
```bash
|
||||
docker compose exec api flask plugin list
|
||||
|
||||
301
docs/DEVELOPMENT-SETUP.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# Development setup: from clone to first change
|
||||
|
||||
The goal of this page: a new developer clones the repo and has a working dev
|
||||
site plus a change they can see in the browser, in one sitting. Reference
|
||||
material lives elsewhere - naming rules in `CONTRIBUTING.md`, every config
|
||||
variable in the CONFIG guide, plugin authoring in the PLUGIN docs - this is
|
||||
just the on-ramp.
|
||||
|
||||
**Most developers here are on Windows in VS Code** - commands below are
|
||||
PowerShell first, with the bash equivalent in a comment where they differ.
|
||||
Install **Git for Windows** (it ships Git Bash, which VS Code and the git
|
||||
hooks use to run the shell-based naming check) and **VS Code** with the
|
||||
extensions this repo recommends (you'll be prompted - section 2c).
|
||||
|
||||
Two ways to run it. **Docker** (Docker Desktop on Windows) is the fastest to a
|
||||
working site. **Manual (venv + Node)** is the daily driver - frontend
|
||||
hot-reloads, backend restarts on save. Do Docker once to confirm the box is
|
||||
sane, then use manual for day-to-day work.
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
| Need | Version | Check |
|
||||
| --- | --- | --- |
|
||||
| Python | 3.14 (64-bit) - matches CI, the container image and the Windows installer wheelhouse | `python --version` |
|
||||
| Node.js | 18+ | `node --version` |
|
||||
| MySQL | 8.0 (or Docker, below) | `mysql --version` |
|
||||
| Git | any recent | `git --version` |
|
||||
|
||||
On Windows, install all of them with winget (accept each license, then
|
||||
reopen the terminal so PATH updates):
|
||||
|
||||
```powershell
|
||||
winget install Git.Git # includes Git Bash (naming hook needs it)
|
||||
winget install Python.Python.3.14
|
||||
winget install OpenJS.NodeJS.LTS # LTS; may install v24, fine for this SPA
|
||||
winget install Microsoft.VisualStudioCode
|
||||
winget install Oracle.MySQL # or Docker.DockerDesktop for the DB
|
||||
```
|
||||
|
||||
CI runs Node 20; the LTS package may be newer. This Vite/Vue frontend builds
|
||||
identically across 20-24, so it does not matter. To pin exactly:
|
||||
`winget install CoreyButler.NVMforWindows` then `nvm install 20; nvm use 20`.
|
||||
|
||||
---
|
||||
|
||||
## 0b. Corp network (SSL cert) - if you are behind a GE/Zscaler proxy
|
||||
|
||||
A proxy that inspects HTTPS (Zscaler on GE PCs) re-signs every connection
|
||||
with a corporate root CA. `git`, `npm`, `pip`, and Node each keep their own
|
||||
trust store and do not trust that CA by default, so downloads fail:
|
||||
|
||||
| Tool | Symptom |
|
||||
| --- | --- |
|
||||
| npm | `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` |
|
||||
| git | `SSL certificate problem: unable to get local issuer certificate` |
|
||||
| pip | `SSLError` / `CERTIFICATE_VERIFY_FAILED` |
|
||||
|
||||
Fix once - export the corp root CA, point every tool at it. PowerShell
|
||||
mangles multi-line pastes, so each step below is **one physical line**: paste
|
||||
it, press Enter, then the next. Do not paste both at once.
|
||||
|
||||
```powershell
|
||||
# 1) Bundle EVERY trusted root into one PEM (one line). Guessing which single cert is the proxy's is fragile; bundling all always includes it.
|
||||
$sb = New-Object System.Text.StringBuilder; Get-ChildItem Cert:\LocalMachine\Root | ForEach-Object { [void]$sb.AppendLine("-----BEGIN CERTIFICATE-----"); [void]$sb.AppendLine([Convert]::ToBase64String($_.RawData,'InsertLineBreaks')); [void]$sb.AppendLine("-----END CERTIFICATE-----") }; [IO.File]::WriteAllText("$HOME\corp-root-ca.pem", $sb.ToString())
|
||||
```
|
||||
|
||||
Confirm it has many certs (dozens, not 1):
|
||||
`(Select-String "BEGIN CERTIFICATE" $HOME\corp-root-ca.pem).Count`
|
||||
|
||||
```powershell
|
||||
# 2) Point every tool at it (one line, persistent). NODE_EXTRA_CA_CERTS also fixes Vite / npm run dev.
|
||||
git config --global http.sslCAInfo "$HOME\corp-root-ca.pem"; npm config set cafile "$HOME\corp-root-ca.pem"; setx NODE_EXTRA_CA_CERTS "$HOME\corp-root-ca.pem"; setx PIP_CERT "$HOME\corp-root-ca.pem"
|
||||
```
|
||||
|
||||
Reopen the terminal so `setx` takes effect. Quick unblock if you cannot
|
||||
export right now (skips verification - use briefly, then set back):
|
||||
`npm config set strict-ssl false`, `git config --global http.sslVerify false`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Get the code
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/ge-aero/shopdb-flask.git
|
||||
cd shopdb-flask
|
||||
```
|
||||
|
||||
Never work on `main`. Branch for your change:
|
||||
|
||||
```powershell
|
||||
git checkout -b feat/<short-description>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2a. Fast path - Docker (a working site in one command)
|
||||
|
||||
```powershell
|
||||
copy .env.example .env
|
||||
# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, and the MYSQL_* passwords.
|
||||
# Generate a secret: python -c "import secrets;print(secrets.token_urlsafe(64))"
|
||||
|
||||
docker compose up -d --build # MySQL + the app (frontend built in-image)
|
||||
|
||||
# Schema + platform data (idempotent, safe to re-run):
|
||||
docker compose exec api flask db upgrade
|
||||
docker compose exec api flask plugin upgrade-all
|
||||
docker compose exec api flask seed permissions
|
||||
docker compose exec api flask seed settings
|
||||
docker compose exec api flask seed reference-data
|
||||
docker compose exec api flask seed admin --username admin --email you@example.com
|
||||
docker compose exec api flask seed demo # OPTIONAL: sample data (undo: flask seed demo-clear)
|
||||
```
|
||||
|
||||
The app is on the port the compose file maps (see `docker-compose.yml`). Good
|
||||
for a smoke test; for active development use the manual path so the frontend
|
||||
hot-reloads.
|
||||
|
||||
---
|
||||
|
||||
## 2b. Manual path - venv + Node (the daily driver)
|
||||
|
||||
### Database
|
||||
|
||||
Either point at an existing MySQL 8, or bring one up with just the db service
|
||||
from compose:
|
||||
|
||||
```powershell
|
||||
docker compose up -d db # MySQL on 127.0.0.1:3306
|
||||
```
|
||||
|
||||
Create the database + app user (skip if compose already did via env):
|
||||
|
||||
```sql
|
||||
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'shopdb'@'%' IDENTIFIED BY 'devpassword';
|
||||
GRANT ALL PRIVILEGES ON shopdb_flask.* TO 'shopdb'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```powershell
|
||||
python -m venv venv
|
||||
venv\Scripts\Activate.ps1 # bash/mac: source venv/bin/activate
|
||||
# If PowerShell blocks the activate script (execution policy), run once:
|
||||
# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
copy .env.example .env # bash/mac: cp .env.example .env
|
||||
# Edit .env - set SECRET_KEY, JWT_SECRET_KEY, and
|
||||
# DATABASE_URL=mysql+pymysql://shopdb:devpassword@127.0.0.1:3306/shopdb_flask?charset=utf8mb4
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
$env:FLASK_APP = "shopdb" # bash/mac: export FLASK_APP=shopdb
|
||||
flask db upgrade # core schema
|
||||
flask plugin upgrade-all # per-plugin schema (ADR-008)
|
||||
flask seed permissions
|
||||
flask seed settings
|
||||
flask seed reference-data
|
||||
flask seed admin --username admin --email you@example.com # password printed once
|
||||
flask seed demo # OPTIONAL: ~25 sample assets across plugins + printed parts (undo: flask seed demo-clear)
|
||||
```
|
||||
|
||||
Enable the plugins you want visible (they install on a fresh box; some ship
|
||||
disabled). To turn on everything for development:
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
foreach ($p in "computers","employees","machines","measuringtools","network",
|
||||
"notifications","printers","slides","usb","warranty",
|
||||
"knowledgebase","geenforce","printedparts") {
|
||||
flask plugin install $p; flask plugin enable $p
|
||||
}
|
||||
```
|
||||
|
||||
(bash/mac: a `for p in ...; do flask plugin install "$p"; ...; done` loop.)
|
||||
|
||||
Run the backend ON PORT 5001 - the frontend dev server proxies `/api` and
|
||||
`/static` there (a bare `flask run` uses 5000 and nothing will load):
|
||||
|
||||
```powershell
|
||||
flask run --port 5001
|
||||
```
|
||||
|
||||
**Leave this running.** `flask run` does not return to a prompt - that is
|
||||
correct, not a hang. The server holds this terminal until you stop it. Do
|
||||
NOT press Ctrl+C to move on; that kills the backend. Open the frontend in a
|
||||
separate terminal (next section) and leave this one alone. Ctrl+C only when
|
||||
you are done for the day.
|
||||
|
||||
### Frontend (a second terminal - leave the backend running)
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Open http://localhost:5173, log in as `admin` with the printed password.
|
||||
|
||||
> Convenience: instead of two terminals you can run both under a process
|
||||
> manager (pm2, honcho, foreman). Keep the backend on 5001.
|
||||
|
||||
---
|
||||
|
||||
## 2c. VS Code (turnkey)
|
||||
|
||||
The repo ships shared VS Code config in `.vscode/` (personal `settings.json`
|
||||
stays git-ignored):
|
||||
|
||||
- **Recommended extensions** - on first open VS Code offers to install them
|
||||
(Python + Pylance, Vue Volar, ESLint, Docker). Accept.
|
||||
- **Run the dev site** - Command Palette > "Tasks: Run Task" >
|
||||
**Dev site (backend + frontend)** starts both servers in parallel (backend
|
||||
on 5001, frontend on 5173). Individual tasks exist too.
|
||||
- **Debug the backend** - the Run panel's **Flask API (:5001)** config runs
|
||||
the app under the debugger (breakpoints in routes/services, full
|
||||
stepping); **Pytest (current file)** debugs the open test file.
|
||||
- **The CI gate** - task **Check: naming + tests + build** runs the same
|
||||
three checks CI runs, before you commit.
|
||||
|
||||
Prerequisite: the venv and `npm install` from 2b must be done first (the
|
||||
tasks call `venv/` and `frontend/node_modules`).
|
||||
|
||||
---
|
||||
|
||||
## 3. The development loop
|
||||
|
||||
1. Make a change. Backend: `flask run` auto-reloads. Frontend: Vite hot-reloads.
|
||||
2. Before committing, run the three gates. Easiest: in VS Code, Command
|
||||
Palette > "Tasks: Run Task" > **Check: naming + tests + build**. By hand
|
||||
in PowerShell:
|
||||
```powershell
|
||||
venv\Scripts\python -m pytest tests/ -q # backend
|
||||
cd frontend; npx vitest run; npm run build; cd ..
|
||||
bash scripts/check-naming-and-style.sh # naming - runs via Git Bash
|
||||
```
|
||||
There is NO auto-installed git hook - you run these yourself (or the
|
||||
VS Code task). CI runs all three on every push and pull request
|
||||
(`.github/workflows/ci.yml` on GitHub Actions; the same gate runs on the
|
||||
internal server) and fails the build on a bad name, so nothing bad
|
||||
reaches `main` - running them locally just saves the round trip. The
|
||||
naming check is a shell script, so that one line needs Git Bash
|
||||
(installed with Git for Windows).
|
||||
|
||||
Want it automatic? The repo ships a hook; enable it once per clone:
|
||||
```powershell
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
Now every `git commit` runs the naming check first (Git for Windows
|
||||
executes the hook under its bundled bash) and blocks the commit if a name
|
||||
is wrong. Purely local convenience; CI is the real backstop.
|
||||
3. Commit in small, working steps. Subject: short, present tense, plain
|
||||
English; body says WHY. Read `CONTRIBUTING.md` before naming anything - the
|
||||
naming hook will reject snake_case DB columns, banned shorthand, and
|
||||
non-ASCII.
|
||||
|
||||
Seeing a change in the real app (not just tests) is the bar for "done" -
|
||||
drive the actual flow in the browser.
|
||||
|
||||
---
|
||||
|
||||
## 4. Your first change (suggested)
|
||||
|
||||
Add a field to an existing list page, or better, build a plugin end to end:
|
||||
`docs/PLUGIN-LAB-PRINTEDPARTS.md` is a literal type-along that constructs the
|
||||
3D-printed-parts plugin from scratch, with the finished code on branch
|
||||
`feat/printedparts-plugin` (tags `lab-stage-01`..`lab-stage-17`) as the
|
||||
answer key. It touches every hook the framework has.
|
||||
|
||||
---
|
||||
|
||||
## 5. Contributing back
|
||||
|
||||
```powershell
|
||||
git push -u origin feat/<short-description>
|
||||
```
|
||||
|
||||
Open a Pull Request against `main` on GitHub. Describe what changed, any
|
||||
plugin hooks implemented, and any contract additions (those need a version
|
||||
bump + `docs/PLUGIN-HOOKS.md` update in the same PR). See the contributor
|
||||
section of the plugin lab for the full review checklist.
|
||||
|
||||
---
|
||||
|
||||
## Common setup problems
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --- | --- |
|
||||
| Frontend loads but every API call fails / CORS error | backend not on 5001 (`flask run --port 5001`), or `CORS_ORIGINS` missing `http://localhost:5173`. |
|
||||
| App refuses to boot in production config | a required `.env` var (`SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, `CORS_ORIGINS`) missing or a dev default. |
|
||||
| `flask db upgrade` error 1071 (key too long) | MySQL 5.6 without the `innodb_large_prefix`/Barracuda flags; use MySQL 8 for dev. |
|
||||
| Nav missing Machines/PCs/... | plugins not installed/enabled (step 2b), or the backend not restarted after enabling. |
|
||||
| "No time zone found with key America/New_York" | `tzdata` not installed - `pip install -r requirements.txt` includes it. |
|
||||
| npm/git/pip SSL error (`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `unable to get local issuer certificate`) | corp proxy (Zscaler) intercepts HTTPS - point each tool at the corp root CA. See section 0b. |
|
||||
| Naming hook rejects a commit | you used snake_case on a DB-mirrored field or a banned acronym - see `CONTRIBUTING.md`. |
|
||||
| Plugin toggle throws an internal error | app cannot write `instance/` (the plugin registry lives there) - fix directory permissions. |
|
||||
@@ -88,13 +88,63 @@ POST /api/geenforce/report
|
||||
present), `skipped` (detected present), `failed`, `filtered`. `selfhealed`
|
||||
marks a drift correction.
|
||||
- shopdb keeps the latest report per (hostname, scope, phase) plus history, and
|
||||
surfaces it under Settings > Enforcement Reports.
|
||||
surfaces it under GE-Enforce > Enforcement Reports.
|
||||
|
||||
The engine already computes these counts (`installed/skipped/failed/pcFiltered`
|
||||
at the end of its main loop) and knows each entry's action; shape them into the
|
||||
`results` list at the call site (`New-ShopdbReport` in the kit takes a summary
|
||||
with `Installed/Skipped/Failed/Filtered` + a `Results` list).
|
||||
|
||||
The engine emits per-entry outcomes in PascalCase (`Name/Action/SelfHealed/
|
||||
ExitCode/Message`); `New-ShopdbReport` maps every per-entry key down to the
|
||||
lowercase names above (`name/action/selfhealed/exitcode/message`) before POST,
|
||||
so the entire wire contract shopdb reads is lowercase. `ConvertTo-ShopdbSummary`
|
||||
first normalizes whatever the engine returns (a well-formed summary, a bare
|
||||
return code, `$null`, or several emitted objects) into the count/results shape
|
||||
`New-ShopdbReport` expects, so a not-yet-compliant engine still produces a valid
|
||||
report.
|
||||
|
||||
## Common-scope inheritance (opt-in, OFF by default)
|
||||
|
||||
By default a PC enforces its `-Scope` ALONE. Pass `-IncludeCommon` to also fetch
|
||||
the fleet-wide `common` scope and merge it on top, mirroring the real
|
||||
GE-Enforce.ps1 (which applies `common\manifest.json` first, then the pctype's).
|
||||
When enabled, `Invoke-ShopdbEnforce.ps1` fetches `common` in addition to
|
||||
`-Scope` and merges it via `Merge-ShopdbManifests`:
|
||||
|
||||
- entries are keyed by `Name` (case-insensitive);
|
||||
- common's unique entries come first, then all pctype entries (common enforces
|
||||
ahead of the pctype, as on the share);
|
||||
- on a `Name` conflict the pctype entry wins (its override replaces common's).
|
||||
|
||||
Common is fetched over the same fail-safe path (ETag + last-known-good cache).
|
||||
`-CommonScope <name>` inherits a different fleet scope; a run whose `-Scope`
|
||||
already is the common scope does not merge itself.
|
||||
|
||||
Displays do NOT use this: the `gea-shopfloor-display` scope is self-sufficient,
|
||||
so the display scheduled task omits `-IncludeCommon`. Common-merge exists for a
|
||||
future share-less non-display PC that genuinely needs the fleet-wide entries
|
||||
(which would first require repackaging common's SMB payloads as http/inline).
|
||||
The three display subtypes (Dashboard, Lobby, 3D Print Room), selected by
|
||||
`C:\Enrollment\display-type.txt`, carry their shared policy inside the display
|
||||
scope itself, not via common.
|
||||
|
||||
## Fail-safe is observable, not silent
|
||||
|
||||
Any error still exits 0 - a bad web app never blocks or breaks a PC. But a fresh
|
||||
display with an EMPTY cache (first boot, shopdb unreachable or the token
|
||||
rejected with 401 / a TLS-trust failure) would otherwise enforce nothing
|
||||
*silently*. When no manifest and no cache are available, the kit:
|
||||
|
||||
- writes a Windows Application event-log entry (source `ShopdbEnforce`, event id
|
||||
1001, type Error) naming the scope and the reason (HTTP status or transport
|
||||
error), and
|
||||
- fires a best-effort report ping (counts `failed: 1`, a single
|
||||
`(manifest-fetch)` result carrying the reason) so the miss surfaces under
|
||||
GE-Enforce > Enforcement Reports.
|
||||
|
||||
The cycle still exits 0; the signal just makes the no-enforcement state visible.
|
||||
|
||||
## Cutover (safe, staged)
|
||||
|
||||
1. **Configure** the registry values on a canary PC; mint the token.
|
||||
@@ -105,7 +155,7 @@ with `Installed/Skipped/Failed/Filtered` + a `Results` list).
|
||||
3. **Read cutover**: drop `-ShadowMode`. The engine now runs against the
|
||||
shopdb-sourced manifest; payloads still come from the share. Rollback is a
|
||||
one-line revert to the share-sourced call. Keep exporting manifests from
|
||||
shopdb to the share (Settings > Imaging PC Types > Export to Share) so the
|
||||
shopdb to the share (GE-Enforce > Manifests > Export to Share) so the
|
||||
share stays a break-glass copy.
|
||||
4. **Payload migration** (optional, later): move small scripts/configs to
|
||||
`http`/`inline` payloads, verified by `payloadsha256`. Big MSIs stay on SMB.
|
||||
|
||||
@@ -106,6 +106,12 @@ retrofit it. `-NoTask` provisions identity + kit without registering the task.
|
||||
|
||||
Re-running it updates identity/config and re-registers the task in place.
|
||||
|
||||
For PC types that have cut over to HTTPS manifest delivery (currently
|
||||
displays/kiosks), the full server-side setup, auth model (IP allowlist vs
|
||||
ApiToken), and per-PC-type cutover playbook live in `geenforce-api-cutover.md`.
|
||||
This doc covers what gets laid on the PC; that doc covers where the manifest
|
||||
comes from.
|
||||
|
||||
---
|
||||
|
||||
## 5. The engine boundary
|
||||
|
||||
133
docs/GE-ENFORCE-DISPLAY.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# GE-Enforce: the gea-shopfloor-display scope
|
||||
|
||||
Displays are the share-less corner of the fleet. They are Entra-joined,
|
||||
credential-less kiosk PCs that pull their manifest over HTTPS on port 443 and
|
||||
authenticate with a read-only service PAT scoped `geenforce.fetch`, sent as
|
||||
`X-API-Key`. They have no SMB share mount. The kiosk engine and the kiosk
|
||||
browser are baked into the display image, not shipped over HTTPS, so the display
|
||||
manifest heals POLICY / CONFIG drift only, never EXEs. It is self-sufficient and
|
||||
does not inherit the fleet-wide `common` scope (see below).
|
||||
|
||||
## The display fetch token MUST be resource-bound
|
||||
|
||||
The same read-only key ships to every display (delivered by DSC, or baked into
|
||||
the image), so it must not be a skeleton key for the whole content store. Mint
|
||||
the display token bound to just this scope, so a leak cannot pull any other
|
||||
scope's manifest or any blob by hash:
|
||||
|
||||
```
|
||||
POST /api/apitokens
|
||||
{ "name": "display fetch", "scopes": ["geenforce.fetch"],
|
||||
"resourcescopes": ["gea-shopfloor-display"] }
|
||||
```
|
||||
|
||||
With `resourcescopes` set, `GET /manifest?pctype=<other>` returns 403 and
|
||||
`GET /payload/<sha>` returns 404 for any blob the display scope does not
|
||||
reference. `resourcescopes` NULL (unset) = unrestricted, for back-compat with
|
||||
existing service tokens. Rotate by minting a new bound token and revoking the
|
||||
old one (deactivate it server-side); DSC re-delivers, or re-image.
|
||||
|
||||
There are three display subtypes, selected by `C:\Enrollment\display-type.txt`:
|
||||
`Dashboard`, `Lobby`, and `3DPrintRoom`.
|
||||
|
||||
## Authoring the scope
|
||||
|
||||
The scope is authored programmatically by
|
||||
`plugins/geenforce/seed_display_scope.py`, which builds a manifest dict and
|
||||
hands it to `service.replace_scope_draft` (the same call the `import-share` CLI
|
||||
uses), then attaches the inline dispatcher payload. From a Flask app context:
|
||||
|
||||
```python
|
||||
from plugins.geenforce.seed_display_scope import seed_display_scope
|
||||
seed_display_scope(publish=True) # publish=False leaves it as a draft
|
||||
```
|
||||
|
||||
`replace_scope_draft` is an idempotent draft rebuild. `publish=True` additionally
|
||||
freezes an immutable published snapshot (that step is not idempotent: it always
|
||||
creates a new version).
|
||||
|
||||
### What the scope contains
|
||||
|
||||
1. Four `Registry` drift-heal entries that re-assert the Microsoft Edge kiosk
|
||||
relaunch policies set at imaging by `09-Setup-Display.ps1`. Each writes the
|
||||
value and detects drift with `DetectionMethod = ValueMatches` against the
|
||||
same path/name, so a display that loses a policy self-heals on the next
|
||||
enforce cycle with no keyboard or mouse on site:
|
||||
- `RelaunchNotification = 2` (DWord, Required auto-restart)
|
||||
- `RelaunchNotificationPeriod = 3600000` (DWord, 1 hour)
|
||||
- `RelaunchHeadsUpPeriod = 60000` (DWord, 1 minute)
|
||||
- `RelaunchWindow` (String, JSON, 02:00 start, 120 minute duration)
|
||||
2. One `PS1` dispatcher, delivered inline over HTTPS. It reads
|
||||
`C:\Enrollment\display-type.txt` and launches the kiosk target for the
|
||||
subtype. The subtype -> route map is a data-driven table
|
||||
(`DISPLAY_TYPE_TARGETS`) at the top of both the seed module and the generated
|
||||
script, so targets are easy to edit. `DetectionMethod = Always` so it
|
||||
re-asserts each cycle, but the script is idempotent (it skips relaunch if a
|
||||
kiosk process is already serving the target URL).
|
||||
|
||||
### Role resolution: server first, display-type.txt fallback
|
||||
|
||||
The dispatcher first asks the server: `GET
|
||||
/api/dashboarddefaults/display-role?fqdn=<fqdn>` (public, unauthenticated). A row
|
||||
in `dashboarddefaults` keyed by the display's FQDN (IP fallback) wins and returns
|
||||
the role and frontend path directly. Only when the server is unreachable or has
|
||||
no mapping does the dispatcher fall back to the local `display-type.txt` map
|
||||
below. To repurpose a display, edit its `dashboarddefaults` row; the change takes
|
||||
effect on the next enforce cycle.
|
||||
|
||||
Fallback map (local file):
|
||||
|
||||
| display-type.txt | kiosk route | notes |
|
||||
| --- | --- | --- |
|
||||
| `Dashboard` | `/shopfloor` | core ShopfloorDashboard, standalone full-screen |
|
||||
| `Lobby` | `/tv` | slides plugin TV dashboard (surface `lobby`) |
|
||||
| `3DPrintRoom` | `/parts-kiosk` | **PLACEHOLDER, TODO-confirm** printedparts parts kiosk route; confirm the real 3D-print-room target with the floor team before publishing to production displays |
|
||||
|
||||
### Dashboard-defaults FQDN keying
|
||||
|
||||
`dashboarddefaults` rows were historically keyed by IP. Migration
|
||||
`7d31_dashboarddefault_fqdn` added an `fqdn` column; resolution is now FQDN-first
|
||||
with IP as fallback (`_resolve_default` in
|
||||
`shopdb/core/api/dashboarddefaults.py`). FQDNs are stored lowercase. This
|
||||
survives DHCP churn on kiosk subnets. `POST /api/dashboarddefaults` accepts
|
||||
`fqdn`, `ipaddress`, `displayrole` (`dashboard`|`lobby`|`partskiosk`),
|
||||
`businessunitid`, and `description`; `displaypath` is not stored but derived from
|
||||
the role (`DISPLAY_ROLE_PATHS`). Two public read endpoints consume it:
|
||||
`/api/dashboarddefaults/display-role` (dispatcher) and
|
||||
`/api/dashboarddefaults/visitor-location` (lobby business-unit lookup). The
|
||||
server derives a display's FQDN from its reported BIOS serial as
|
||||
`F<serial>.<domain>` (`derive_display_fqdn`, domain from the `display_fqdn_domain`
|
||||
setting); the dispatcher in `plugins/geenforce/seed_display_scope.py` builds the
|
||||
same FQDN client-side for its lookup.
|
||||
|
||||
### Legacy autostart self-heal
|
||||
|
||||
The dispatcher also cleans up after the old GE Aerospace Dashboard / Lobby
|
||||
Display Inno installers, which planted autostarts (a Public-Desktop `.lnk`, an
|
||||
all-users Startup `.lnk`, and an `HKLM ...\CurrentVersion\Run` value) that
|
||||
relaunch Edge at now-dead URLs (`/shopfloor-dashboard/`, `/tv-dashboard/`) and
|
||||
white-screen. The 32-bit installer's Run value was WOW64-redirected into
|
||||
`Wow6432Node`, which is why it survived earlier cleanup. Every enforce cycle the
|
||||
dispatcher sweeps both registry views, all loaded user hives, Run/RunOnce/policy
|
||||
Run keys, and every per-user and common Startup folder, matching by legacy name
|
||||
and by the old URLs, then kills any old-URL Edge. The kiosk shortcut it writes is
|
||||
a direct Edge shortcut (no launcher or VBS). The fix ships by re-publishing this
|
||||
code-authored scope (`seed_display_scope(publish=True)`), not an import-share.
|
||||
`pxe-images/github/find-legacy-kiosk-autostart.ps1` is a read-only locator for
|
||||
stragglers.
|
||||
|
||||
## Self-sufficient: displays do NOT inherit common
|
||||
|
||||
The `gea-shopfloor-display` scope carries everything a display enforces. It does
|
||||
NOT inherit the fleet-wide `common` scope. Displays run the enforcer with
|
||||
common-merge off (the client default; common-merge is opt-in via
|
||||
`Invoke-ShopdbEnforce.ps1 -IncludeCommon`), so `common`'s SMB-backed fleet
|
||||
entries (Adobe, Oracle, OpenText, Defect Tracker, EventSaver, printer map,
|
||||
self-update, asset-reporting, ...) never reach a share-less display.
|
||||
|
||||
This was a deliberate decision: a display needs none of common's software, and
|
||||
inheriting common would have forced repackaging every SMB `common` payload as
|
||||
`http`/`inline` for a share-less box. Keeping the display scope self-sufficient
|
||||
avoids all of that. If a future non-display share-less PC genuinely needs the
|
||||
fleet-wide entries, that is what `-IncludeCommon` plus a per-entry SMB->http
|
||||
payload conversion would be for -- but displays do not use it.
|
||||
@@ -231,9 +231,9 @@ because it is a full management surface.
|
||||
### 4.1 Manifests - authoring (GE-Enforce > Manifests)
|
||||
|
||||
- **PC Types (scopes):** each imaging PC type is a row; add/edit/delete. (The
|
||||
scope carries an optional `computertypeid` reference field, but the collector's
|
||||
imaging-pc-type -> ComputerType mapping is configured separately at
|
||||
Settings > Collector PC Types.)
|
||||
scope's `computertypeid` field is now the imaging-pc-type -> ComputerType
|
||||
mapping mechanism, per ADR-012. The old Settings > Collector PC Types page is
|
||||
retired; there is nothing to configure there anymore.)
|
||||
- **Entries:** an ordered list (Up/Down = the execution-order contract). Add/Edit
|
||||
opens a typed form: the payload fields switch on `Type` (MSI shows Installer +
|
||||
InstallArgs, PS1 shows Script + Args, File shows Source + Destination, Registry
|
||||
@@ -288,8 +288,15 @@ The engine sources the manifest and reports results using the reference kit in
|
||||
(BaseUrl + a `geenforce.fetch`/`geenforce.report` service token). See
|
||||
`docs/GE-ENFORCE-CLIENT.md` for the fetch/report contract, the last-known-good
|
||||
cache, shadow mode, and the staged cutover from share-sourced to shopdb-sourced
|
||||
manifests. Until that cutover, the client only REPORTS; the manifest still comes
|
||||
from the share via Export to Share (4.2).
|
||||
manifests.
|
||||
|
||||
The cutover from share-sourced to shopdb-sourced manifests is per PC type. The
|
||||
**displays/kiosks cohort has cut over**: share-less display PCs fetch their
|
||||
manifest and payloads entirely over HTTPS (see `docs/geenforce-api-cutover.md`
|
||||
and `docs/GE-ENFORCE-DISPLAY.md`). All other fleet PC types (cmm, collections,
|
||||
keyence, genspect, heattreat, partmarker, nocollections, common) still enforce
|
||||
from the SFLD SMB share via Export to Share (4.2) and only REPORT to shopdb. The
|
||||
playbook for moving the next PC type is `geenforce-api-cutover.md` section 11.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Importing a site's legacy data
|
||||
|
||||
## Two routes in, and which one you want
|
||||
|
||||
**If the site has a spreadsheet and no developer**, use the CSV import. It is
|
||||
the common case, and it needs nothing beyond the templates:
|
||||
|
||||
```bash
|
||||
flask csv templates --out csv-templates # generated from the live schema
|
||||
# fill them in
|
||||
flask csv import --dir csv-templates # checks only, changes nothing
|
||||
flask csv import --dir csv-templates --commit
|
||||
```
|
||||
|
||||
Foreign keys take a NAME, not an id - write `Bay 3`, not `locationid=7`. The
|
||||
importer resolves them, including across files in the same run, and a name it
|
||||
cannot find is reported with the line, the column and the value. Nothing is
|
||||
written unless every row passes, and re-running an edited file updates rows
|
||||
rather than duplicating them. See [CSV-IMPORT.md](CSV-IMPORT.md).
|
||||
|
||||
**If the site has a source database to read from**, and someone able to script
|
||||
against it, the HTTP import API below is the better tool: it carries the whole
|
||||
history, preserves original timestamps, and handles relationships the CSV set
|
||||
does not model.
|
||||
|
||||
---
|
||||
|
||||
Every adopting site has its own source database - it will not match another
|
||||
site's schema. So the import is split in two layers:
|
||||
|
||||
@@ -25,6 +50,11 @@ implementation #1. Read it alongside this guide.
|
||||
- `run.py` - ordered `stage_*` functions. Each reads a slice of the source,
|
||||
POSTs it, and records the crosswalk later stages resolve foreign keys against.
|
||||
|
||||
Post-import fixups that re-point existing assets (example:
|
||||
`scripts/reclassify_servers_to_network.py`, servers imported as PCs moved to
|
||||
network devices in place) belong in the site loader's verify stage, not in the
|
||||
stable API layer.
|
||||
|
||||
### Stage order matters
|
||||
|
||||
Reference/lookup tables first (so foreign keys resolve), then the entity hub,
|
||||
|
||||
@@ -234,10 +234,13 @@ upload them after import via `POST /api/models/<modelid>/image` (multipart
|
||||
| `pctype` | `POST /api/computers/types` | `typename` -> `computertype`, `description` | `computertype` |
|
||||
| `subnettypes` | (see subnets) | used as `subnettype` string on subnets | - |
|
||||
| `subnets` | `POST /api/network/subnets` | `cidr`, `description` -> `name`/`description`, `vlan` -> create VLAN first (`POST /api/network/vlans`) then `vlanid`, `subnettypeid` -> `subnettype` name | `cidr` |
|
||||
| `dashboarddefaults` | `POST /api/dashboarddefaults` | `ipaddress` -> `ipaddress`, `businessunitid` (remapped), `description` | `ipaddress` |
|
||||
| `dashboarddefaults` | `POST /api/dashboarddefaults` | `fqdn` (preferred key, stored lowercase), `ipaddress` (fallback key), `displayrole` (`dashboard`/`lobby`/`partskiosk`), `businessunitid` (remapped; only the `dashboard` role uses it), `description` | `fqdn`, else `ipaddress` |
|
||||
| `controllertypes` | remap into `vendors` + `models` | e.g. "Fanuc" -> a Vendor; the controller model -> a Model; then set `controllervendorid`/`controllermodelid` on the machine | - |
|
||||
| `comstypes` | `communicationtypes` (seeded, no API) | ensure `flask seed reference-data` created IP/Serial/USB/... before importing comms | - |
|
||||
|
||||
Resolution at runtime is FQDN-first with IP fallback (migration
|
||||
`7d31_dashboarddefault_fqdn`); import both when the legacy source has them.
|
||||
|
||||
Note on communication types: the classic `comstypes.typename` values
|
||||
(IP, Serial, Network_Interface, USB, Parallel, VNC, FTP, DNC) correspond to the
|
||||
seeded `communicationtypes.comtype`. They are created by the reference-data seed,
|
||||
@@ -494,7 +497,7 @@ need it to remap foreign keys (a machine's `businessunitid`, a checkout's
|
||||
After each phase, compare counts. Legacy side (read-only), for example:
|
||||
|
||||
```bash
|
||||
docker exec dev-mysql mysql -uroot -prootpassword prodscratch \
|
||||
docker exec dev-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" prodscratch \
|
||||
-e "SELECT COUNT(*) FROM vendors;"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# ShopDB - Windows + IIS install runbook
|
||||
|
||||
> **Not the route for a new site.** Sister sites install from the Windows
|
||||
> installer - one `.exe`, no manual IIS work: **[INSTALL-WINDOWS.md](INSTALL-WINDOWS.md)**.
|
||||
>
|
||||
> This document is the **manual** procedure, kept for reference and for
|
||||
> hand-built servers that predate the installer. Note that the installer will not
|
||||
> adopt a server built this way without `-AdoptExisting`, on purpose.
|
||||
|
||||
|
||||
A step-by-step, **tested** install for a new site on Windows Server / Windows 11
|
||||
with IIS in front of the Flask app (HttpPlatformHandler -> waitress), backed by
|
||||
MySQL. This runbook was validated end to end on a win11 + IIS + MySQL 5.6 box.
|
||||
@@ -13,9 +21,9 @@ lives). Run PowerShell as Administrator.
|
||||
|
||||
| Need | Notes |
|
||||
| --- | --- |
|
||||
| **Python 3.12** (64-bit) | `python --version` |
|
||||
| **Python 3.14** (64-bit) | `python --version` |
|
||||
| **IIS** with **HttpPlatformHandler** | https://www.iis.net/downloads/microsoft/httpplatformhandler (direct MSI: `download.microsoft.com/download/8/1/3/813AC4E6-9203-4F7A-8DD5-F3D54D10C5CD/httpPlatformHandler_amd64.msi`) |
|
||||
| **MySQL 5.7+/8.0** (or 5.6 with the flags in step 1) | reachable from the app host |
|
||||
| **MySQL 8.4 LTS** (standard for new installs) | reachable from the app host. 8.0 reached end of life in April 2026 and no longer ships a standalone server MSI. 5.7+ still works on an existing server; 5.6 needs the flags in step 1. |
|
||||
| URL Rewrite (optional) | only for the real-client-IP rule; skip it and the app still runs |
|
||||
|
||||
The app itself pulls in `waitress` and `tzdata` from `requirements.txt` (step 4).
|
||||
@@ -113,7 +121,7 @@ venv\Scripts\flask seed settings
|
||||
# enable the plugins this site tracks (registry is empty on a fresh box).
|
||||
# usb + employees install DISABLED by default - enable them later in the wizard
|
||||
# if the site wants those (they create extra tables).
|
||||
foreach ($p in "computers","equipment","network","notifications","printers","knowledgebase","slides","warranty") {
|
||||
foreach ($p in "computers","machines","network","notifications","printers","knowledgebase","slides","warranty") {
|
||||
venv\Scripts\flask plugin install $p
|
||||
}
|
||||
|
||||
@@ -151,6 +159,8 @@ Two supported deployment methods:
|
||||
```powershell
|
||||
icacls APP_ROOT /grant "IIS AppPool\shopdbflask:(OI)(CI)RX" /T
|
||||
icacls APP_ROOT\logs /grant "IIS AppPool\shopdbflask:(OI)(CI)M" /T
|
||||
mkdir APP_ROOT\instance 2>NUL
|
||||
icacls APP_ROOT\instance /grant "IIS AppPool\shopdbflask:(OI)(CI)M" /T
|
||||
```
|
||||
4. **Unlock the handler sections** (locked server-wide by default; without this
|
||||
IIS returns **HTTP 500.19**):
|
||||
@@ -236,8 +246,9 @@ each gets its own site, app pool, port, and venv.
|
||||
| IIS **500.52** after enabling the rewrite block | `allowedServerVariables` locked at server level - `appcmd unlock config -section:system.webServer/rewrite/allowedServerVariables`. |
|
||||
| Audit log shows only **127.0.0.1** with the rewrite block active | waitress strips untrusted proxy headers - `--trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for` missing from the waitress `arguments`. |
|
||||
| **500** with an empty HttpPlatform log | app-pool identity can't read `APP_ROOT` / run the venv (step 7.3), or `.env` missing/invalid. |
|
||||
| "internal error" toggling plugins, or uploads fail | app pool cannot WRITE `APP_ROOT\instance` (plugin registry, logos, photos, files live there) - step 7.3 grants it Modify. |
|
||||
| "No time zone found with key America/New_York" | `tzdata` not installed (`pip install tzdata`). |
|
||||
| Nav missing Equipment/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. |
|
||||
| Nav missing Machines/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. |
|
||||
| Method B: blank page / assets 404 under `/ops` | frontend `dist` built without `VITE_BASE_PATH=/ops/` (step 7b.1). |
|
||||
| Method B: SPA loads but every API call 404s | `MOUNT_PATH` unset or not matching the Application alias (step 7b.3). |
|
||||
| ConfigError on boot | a required `.env` var missing or left at a dev default. |
|
||||
|
||||
216
docs/INSTALL-WINDOWS.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Install ShopDB-Flask on Windows Server
|
||||
|
||||
**This is the route for a new site.** You run one `.exe`, answer a few questions,
|
||||
and get a working application. Nothing here needs an internet connection, and you
|
||||
do not need to know IIS, Python or MySQL.
|
||||
|
||||
If you are looking after an existing hand-built server, see
|
||||
[DEPLOY-WINDOWS-IIS.md](DEPLOY-WINDOWS-IIS.md) instead - that is the manual
|
||||
procedure, and the installer will not adopt a server it did not build.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
You need **four things**. The installer supplies everything else.
|
||||
|
||||
| | What | How to check |
|
||||
|---|---|---|
|
||||
| 1 | Windows Server 2019 or newer | `winver` |
|
||||
| 2 | The **IIS Web Server role** installed | Server Manager -> Manage -> Add Roles -> Web Server (IIS). Or run the PowerShell below. |
|
||||
| 3 | Administrator rights on the box | Right-click PowerShell -> "Run as administrator" works |
|
||||
| 4 | A decision about the database - see [Which database?](#which-database) | - |
|
||||
|
||||
Installing IIS, if it is missing (this needs no internet):
|
||||
|
||||
```powershell
|
||||
Install-WindowsFeature -Name Web-Server -IncludeManagementTools
|
||||
```
|
||||
|
||||
The installer **checks all of this before it changes anything**, and it will not
|
||||
let you continue until the check passes. You do not have to get it right first
|
||||
time.
|
||||
|
||||
### Which database?
|
||||
|
||||
Two options. Pick before you start, because they ask different questions.
|
||||
|
||||
- **Use the bundled MySQL** - the installer puts MySQL 8.4 LTS on this server and
|
||||
creates the database for you. Choose this when the server has no database
|
||||
today. Simplest option, nothing to arrange in advance.
|
||||
- **Use an existing MySQL** - the database already exists somewhere, and you have
|
||||
a hostname, a database name, a username and a password for it. Choose this if
|
||||
your site already runs MySQL, or a DBA looks after it.
|
||||
|
||||
If you are unsure: if nobody has given you database credentials, you want the
|
||||
bundled option.
|
||||
|
||||
### SQL for your DBA (existing-MySQL option only)
|
||||
|
||||
The installer does not create the database or the user - it never needs
|
||||
administrative rights on your database server. Ask your DBA to run:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'shopdb'@'%' IDENTIFIED BY '<a password you choose>';
|
||||
GRANT ALL PRIVILEGES ON shopdb_flask.* TO 'shopdb'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
The `utf8mb4` charset matters - the default on older servers is `latin1`, and a
|
||||
latin1 database mangles any non-ASCII text you store.
|
||||
|
||||
---
|
||||
|
||||
## Installing
|
||||
|
||||
1. Copy the installer `.exe` onto the server. It is one file and needs no
|
||||
network.
|
||||
2. **Right-click it -> Run as administrator.** Without this it cannot configure
|
||||
IIS, and it will tell you so.
|
||||
3. Work through the wizard. The pages are:
|
||||
|
||||
| Page | What it wants | If unsure |
|
||||
|---|---|---|
|
||||
| **Server check** | Nothing - it reports what it found | Fix anything red, then "Check again". You cannot continue while something is red, and nothing has been changed yet. |
|
||||
| **Features** | Which parts of the product this site uses | The defaults are fine. You can add more later; removing needs a new installer. |
|
||||
| **Database** | Bundled or existing - see above | Bundled |
|
||||
| **Database details** | Host, port, name, user, password | Only asked for the existing-database option |
|
||||
| **Address** | How people reach the site | See [Own address or subpath?](#own-address-or-subpath) |
|
||||
| **Client addresses** | Whether a proxy sits in front | See [Client addresses](#client-addresses) |
|
||||
| **Location** | Where to install | `C:\shopdb-flask` is fine |
|
||||
|
||||
4. The install takes a few minutes. Most of it is Python and the database schema.
|
||||
5. At the end you get the address to open. **Write it down** - it is also on the
|
||||
Start Menu as "Open ShopDB-Flask".
|
||||
|
||||
### Own address or subpath?
|
||||
|
||||
- **Its own address** - `http://yourserver:8090/`. Choose this on a server that
|
||||
is not already running a website. Simplest.
|
||||
- **Under this server's existing address** - `http://yourserver/shopdb/`. Choose
|
||||
this when the server already serves something else and you do not want a second
|
||||
port or a new DNS name. This is what West Jefferson uses.
|
||||
|
||||
You cannot change your mind later without re-running the installer, because the
|
||||
web interface has the address compiled into it.
|
||||
|
||||
### Client addresses
|
||||
|
||||
The application records who connects, and some features decide what to show based
|
||||
on it. The wizard asks one question:
|
||||
|
||||
- **Clients connect to this server directly** - the normal answer. Pick this
|
||||
unless you know otherwise.
|
||||
- **A proxy or load balancer sits in front** - pick this only if your network
|
||||
team has told you traffic reaches this server through something else first.
|
||||
|
||||
Getting this wrong is not dangerous, but the site will record every visitor as
|
||||
coming from the server itself, and features that depend on location will not
|
||||
work. It can be changed later by re-running the installer.
|
||||
|
||||
---
|
||||
|
||||
## First login
|
||||
|
||||
Open the address the installer gave you. With no users in the database yet, the
|
||||
page offers to **create the first administrator**, then runs a short setup wizard
|
||||
for site details, features and the floor map.
|
||||
|
||||
That first account is a normal administrator account. Use a real password -
|
||||
this is the account that creates everyone else.
|
||||
|
||||
---
|
||||
|
||||
## Did it work?
|
||||
|
||||
From the Start Menu, open **ShopDB-Flask Console** and pick option 1, or:
|
||||
|
||||
```powershell
|
||||
cd C:\shopdb-flask
|
||||
.\shopdb-admin.ps1 status
|
||||
```
|
||||
|
||||
You want to see the site started, the pool started, and `responding : yes`.
|
||||
|
||||
Day-to-day tasks - restarting, backups, logs, upgrades - are in
|
||||
[OPERATE-WINDOWS.md](OPERATE-WINDOWS.md).
|
||||
|
||||
---
|
||||
|
||||
## If the install fails
|
||||
|
||||
**The server is left part-configured.** Whatever had been done before the failure
|
||||
is still there. That is deliberate: it means re-running is able to pick up where
|
||||
it stopped.
|
||||
|
||||
1. Read the error. It names the cause and what to do about it.
|
||||
2. Fix that, then **run the same installer again**. Re-running is safe - it skips
|
||||
what is already done and does not touch your database or `.env`.
|
||||
3. If you would rather start clean, remove it from **Settings -> Apps** first.
|
||||
|
||||
The full log is at:
|
||||
|
||||
```
|
||||
C:\ProgramData\ShopDB-Flask\logs\shopdb-install-<date>.log
|
||||
```
|
||||
|
||||
It records every step, including everything that was created. Send this if you
|
||||
need help.
|
||||
|
||||
### Getting help from an AI assistant
|
||||
|
||||
These installs are often done with an assistant open in another window. Give it
|
||||
real state rather than a description:
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 check -Json
|
||||
```
|
||||
|
||||
That prints one structured block covering the version, how the site is published,
|
||||
IIS state, database reachability, Python version, installed features and any
|
||||
errors. Paste it in. **It contains no passwords.** The install log is also safe
|
||||
to share - the installer keeps secrets out of it deliberately.
|
||||
|
||||
Offline API reference for this server is served at `/api/docs` on the site
|
||||
itself, and `docs\` in the install directory holds these runbooks.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
Run a newer installer over the top. It:
|
||||
|
||||
- backs the database up first, **verifies the dump is complete**, and refuses to
|
||||
continue if it cannot;
|
||||
- restores from that backup if the schema migration fails;
|
||||
- refuses to install an **older** build over a newer one;
|
||||
- keeps your `.env`, your data and your `web.config`.
|
||||
|
||||
Nothing else is required. See [UPGRADE.md](UPGRADE.md).
|
||||
|
||||
> **Before your first upgrade:** confirm `mysqldump` is available - the console's
|
||||
> health check reports it. Without it the pre-upgrade backup is skipped, and that
|
||||
> is the one you would want if a migration went wrong. It ships with the bundled
|
||||
> database option; for an existing remote database, ask for `mysqlclient\` to be
|
||||
> included in your installer bundle.
|
||||
|
||||
---
|
||||
|
||||
## Removing it
|
||||
|
||||
**Settings -> Apps -> ShopDB-Flask**, or Add/Remove Programs.
|
||||
|
||||
That removes the website, the application pool, the firewall rule and the
|
||||
application directory. It deliberately **does not** drop the database and does
|
||||
not uninstall MySQL, so your data survives.
|
||||
|
||||
Take a backup first: `.\shopdb-admin.ps1 backup`
|
||||
|
||||
---
|
||||
|
||||
## Notes for the person who builds the installer
|
||||
|
||||
Building a bundle for a site is a separate job, documented in
|
||||
[../deploy/windows/installer/README.md](../deploy/windows/installer/README.md).
|
||||
Sites receive a finished `.exe`; they do not build one.
|
||||
241
docs/OPERATE-WINDOWS.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Running ShopDB-Flask on Windows Server
|
||||
|
||||
Day-to-day operation of a site installed with the Windows installer. If you are
|
||||
installing for the first time, start with [INSTALL-WINDOWS.md](INSTALL-WINDOWS.md).
|
||||
|
||||
Everything here goes through one tool, installed alongside the application:
|
||||
|
||||
```
|
||||
C:\shopdb-flask\shopdb-admin.ps1
|
||||
```
|
||||
|
||||
The Start Menu folder **ShopDB-Flask** has shortcuts for the common tasks. Run it
|
||||
with no arguments for a menu, or pass a command directly. It needs
|
||||
Administrator - it will ask, except for `open`.
|
||||
|
||||
---
|
||||
|
||||
## The commands
|
||||
|
||||
| Command | What it does | Safe at any time |
|
||||
|---|---|---|
|
||||
| `status` | Is it published, running, responding; database and table count | yes |
|
||||
| `restart` | Recycles the application pool. **Use this after any config change.** | yes - drains requests rather than cutting them off |
|
||||
| `stop` / `start` | Takes the site down / brings it back | yes, but `stop` makes it unavailable |
|
||||
| `logs` | Last lines of the application and install logs | yes |
|
||||
| `check` | Full health check | yes |
|
||||
| `check -Json` | The same, machine-readable - see [Getting help](#getting-help) | yes |
|
||||
| `verify` | Which build this is, and whether what is installed still matches it | yes |
|
||||
| `sessions` | IIS worker processes and memory | yes |
|
||||
| `plugins` | Which features are installed, which are available | yes |
|
||||
| `add-plugin -Path <name>` | Turns on a feature this build ships | changes the site; restarts it |
|
||||
| `backup [-Path <dir>]` | Writes a verified `.sql` dump | yes, but see below |
|
||||
| `open` | Opens the site in a browser | yes |
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 status
|
||||
.\shopdb-admin.ps1 restart
|
||||
.\shopdb-admin.ps1 backup D:\backups
|
||||
.\shopdb-admin.ps1 verify -Path leaflet
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backups
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 backup
|
||||
```
|
||||
|
||||
Writes to `C:\ProgramData\ShopDB-Flask\backups` unless you pass a directory. The
|
||||
dump is **verified complete** before it is reported as good - a truncated backup
|
||||
is deleted rather than left to be discovered later.
|
||||
|
||||
Two things to know:
|
||||
|
||||
- **The dump contains everything, including user password hashes.** The directory
|
||||
is locked to Administrators and SYSTEM. Keep it that way, and treat copies as
|
||||
sensitive.
|
||||
- **Store it off this server.** A backup on the server does not survive the
|
||||
server.
|
||||
|
||||
An upgrade takes its own backup automatically, before it touches the schema.
|
||||
|
||||
Restore, and the Linux/Docker equivalents, are in
|
||||
[BACKUP-RESTORE.md](BACKUP-RESTORE.md).
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
Run a newer installer over the top. Nothing else. It backs up first, refuses to
|
||||
go backwards, and restores if a migration fails. See
|
||||
[UPGRADE.md](UPGRADE.md).
|
||||
|
||||
---
|
||||
|
||||
## Adding a feature
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 plugins # what is here
|
||||
.\shopdb-admin.ps1 add-plugin -Path warranty # turn one on
|
||||
```
|
||||
|
||||
Only features **shipped in this build** can be added. Each site's installer is
|
||||
built for that site's chosen feature set, so a feature nobody asked for is not on
|
||||
the server at all - adding it means a new installer built from an updated
|
||||
profile. `plugins` shows you which is which.
|
||||
|
||||
---
|
||||
|
||||
## When something is wrong
|
||||
|
||||
Work down this list.
|
||||
|
||||
**1. Is it actually down?**
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 status
|
||||
```
|
||||
|
||||
`responding : NO` with the pool `Started` usually means the application failed to
|
||||
start, not that IIS is broken.
|
||||
|
||||
**2. What does it say?**
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 logs
|
||||
```
|
||||
|
||||
Application logs are in `C:\shopdb-flask\logs`, install logs in
|
||||
`C:\ProgramData\ShopDB-Flask\logs`.
|
||||
|
||||
**3. Try a restart.** It fixes anything that is a stuck worker, and tells you
|
||||
immediately if it is not:
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 restart
|
||||
```
|
||||
|
||||
**4. Check the database is reachable** - `status` reports the host and whether it
|
||||
could count tables. A site that starts but shows no data is usually a database
|
||||
problem, not an application one.
|
||||
|
||||
**5. Confirm nothing has drifted:**
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 verify
|
||||
```
|
||||
|
||||
This flags packages that no longer match what shipped - which usually means
|
||||
somebody ran a `pip install` on the server by hand.
|
||||
|
||||
---
|
||||
|
||||
## Getting help
|
||||
|
||||
Give an assistant real state rather than describing the symptom:
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 check -Json
|
||||
```
|
||||
|
||||
One structured block: version, how the site is published, IIS and pool state,
|
||||
whether it responds, database host and reachability, Python version, installed
|
||||
features, and any errors. **It contains no passwords** and is safe to paste into
|
||||
a chat window or a ticket.
|
||||
|
||||
The install log is also safe to share - secrets are deliberately kept out of it.
|
||||
|
||||
Offline reference on the server itself:
|
||||
|
||||
- `/api/docs` on the site - the full API reference, self-hosted, no internet.
|
||||
- `C:\shopdb-flask\docs\` - these runbooks.
|
||||
- `C:\shopdb-flask\sbom.cdx.json` - every component this build contains.
|
||||
|
||||
---
|
||||
|
||||
## Answering "are we affected by this vulnerability?"
|
||||
|
||||
The server carries its own bill of materials, so this does not need the build box
|
||||
or an internet connection:
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 verify -Path <component-name>
|
||||
```
|
||||
|
||||
It reports whether the component is here, at what version, and whether it
|
||||
actually **ships** or is only used to build the software. Example:
|
||||
|
||||
```
|
||||
matches for 'leaflet':
|
||||
leaflet 1.9.4 SHIPPED
|
||||
```
|
||||
|
||||
Nothing found means this server does not carry it.
|
||||
|
||||
---
|
||||
|
||||
## Adding HTTPS
|
||||
|
||||
**The installer publishes over HTTP.** It has no certificate to use and no way to
|
||||
get one on an air-gapped server, so it does not pretend otherwise. On an internal
|
||||
network behind the site firewall that is often accepted; confirm it against your
|
||||
own policy rather than assuming.
|
||||
|
||||
If you installed **under an existing site** (the subpath option) and that site
|
||||
already has a certificate, you are already on HTTPS - nothing to do.
|
||||
|
||||
For a site of its own, once you have a certificate in the machine store:
|
||||
|
||||
```powershell
|
||||
Import-Module WebAdministration
|
||||
|
||||
# 1. Add the binding. Get the thumbprint from the certificate you imported.
|
||||
New-WebBinding -Name shopdb-flask -Protocol https -Port 443
|
||||
$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Subject -like '*yourserver*' }
|
||||
Get-Item "IIS:\SslBindings\0.0.0.0!443" -EA SilentlyContinue | Remove-Item -EA SilentlyContinue
|
||||
New-Item "IIS:\SslBindings\0.0.0.0!443" -Value $cert
|
||||
|
||||
# 2. Open the port.
|
||||
New-NetFirewallRule -DisplayName "shopdb-flask 443" -Direction Inbound `
|
||||
-Protocol TCP -LocalPort 443 -Action Allow
|
||||
```
|
||||
|
||||
Then **update `CORS_ORIGINS` in `C:\shopdb-flask\.env`** to the `https://` address
|
||||
and restart:
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 restart
|
||||
```
|
||||
|
||||
That last step is not optional. `CORS_ORIGINS` is an exact origin match, so a
|
||||
site reached over `https://` while `.env` still says `http://` loads the page and
|
||||
then fails every data request - which looks like the application is broken rather
|
||||
than a configuration mismatch.
|
||||
|
||||
## Where things live
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Application | `C:\shopdb-flask` |
|
||||
| Configuration and secrets | `C:\shopdb-flask\.env` (locked down - do not loosen) |
|
||||
| Application logs | `C:\shopdb-flask\logs` |
|
||||
| Install logs | `C:\ProgramData\ShopDB-Flask\logs` |
|
||||
| Backups | `C:\ProgramData\ShopDB-Flask\backups` |
|
||||
| Bill of materials | `C:\shopdb-flask\sbom.cdx.json` |
|
||||
| Which build this is | `C:\shopdb-flask\.installed-version` |
|
||||
|
||||
If the bundled MySQL was installed, its generated root password was written once
|
||||
to `C:\ProgramData\ShopDB-Flask\mysql-root-password.txt`. **Move it into your
|
||||
password manager and delete that file.** It cannot be recovered.
|
||||
|
||||
## See also
|
||||
|
||||
- [UPDATES-WINDOWS.md](UPDATES-WINDOWS.md) - what future updates, bug fixes and
|
||||
security releases will look like, including downtime and the effect on other
|
||||
sites on the same IIS server
|
||||
- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - what to back up and how to restore
|
||||
- [INSTALL-WINDOWS.md](INSTALL-WINDOWS.md) - installing a new site
|
||||
@@ -76,16 +76,32 @@ import API. It is site glue, not product code.
|
||||
venv/bin/python -m scripts.site_imports.wjf.run
|
||||
```
|
||||
|
||||
The 15 stages run in order (reference -> catalog -> assets hub -> locations ->
|
||||
The 16 stages run in order (reference -> catalog -> assets hub -> locations ->
|
||||
printers -> dependents -> relationships -> subnets -> usb -> verify). It is
|
||||
idempotent - a crashed run resumes from `idmap.json`.
|
||||
|
||||
3. Reclassify servers into network devices. The classic DB stored servers as
|
||||
PCs, so the import lands them as `computer` assets. Re-point them in place:
|
||||
|
||||
```
|
||||
DATABASE_URL=... venv/bin/python scripts/reclassify_servers_to_network.py # dry run, prints matches
|
||||
DATABASE_URL=... venv/bin/python scripts/reclassify_servers_to_network.py --commit # apply
|
||||
# match on an exact computer type instead of the SVR- name prefix:
|
||||
... --type "Server" --commit
|
||||
```
|
||||
|
||||
The assetid does not change: communications, relationships, map position, and
|
||||
audit history carry over. Only the extension row is swapped (computers ->
|
||||
networkdevices) and the asset type flipped; reclassified devices get the
|
||||
`Server` networkdevicetype. Run the dry run, eyeball the list, then commit.
|
||||
Re-running is safe (already-moved assets no longer match).
|
||||
|
||||
Expected magnitude (from the WJ dumps used in development - your fresh dumps will
|
||||
differ slightly):
|
||||
|
||||
| entity | count |
|
||||
|---|---|
|
||||
| assets | ~983 (computer ~663, machine ~76, network ~58, measuring-tool ~136, printer ~50) |
|
||||
| assets [*] | ~983 (computer ~663, machine ~76, network ~58, measuring-tool ~136, printer ~50) |
|
||||
| locations | ~24 |
|
||||
| employees | ~415 |
|
||||
| installs | ~850 |
|
||||
@@ -97,6 +113,16 @@ differ slightly):
|
||||
| subnets | ~37 |
|
||||
| USB devices / events | ~18 / ~232 |
|
||||
|
||||
[*] Counts taken AFTER `scripts/reclassify_servers_to_network.py --commit`.
|
||||
Servers imported as computers are re-pointed to network devices, so the computer
|
||||
count drops and network rises by the same amount versus a raw import.
|
||||
|
||||
> PLACEHOLDER - re-measure before publishing. The computer/network split shown
|
||||
> in the assets row above still reflects a RAW import (pre-reclassify). Re-run
|
||||
> the counts on the current prodscratch AFTER the reclassify step above and drop
|
||||
> in the actual numbers; do not carry these development figures forward as if
|
||||
> they already account for the reclassify.
|
||||
|
||||
The `verify` stage prints a source-vs-target row-count audit; the gaps are the
|
||||
documented skips (inactive rows, duplicate machinenumbers, LocationOnly, the
|
||||
9999 placeholder).
|
||||
|
||||
@@ -7,6 +7,12 @@ the framework ships a bundled set, and you drop your own plugin into
|
||||
`<framework>/plugins/<name>/` by clone, submodule, or symlink. No pip packaging
|
||||
is required for v1 (pip distribution is deferred to v2 per ADR-003).
|
||||
|
||||
> **Windows / VS Code:** command examples use the Linux venv path
|
||||
> `venv/bin/python`; on Windows use `venv\Scripts\python` and
|
||||
> `$env:FLASK_APP="shopdb"` (not `export`). Full Windows onboarding:
|
||||
> [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md).
|
||||
|
||||
|
||||
If you have not written a plugin before, start with
|
||||
[PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) and the hook reference in
|
||||
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md). This document only covers the parts that are
|
||||
@@ -63,18 +69,23 @@ live. The loader discovers a symlinked directory the same as a real one.
|
||||
|
||||
```bash
|
||||
# 1. Clone the framework and your plugin repo side by side.
|
||||
git clone https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||
git clone https://gitea.proudtech.net/wjsf/wjsf-shipping.git
|
||||
git clone https://github.com/ge-aero/shopdb-flask.git
|
||||
git clone https://github.com/ge-aero/wjsf-shipping.git
|
||||
|
||||
# 2. Symlink your repo into the framework's plugins/ directory.
|
||||
# The link name is the plugin name from your manifest.json.
|
||||
cd shopdb-flask
|
||||
ln -s ../../wjsf-shipping plugins/shipping
|
||||
# (use an absolute path if you prefer: ln -s "$(pwd)/../wjsf-shipping" plugins/shipping)
|
||||
#
|
||||
# Windows: use a directory junction instead of ln -s. In an ADMIN prompt
|
||||
# (or with Developer Mode on) from the shopdb-flask dir:
|
||||
# mklink /D plugins\shipping ..\..\wjsf-shipping
|
||||
# The plugin loader treats a junction the same as a real directory.
|
||||
|
||||
# 3. Set up the framework as usual.
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r requirements.txt
|
||||
venv/bin/pip install -r requirements-dev.txt
|
||||
|
||||
# 4. Install (enable) your plugin.
|
||||
venv/bin/flask plugin install shipping
|
||||
@@ -101,7 +112,7 @@ admits only the contract minor you tested against, not the whole 0.x line.
|
||||
The current contract version is declared in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.6.0'
|
||||
__contract_version__ = '0.13.0'
|
||||
```
|
||||
|
||||
Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges):
|
||||
@@ -111,7 +122,7 @@ Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges):
|
||||
"name": "shipping",
|
||||
"version": "1.0.0",
|
||||
"description": "Tracks shipping-station scanners and label printers",
|
||||
"core_version": ">=0.6.0,<0.7.0",
|
||||
"core_version": ">=0.13.0,<0.14.0",
|
||||
"dependencies": []
|
||||
}
|
||||
```
|
||||
@@ -177,14 +188,14 @@ The full script:
|
||||
# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required.
|
||||
# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main.
|
||||
# FRAMEWORK_URL framework git URL for CI mode.
|
||||
# Default: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||
# Default: https://github.com/ge-aero/shopdb-flask.git
|
||||
# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline.
|
||||
|
||||
set -eu
|
||||
|
||||
PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
|
||||
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
|
||||
FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}"
|
||||
FRAMEWORK_URL="${FRAMEWORK_URL:-https://github.com/ge-aero/shopdb-flask.git}"
|
||||
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"
|
||||
|
||||
if [ -z "$PLUGIN_DIR" ]; then
|
||||
@@ -228,7 +239,7 @@ else
|
||||
python3 -m venv "$WORKDIR/venv"
|
||||
PYTHON="$WORKDIR/venv/bin/python"
|
||||
"$PYTHON" -m pip install --upgrade pip >/dev/null
|
||||
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt"
|
||||
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements-dev.txt"
|
||||
fi
|
||||
|
||||
echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/"
|
||||
@@ -312,14 +323,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FRAMEWORK_REF: v0.5.0
|
||||
FRAMEWORK_URL: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||
FRAMEWORK_URL: https://github.com/ge-aero/shopdb-flask.git
|
||||
steps:
|
||||
- name: Check out the plugin
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.14'
|
||||
- name: Fetch the harness from the framework
|
||||
run: |
|
||||
git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" /tmp/framework
|
||||
|
||||
@@ -5,6 +5,10 @@ running feature with its own list, detail, form, settings page, and report. It i
|
||||
the companion to [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md): the quickstart gets
|
||||
you moving with `flask plugin new`; this guide explains *why* each piece looks the
|
||||
way it does by walking the shipped code of the exemplar plugin.
|
||||
> **Windows / VS Code:** command examples use the Linux venv path
|
||||
> `venv/bin/python`; on Windows use `venv\Scripts\python` and
|
||||
> `$env:FLASK_APP="shopdb"` (not `export`). Full Windows onboarding:
|
||||
> [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md).
|
||||
|
||||
`measuringtools` was chosen as the exemplar on purpose. It is the first plugin
|
||||
built after the framework matured (ADR-005 scoped it; ADR-008 changed how plugin
|
||||
@@ -74,7 +78,7 @@ plugin's identity (ADR-002):
|
||||
Two fields deserve attention.
|
||||
|
||||
`core_version` is a semver range against the framework's `__contract_version__`
|
||||
(declared in `shopdb/__init__.py`, currently `0.6.0`). The loader refuses to load
|
||||
(declared in `shopdb/__init__.py`, currently `0.13.0`). The loader refuses to load
|
||||
a plugin whose range excludes the running framework. We pin `>=0.6.0` because this
|
||||
plugin uses the `get_reports` hook, which was added to the contract in 0.6.0
|
||||
(see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because
|
||||
@@ -268,7 +272,7 @@ assertion to a frozen `CUTOVER_PLUGINS` list rather than to all discovered plugi
|
||||
and to expect `measuringtools`'s real baseline revision in the upgrade-all test:
|
||||
|
||||
```python
|
||||
CUTOVER_PLUGINS = ('computers', 'employees', 'equipment', 'knowledgebase',
|
||||
CUTOVER_PLUGINS = ('computers', 'employees', 'knowledgebase', 'machines',
|
||||
'network', 'notifications', 'printers', 'slides', 'usb', 'warranty')
|
||||
|
||||
@pytest.mark.parametrize('plugin', CUTOVER_PLUGINS) # not all plugins
|
||||
@@ -277,7 +281,10 @@ def test_anchor_migration_is_noop(plugin):
|
||||
```
|
||||
|
||||
Freezing the list (rather than deriving it) is intentional: a newly discovered
|
||||
plugin should not silently be treated as a cutover no-op.
|
||||
plugin should not silently be treated as a cutover no-op. Note that `machines`
|
||||
(renamed from `equipment`, ADR-011) keeps its original cutover anchor but carries
|
||||
a `machines0002rename` revision on top of it, so its expected head is that rename
|
||||
revision rather than the bare anchor (`tests/test_plugin_migrations.py`).
|
||||
|
||||
---
|
||||
|
||||
@@ -495,23 +502,25 @@ the plugin small.
|
||||
|
||||
## 9. Frontend integration
|
||||
|
||||
There is no frontend plugin system yet (see
|
||||
[ADR-009](adr/ADR-009-frontend-plugin-gating.md), "Future direction"). A plugin's
|
||||
Vue routes and views ship in the core bundle. The plugin's job is to add them
|
||||
correctly and gate them.
|
||||
A plugin ships its own Vue routes and views under
|
||||
`plugins/<name>/frontend/` (ADR-010's frontend plugin hook contract). At build
|
||||
time those files are staged into `frontend/src/.plugins-staged/<name>/` so the
|
||||
core bundle picks them up; you author them in the plugin tree, not in core
|
||||
`frontend/src/`. The plugin's job is to add them correctly and gate them.
|
||||
|
||||
**Route module with `meta.plugin` gating (ADR-009).** A new file
|
||||
`frontend/src/router/routes/measuringtools.js` is auto-discovered by the router's
|
||||
**Route module with `meta.plugin` gating (ADR-009).** The file
|
||||
`plugins/measuringtools/frontend/routes.js` is staged into
|
||||
`frontend/src/.plugins-staged/measuringtools/` and auto-discovered by the router's
|
||||
`import.meta.glob('./routes/*.js')`. Every route carries `meta.plugin =
|
||||
'measuringtools'`:
|
||||
|
||||
```js
|
||||
export default [
|
||||
{ path: 'measuringtools', name: 'measuringtools',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolsList.vue'),
|
||||
component: () => import('./views/MeasuringToolsList.vue'),
|
||||
meta: { plugin: 'measuringtools' } },
|
||||
{ path: 'measuringtools/new', name: 'measuringtool-new',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
|
||||
component: () => import('./views/MeasuringToolForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'measuringtools' } },
|
||||
{ path: 'measuringtools/:id', ..., meta: { plugin: 'measuringtools' } },
|
||||
{ path: 'measuringtools/:id/edit', ..., meta: { requiresAuth: true, plugin: 'measuringtools' } },
|
||||
@@ -535,18 +544,18 @@ reorganize the file; just add the block, mirroring `machinesApi`.
|
||||
**Views mirror the master templates.** The frontend has master templates
|
||||
(`PrintersList.vue` for lists, `PrinterDetail.vue` for detail pages). `measuringtools` mirrors the equivalent equipment views:
|
||||
|
||||
- `views/measuringtools/MeasuringToolsList.vue` - table with search, a type filter,
|
||||
- `plugins/measuringtools/frontend/views/MeasuringToolsList.vue` - table with search, a type filter,
|
||||
and a calibration-status filter; the status badge uses `utils/colorStyle` with
|
||||
the color the API derived.
|
||||
- `views/measuringtools/MeasuringToolDetail.vue` - hero + Identity card +
|
||||
- `plugins/measuringtools/frontend/views/MeasuringToolDetail.vue` - hero + Identity card +
|
||||
Calibration card (with the derived badge) + Location card, plus the shared
|
||||
`CustomFieldsSection` and `WarrantyPanel` (section 10).
|
||||
- `views/measuringtools/MeasuringToolForm.vue` - asset core fields + type +
|
||||
- `plugins/measuringtools/frontend/views/MeasuringToolForm.vue` - asset core fields + type +
|
||||
location + the calibration fields, plus `CustomFieldsInputs`.
|
||||
- `views/reports/CalibrationReport.vue` - the four buckets (overdue / due soon /
|
||||
- `plugins/measuringtools/frontend/views/CalibrationReport.vue` - the four buckets (overdue / due soon /
|
||||
current / unknown), mirroring `WarrantyReport.vue`.
|
||||
|
||||
**Settings subtype page.** `views/settings/MeasuringToolTypesList.vue` mirrors
|
||||
**Settings subtype page.** `plugins/measuringtools/frontend/views/MeasuringToolTypesList.vue` mirrors
|
||||
`PCTypesList.vue`: add / edit / delete with a `ColorSwatchPicker`. It is linked from
|
||||
`settingsNav.js` with a "Measuring Tools" card group, so it appears in the settings
|
||||
rail and landing overview.
|
||||
@@ -574,7 +583,7 @@ detail page drops in `<CustomFieldsSection :assetid="tool.assetid" />` and the f
|
||||
drops in `<CustomFieldsInputs :assettypeid="assettypeid" :assetid="currentAssetId" />`,
|
||||
then calls `customFieldsRef.value.save(assetId)` after the tool saves. The one
|
||||
subtlety: `CustomFieldsInputs` needs the asset-type id. Rather than hardcode it (the
|
||||
equipment form hardcodes `EQUIPMENT_ASSETTYPEID = 1`), the measuringtools form
|
||||
machines form hardcodes `MACHINE_ASSETTYPEID = 1`), the measuringtools form
|
||||
resolves it dynamically from `GET /api/assets/types`, finding the row whose
|
||||
`assettype === 'measuring_tool'`. Dynamic lookup is preferred because seeded ids are
|
||||
not stable across sites.
|
||||
|
||||
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
||||
The framework declares its contract version in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.11.0'
|
||||
__contract_version__ = '0.15.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -174,7 +174,9 @@ isolated in prod, re-raised in dev/test).
|
||||
|
||||
### `get_navigation_items() -> List[Dict]`
|
||||
|
||||
Returns navigation menu items.
|
||||
Returns navigation menu items. A plugin owns its own sidebar entry here, so it
|
||||
appears when the plugin is installed and disappears when it is not (including in
|
||||
a lean per-site build that omits the plugin).
|
||||
|
||||
```python
|
||||
class ComputersPlugin(BasePlugin):
|
||||
@@ -187,6 +189,26 @@ class ComputersPlugin(BasePlugin):
|
||||
}]
|
||||
```
|
||||
|
||||
**Placement.** `position` (int) sets both the sort order and which section the
|
||||
item lands in - the core sidebar (`AppLayout.vue:buildNavItems`) assigns section
|
||||
headers by position range:
|
||||
|
||||
| `position` | section |
|
||||
|-----------|---------|
|
||||
| `< 10` | top, above any header (Dashboard is 0, Map is 4) |
|
||||
| `10-29` | **Assets** |
|
||||
| `30-49` | **Information** |
|
||||
| `>= 50` | trailing, below Information |
|
||||
|
||||
Lower number sorts higher within a section. An explicit `'section':
|
||||
'information'` forces the Information group regardless of position. Only these
|
||||
two named sections exist; a new section needs a core edit to `buildNavItems`.
|
||||
The **Displays** group (kiosk/TV links) is hardcoded in `AppLayout.vue`, not
|
||||
plugin-driven.
|
||||
|
||||
`icon` is a string key mapped to a Lucide component core-side (same idea as
|
||||
`get_settings_cards`); an unknown key renders with no icon.
|
||||
|
||||
> Removed in contract 0.4.0: `get_searchable_fields`. Global search
|
||||
> (`/api/search`) is a core concern that queries the asset model directly and
|
||||
> already covers every bundled asset type; no plugin ever implemented the hook.
|
||||
@@ -465,7 +487,8 @@ What `shopdb.api` exposes:
|
||||
- Model bases: `BaseModel`, `AuditMixin`
|
||||
- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`,
|
||||
`Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`,
|
||||
`Application`, `AppVersion`, `OperatingSystem`
|
||||
`Application`, `AppVersion`, `OperatingSystem`, `AssetRelationship`,
|
||||
`RelationshipType`
|
||||
- Responses: `success_response`, `error_response`, `paginated_response`,
|
||||
`ErrorCodes`
|
||||
- Pagination: `get_pagination_params`, `paginate_query`
|
||||
@@ -474,11 +497,30 @@ What `shopdb.api` exposes:
|
||||
(`service_token_authorized(scope)` returns True when the request carries a
|
||||
managed service token scoped for `scope` whose owner holds that permission -
|
||||
for unattended plugin endpoints like the GE-Enforce fetch API)
|
||||
- `authorized_service_token(scope)` (0.15.0) - same check as
|
||||
`service_token_authorized` but returns the `ApiToken` itself (or None), so a
|
||||
plugin can honor the token's optional resource binding
|
||||
(`token.resourcescopelist`: an allowlist of resource names the token may
|
||||
reach, NULL = unrestricted). GE-Enforce uses it to pin a display's fetch
|
||||
token to its own manifest scope + that scope's blobs.
|
||||
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
|
||||
`dualpath_single_machine_enabled`
|
||||
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
||||
`parse_import_datetime`
|
||||
- Legacy employee directory: `employee_connection`
|
||||
- CMMC USB check-in/out DB (read-write, used by the usb plugin):
|
||||
`cmmc_usb_connection`
|
||||
- `User` / `Role` (0.13.0) - the account and role models, e.g. resolving
|
||||
alert recipients' emails from selected user ids or role membership
|
||||
- `SupportTeam` (0.15.0) - the support-team model (carries a `webhookurl`), so
|
||||
an alerting plugin can route a notification to a chosen team's Teams webhook
|
||||
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
|
||||
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
|
||||
email is unconfigured; send_alert targets the site's alert_recipients
|
||||
- `send_webhook(title, text)` (0.14.0) - POST an alert to the configured
|
||||
`alert_webhook_url` (Teams Incoming Webhook / Workflow, or generic JSON via
|
||||
the `alert_webhook_format` setting); best-effort, no-op when unset.
|
||||
`send_alert` fans out to this automatically alongside email.
|
||||
|
||||
```python
|
||||
from shopdb.api import db, Asset, AssetType, success_response, paginate_query
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
Build a working shopdb-flask plugin in 30 minutes. This walks through generating, customizing, installing, and testing a plugin from scratch.
|
||||
|
||||
For the full hook reference, see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md).
|
||||
|
||||
> **Windows / VS Code:** command examples below use the Linux venv path
|
||||
> `venv/bin/python`; on Windows use `venv\Scripts\python` and
|
||||
> `$env:FLASK_APP="shopdb"` (not `export`). Full Windows onboarding:
|
||||
> [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md).
|
||||
|
||||
For the architectural decisions behind the contract, see [docs/adr/](../docs/adr/).
|
||||
|
||||
## Step 1: Generate the skeleton
|
||||
@@ -85,13 +91,14 @@ audit_log(action='created', entitytype='Camera', entityid=asset.assetid, entityn
|
||||
|
||||
## Step 4: Install the plugin
|
||||
|
||||
First add `plugins/cameras/migrations/` with a per-plugin Alembic chain that creates the plugin's tables, and register those tables in `PLUGIN_TABLE_OWNERS` (per ADR-008; the plugin chain owns plugin schema, never the core chain). Then:
|
||||
|
||||
```bash
|
||||
flask plugin install cameras
|
||||
flask db migrate -m "Add cameras plugin tables"
|
||||
flask db upgrade
|
||||
flask plugin upgrade-all
|
||||
```
|
||||
|
||||
`install` runs the plugin's `on_install` hook (which seeds the AssetType row), registers it in the plugin registry, and runs migrations.
|
||||
`install` runs the plugin's `on_install` hook (which seeds the AssetType row), registers it in the plugin registry, and runs the plugin's own migration chain. `flask db migrate`/`flask db upgrade` is reserved for core tables and must not be used for plugin schema.
|
||||
|
||||
## Step 5: Verify it works
|
||||
|
||||
|
||||
141
docs/PLUGIN-SIGNING.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Plugin signing and packaging (curator guide)
|
||||
|
||||
ADR-013 Phase 1. How a plugin becomes a signed, verifiable artifact and how a
|
||||
site trusts it. The signature proves an artifact is EXACTLY what a curator
|
||||
reviewed and signed - it does not prove the code is safe. Human review before
|
||||
signing is the actual safety control; the signature makes that review's verdict
|
||||
tamper-evident all the way to the point of execution.
|
||||
|
||||
Requires the `cryptography` package (already a dependency).
|
||||
|
||||
## One-time: create the publisher key pair
|
||||
|
||||
```
|
||||
flask plugin keygen --out ./keys --name curator
|
||||
```
|
||||
|
||||
Writes `keys/curator.key` (PRIVATE) and `keys/curator.pub` (public).
|
||||
|
||||
- Keep the `.key` OFFLINE with the curator. It is the only thing that can sign a
|
||||
trusted artifact. Never put it on the plugin shelf or in the repo.
|
||||
- Distribute the `.pub` with each site's deployed config and pin it (below).
|
||||
- Rotation: generate a new pair, pin BOTH public keys on sites for an overlap
|
||||
window (`verify` accepts any trusted key), then retire the old one.
|
||||
|
||||
## Per plugin: review, then pack
|
||||
|
||||
1. Review the plugin's source. This is the security gate - read what it does.
|
||||
2. Validate and package in one step:
|
||||
|
||||
```
|
||||
flask plugin pack printers --key ./keys/curator.key --publisher west-jefferson
|
||||
```
|
||||
|
||||
`pack` refuses to sign a directory that does not validate (manifest schema,
|
||||
name/dir match, core_version, dependencies on disk). On success it writes
|
||||
`printers-<version>.shopdbplugin` - a zip of the plugin plus:
|
||||
|
||||
- `PROVENANCE.json`: name, version, publisher, created, and a sorted
|
||||
`{file: sha256}` map of every packaged file.
|
||||
- `PROVENANCE.sig`: a detached ed25519 signature over the exact
|
||||
`PROVENANCE.json` bytes.
|
||||
|
||||
3. Publish the artifact to the shelf (a SharePoint-synced or copied folder).
|
||||
Transport is untrusted; the signature is what makes it safe.
|
||||
|
||||
## Verify an artifact
|
||||
|
||||
```
|
||||
flask plugin validate dist/printers-1.0.0.shopdbplugin --pubkey ./keys/curator.pub
|
||||
```
|
||||
|
||||
Checks, fail-closed: signature against the trusted key(s), every file's hash,
|
||||
no unexpected files, manifest schema, and that the plugin's `core_version`
|
||||
admits this framework's contract version. Any changed byte in any file fails
|
||||
the hash check; a signature from an untrusted key fails the signature check.
|
||||
|
||||
## Pin trusted keys on a site
|
||||
|
||||
Set `PLUGIN_TRUSTED_KEYS` to one or more public-key PEM paths, separated by the
|
||||
OS path separator (`:` on Linux, `;` on Windows), in the site's environment:
|
||||
|
||||
```
|
||||
PLUGIN_TRUSTED_KEYS=/etc/shopdb/keys/curator.pub:/etc/shopdb/keys/curator-next.pub
|
||||
```
|
||||
|
||||
Keys are read only from this deployed config, never from the shelf - a folder an
|
||||
attacker could write must not also carry the keys that authenticate it. With no
|
||||
keys set, `validate` on an artifact fails closed (unverifiable).
|
||||
|
||||
## Enforce signatures (Phase 2)
|
||||
|
||||
By default nothing is enforced - plugins load unsigned, as before. To require
|
||||
signatures on a site:
|
||||
|
||||
1. Stamp the plugins the image ships with, so verify-at-load applies to them
|
||||
too (run at image build with the site/build key):
|
||||
|
||||
```
|
||||
flask plugin stamp-bundled --key ./keys/curator.key
|
||||
```
|
||||
|
||||
This writes `PROVENANCE.json` + `PROVENANCE.sig` into each in-tree plugin.
|
||||
|
||||
2. Pin the public key(s) and turn enforcement on (site config):
|
||||
|
||||
```
|
||||
PLUGIN_TRUSTED_KEYS=/etc/shopdb/keys/curator.pub
|
||||
PLUGIN_REQUIRE_SIGNED=true
|
||||
```
|
||||
|
||||
Now a plugin only loads or migrates when its tree matches a trusted signature.
|
||||
Under enforcement a `sys.meta_path` guard verifies EVERY `plugins.<name>.*`
|
||||
import (not just `plugin.py`) - including the `from plugins.<name>.models import
|
||||
...` that core request handlers do - against the plugin's signed provenance, and
|
||||
executes the exact bytes it hashed (never a `.pyc`). An unsigned, tampered, or
|
||||
wrong-key plugin is refused, fail-closed. `PLUGIN_DEV_TRUST_DIRS` exempts named
|
||||
directories, but ONLY under DEBUG/TESTING (the external-repo dev workflow);
|
||||
production ignores it.
|
||||
|
||||
Because every `plugins.*` import is verified, `stamp-bundled` must cover EVERY
|
||||
plugin directory present (its no-argument form does), not only the enabled ones
|
||||
- core code can import a disabled plugin's module, and an unstamped one would be
|
||||
refused.
|
||||
|
||||
Defense in depth - set filesystem permissions so the app's runtime user CANNOT
|
||||
write the `plugins/` directory (owned by the deploy user). Import-time
|
||||
verification closes the "attacker drops a file, a request imports it" path; a
|
||||
strict read-only `plugins/` also closes the narrow verify-vs-migrate race where
|
||||
an attacker with concurrent write to `plugins/` swaps a migration script between
|
||||
the check and alembic re-reading it.
|
||||
|
||||
## The shelf and adopt (Phase 2)
|
||||
|
||||
A shelf is a read-only folder of artifacts plus a signed index. The app reads
|
||||
`PLUGIN_SHELF_DIR`; it never talks to SharePoint - a sync (or robocopy/USB)
|
||||
populates that folder, and the signature makes the transport untrusted and
|
||||
interchangeable.
|
||||
|
||||
Publish (curator, after packing artifacts into the shelf folder):
|
||||
|
||||
```
|
||||
flask plugin shelf-build --dir /srv/shelf --key ./keys/curator.key --serial 3
|
||||
```
|
||||
|
||||
The index carries a monotonic `serial` (a site refuses an index older than the
|
||||
last it saw) and a `revoked` list (carried forward across builds). Bump
|
||||
`--serial` on every publish.
|
||||
|
||||
On a site:
|
||||
|
||||
```
|
||||
flask plugin shelf-list # browse (verifies index + serial)
|
||||
flask plugin adopt printers # or printers==1.2.0
|
||||
flask plugin audit # warn if an installed version is revoked
|
||||
```
|
||||
|
||||
`adopt` verifies the shelf index and the artifact (signature + every file
|
||||
hash), unpacks into a staging area, re-verifies, then atomically moves it into
|
||||
place and installs + enables it with its dependency closure. It refuses a
|
||||
downgrade unless `--force-downgrade`. Run `flask plugin upgrade-all` and restart
|
||||
afterward so migrations apply and routes register.
|
||||
@@ -10,11 +10,24 @@ These plugins are in `plugins/` in this repo. Enable per site with `flask plugin
|
||||
|--------|--------|-------|
|
||||
| `machines` | Manufacturing machinery: 5-axis mills, lathes, broachers, heat treatment ovens | Manually entered. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Subtype tables for FOCAS / CLM / MTConnect controller protocols (planned). |
|
||||
| `computers` | Shop-floor PCs and engineering workstations | Fed by the PXE pipeline collector per [ADR-006](adr/ADR-006-collector-contract.md). |
|
||||
| `printers` | Network and shop-floor printers | Optional Zabbix integration for supply tracking. Legacy `PrinterData` retiring per ADR-001. |
|
||||
| `printers` | Network and shop-floor printers | Public installer map page + fleet install contract (`/api/printers/install-list`, `/pc-default`, `/install-batch`; see [PRINTER-INSTALLER.md](PRINTER-INSTALLER.md)). Optional Zabbix integration for supply tracking. Legacy `PrinterData` retiring per ADR-001. |
|
||||
| `network` | Switches, routers, access points, IDFs as locations | Asset-only; cleanest of the bundled set. |
|
||||
| `usb` | USB devices issued to shop-floor users | Lightweight checkout / check-in. |
|
||||
| `notifications` | Shop-floor notifications, recognitions, kiosk feed | Used by `ShopfloorDashboard.vue`. |
|
||||
| `measuringtools` | Metrology and inspection instruments: calipers, micrometers, thread/bore/height gages, indicators | Per [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Calibration lifecycle with derived status. First plugin built on the matured scaffold; its walkthrough is [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md). Ships `default_enabled: false`. |
|
||||
| `employees` | Read-only employee directory lookup | Backed by a separate HR database. Ships `default_enabled: false`. |
|
||||
| `geenforce` | GE-Enforce manifest store: imaging PC-type scopes and their install manifests (apps, scripts, files, registry, version gates) | Per [ADR-012](adr/ADR-012-geenforce-manifest-ownership.md). Served to the GE-Enforce client as JSON. Requires GE-Enforce lib >= 2.6 on target PCs. Ships `default_enabled: false`. |
|
||||
| `knowledgebase` | Knowledge Base articles linking to external resources | Lightweight article store. |
|
||||
| `printedparts` | 3D-printed parts inventory | Kiosk checkout / check-in. Ships `default_enabled: false`. |
|
||||
| `slides` | Slides for the lobby display and shop-floor screensaver | Upload / reorder / delete per surface. Management gated on the `slides.manage` permission, grantable to non-admin curators; display routes are public. |
|
||||
| `warranty` | Asset warranty tracking | Manual entry now, Dell / Lenovo / HP provider lookups later. Derived coverage status with report buckets. |
|
||||
|
||||
## Plugin permissions
|
||||
|
||||
Plugins may register their own permissions (for example `slides.manage`). Admins
|
||||
implicitly hold them; grant them to specific roles or users to delegate curation
|
||||
without admin. Each plugin's registered permissions appear in its `plugin.py`
|
||||
`get_permissions()`.
|
||||
|
||||
## Building your own
|
||||
|
||||
@@ -62,11 +75,42 @@ which revisions each plugin has applied in `migrations_applied`.
|
||||
|
||||
For sister-site plugins (per [ADR-003](adr/ADR-003-plugin-distribution.md)):
|
||||
|
||||
- Plugin lives in its own git repo: `gitea.proudtech.net/<your-site>/<pluginname>`
|
||||
- Plugin lives in its own git repo: `<git-host>/<your-site>/<pluginname>`
|
||||
- Adopting site clones or symlinks into their `<repo>/plugins/<name>/`
|
||||
- Plugin manifest declares `core_version` range matching the framework version they target
|
||||
- Plugin readme explains: what it tracks, who maintains it, where to file issues
|
||||
|
||||
## Lean per-site builds
|
||||
|
||||
A site ships only the plugins it chose; a site that never wants printedparts /
|
||||
usb / network never carries that code (see
|
||||
[ADR-013](adr/ADR-013-plugin-catalog-and-lean-builds.md) and
|
||||
[ADR-014](adr/ADR-014-schema-lean-per-site.md)). Three layers make a build lean:
|
||||
|
||||
- **Backend code** - `scripts/build-site.sh <profile>` stages `shopdb/core` plus
|
||||
only the chosen plugins' directories (and their hard-dependency closure). A
|
||||
plugin a site did not choose is absent from the backend tree.
|
||||
- **Frontend code** - `SITE_PLUGINS=machines,printers npm run build` (via
|
||||
`scripts/stage-frontend.mjs`) stages only those plugins' `frontend/` dirs and
|
||||
codegens the route table. **Exception:** a `plugins/<name>/frontend/` dir with
|
||||
**no `manifest.json`** is a CORE feature (e.g. `applications`), not a per-site
|
||||
plugin, and is ALWAYS staged regardless of `SITE_PLUGINS` - otherwise a lean
|
||||
build would lose a core page.
|
||||
- **Database** - the shared core Alembic baseline creates every plugin's tables,
|
||||
so a lean site provisions them and then drops the ones it does not use with
|
||||
`flask plugin prune-schema` (ADR-014). Run it once at provisioning, after
|
||||
`flask db upgrade` and `flask plugin upgrade-all`; see
|
||||
[DEPLOY.md](DEPLOY.md).
|
||||
|
||||
**Menus follow the build, not a plugin flag.** The sidebar nav, the settings
|
||||
rail, and the Displays links all gate on whether the target route was actually
|
||||
staged into this build (the router's own route table), not on a registry
|
||||
"enabled" flag. So a lean site never shows a menu entry that dead-ends on a
|
||||
blank page - an omitted plugin's nav item, settings cards, and kiosk links all
|
||||
disappear together. Shopfloor Dashboard is a core view but is gated on the
|
||||
notifications plugin (its only data source), so it drops when notifications is
|
||||
not in the build.
|
||||
|
||||
## Naming policy
|
||||
|
||||
Plugin names follow the framework's naming convention (lowercase concatenated, no underscores or dashes; full words preferred over acronyms). See [CONTRIBUTING.md](../CONTRIBUTING.md). Plugin name collisions across sites are not enforced; the convention recommends prefixing site-specific plugins with the site code (e.g., `wjsf-shippingstation`) when there is risk of overlap.
|
||||
|
||||
104
docs/PRINTER-INSTALLER.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Printer installer map and install endpoints
|
||||
|
||||
How the shop-floor fleet installs network printers from shopdb-flask, replacing
|
||||
the classic ASP `apiprinters.asp` / `apipcdefaultprinter.asp` / `installprinter.asp`
|
||||
contract. Shopfloor 2.0 PCs cannot run unsigned `.bat` maps, so a signed
|
||||
installer EXE (and the public web map page) drives installs from three endpoints
|
||||
in the printers plugin.
|
||||
|
||||
- Server code: `plugins/printers/api/asset_routes.py`
|
||||
(`printer_install_list`, `pc_default_printer`, `printer_install_batch`)
|
||||
- Consumed as a fleet manifest entry: the `common` scope's `printer map`
|
||||
entry (see `GE-ENFORCE-DISPLAY.md`).
|
||||
|
||||
All three endpoints are `@jwt_required(optional=True)`: an anonymous fleet
|
||||
client works, and a logged-in browser (the public map page) works too.
|
||||
|
||||
---
|
||||
|
||||
## 1. The public map page
|
||||
|
||||
`PrinterInstallerMap` is a public (no-login) frontend page: the floor map with
|
||||
printer hotspots positioned at each printer's `mapx` / `mapy`. The user clicks
|
||||
the printers they want, and the page requests an install batch. The PC's default
|
||||
printer is preselected via `pc-default`.
|
||||
|
||||
---
|
||||
|
||||
## 2. `GET /api/printers/install-list`
|
||||
|
||||
Flat, unpaginated list of active NETWORK printers. A printer counts as network
|
||||
only if it has a hostname or a non-USB IP; USB-only printers are excluded.
|
||||
|
||||
Fields per row:
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `printerid` | Printer id (the token `install-batch` takes). |
|
||||
| `name` | Asset name, else asset number. |
|
||||
| `machinenumber` | The asset number. |
|
||||
| `windowsname` | Standardized Windows printer name. |
|
||||
| `sharename` | Share / CSF name. |
|
||||
| `hostname` | Print-queue host. |
|
||||
| `ipaddress` | Primary IP (falls back to any communication row). |
|
||||
| `vendorname` | Direct vendor, else the model's vendor. |
|
||||
| `modelnumber` | Model name. |
|
||||
| `installpath` | Installer path for this printer (see install-batch). |
|
||||
| `iscsf` | CSF flag. |
|
||||
| `locationname` | Location name, if the asset has one. |
|
||||
| `mapx` / `mapy` | Floor-map hotspot position. |
|
||||
|
||||
`?format=text` returns a pipe-delimited line per printer, one printer per line,
|
||||
with a fixed field order so the Inno / Pascal installer does a `split()` instead
|
||||
of parsing JSON:
|
||||
|
||||
```
|
||||
printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy
|
||||
```
|
||||
|
||||
Any pipe or newline inside a value is neutralized to a space so the field count
|
||||
stays fixed. The web map uses the default JSON.
|
||||
|
||||
---
|
||||
|
||||
## 3. `GET /api/printers/pc-default?machine=NNNN`
|
||||
|
||||
The PC's default printer, by machine (asset) number persisted at PXE enrollment.
|
||||
Parity with classic `apipcdefaultprinter.asp`: the installer preselects a PC's
|
||||
default-printer hotspot on the site-map wizard. The link is resolved through the
|
||||
`defaultprinter` asset relationship (PC asset -> printer asset), so it stays
|
||||
inside the contract surface (no cross-plugin model import).
|
||||
|
||||
Returns `{printerid, windowsname}`, or `{}` when the machine is unknown or has no
|
||||
active default printer set.
|
||||
|
||||
`?format=text` returns one pipe-delimited line (`printerid|windowsname`), or an
|
||||
EMPTY body when there is no default (so the installer's split yields nothing).
|
||||
|
||||
---
|
||||
|
||||
## 4. `GET /api/printers/install-batch?printerids=1,2,3`
|
||||
|
||||
Returns a self-deleting Windows `.bat` that installs the selected printers,
|
||||
grouped the same way classic `installprinter.asp` grouped them:
|
||||
|
||||
- HP / Xerox: one universal `PrinterInstaller.exe /PRINTER="a,b,c"` call.
|
||||
- Printers with a `.exe` `installpath`: a PowerShell `Invoke-WebRequest` download
|
||||
(using the caller's Windows credentials, against the site base URL + the
|
||||
IIS-served `/installers` folder) followed by running it `/SILENT`.
|
||||
- No `installpath`, or a non-`.exe` payload (e.g. a `.zip`): listed as a manual
|
||||
install rather than run blindly.
|
||||
|
||||
`printerids` is required, comma-separated; non-numeric tokens are ignored. An
|
||||
empty / missing list is a validation error.
|
||||
|
||||
The install name preference is `windowsname`, else `sharename`, else the asset
|
||||
name / number.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fleet wiring
|
||||
|
||||
The `common` scope's `printer map` manifest entry (see `GE-ENFORCE-DISPLAY.md`)
|
||||
lays down the signed installer that consumes these endpoints. The web map page
|
||||
covers the same install flow for a human at a browser.
|
||||
56
docs/PROJECT-REVIEW.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# ShopDB Flask - Project Health Review
|
||||
As of HEAD `ecf4ef6` (2026-07-30), product `__version__ 0.7.0`, contract `__contract_version__ 0.15.0` (verified in `shopdb/__init__.py`).
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
**Overall: healthy engineering, drifting focus.** The stated product vision ("plugin system is the product," the project brief) is delivered: all 7 refactor phases are done, 13 bundled plugins are contract-compliant, the per-plugin migration regime (ADR-008) is live and exercised (geenforce is at `geenforce0002blobs`, proving the post-cutover chain works in anger), and CI enforces naming, contract, and real-MySQL migration idempotency. Test count grew 1077 -> 1159 since the last the project brief snapshot.
|
||||
|
||||
The concern is not quality but trajectory. the project brief and ROADMAP.md both name the last big milestone before 1.0 as "legacy-ASP data import + production pilot." The loader is built and VM-validated (16 stages, `scripts/site_imports/wjf/`), but the prod run has not happened, and ~35 of the last 60 commits went to the GE-Enforce HTTPS cutover instead. That work is legitimate and high-value, but it is feature/fleet work on one plugin, and it has accumulated two process debts that violate the project's own discipline: the cutover playbook (`docs/geenforce-api-cutover.md`) is the only dirty file in the repo and is **untracked**, and the `prod-patch-geenforce` robocopy fast-path can leave prod ahead of git.
|
||||
|
||||
**Verdict: on track against standards, behind against goals.** The 1.0 gate has four items; only one is arguably done, and the roadmap doc does not know it.
|
||||
|
||||
## 2. Standards Compliance
|
||||
|
||||
| Rule | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Naming (tables/columns/vars, CONTRIBUTING.md) | **MET** | `scripts/check-naming-and-style.sh` present and executable; dedicated `naming` job in the internal CI workflow. One borderline: `asset.py:175` documents a derived API key `location_name` with an underscore - not a DB-mirrored column so likely legal, but worth a glance since "response keys match column names exactly" is the spirit of the rule. |
|
||||
| Plugin contract (manifest.json, BasePlugin, `shopdb.api` only) | **MET** | 13 `plugins/*/manifest.json` verified; contract test suites in `tests/`; contract bumped correctly to 0.15.0 for the geenforce resource-scope fetch tokens (75386d2), per ADR-002 discipline. |
|
||||
| Migration ownership (ADR-008) | **MET** | `PLUGIN_TABLE_OWNERS` registry tested by `tests/test_plugin_migrations.py` (`EXPECTED_HEAD_REVISION` lines 47-51); `migrations-mysql` CI job does fresh utf8mb4 MySQL 8 upgrade + all plugin chains + second-upgrade-no-op assertion. The geenforce `0002blobs` revision shows the per-plugin chain is being used as designed, not just anchored. |
|
||||
| Versioning/release discipline (ADR-007) | **AT RISK** | Tags through v0.7.0 exist and contract bumps are disciplined, but the documentation half of the procedure has drifted - see Gaps 3. |
|
||||
| ADRs canonical, new priorities get an ADR | **AT RISK** | 14 ADRs present. But lean per-site builds are half-shipped (ADR-014 ACCEPTED and implemented; `default_enabled: false` on 5 plugins) while ADR-013, which defines the catalog/tiers/signed-artifact model those builds imply, is still PROPOSED. The GE-Enforce HTTPS cutover itself - a major architectural shift off the SMB share - lives in an untracked doc, not an ADR or ADR-012 amendment. |
|
||||
| Style (plain ASCII, no emojis, comment discipline) | **MET** | Enforced by the same pre-commit hook + CI naming job. |
|
||||
| Everything in git / repo as source of truth | **VIOLATED** | `docs/geenforce-api-cutover.md` untracked (only dirty file, verified `git status`); `prod-patch-geenforce` robocopy path acknowledged in-doc as leaving prod ahead of git. |
|
||||
|
||||
## 3. Roadmap Status
|
||||
|
||||
**Done:** Phases 0-6 (contract lock through multi-site distribution, tags v0.5.0-v0.7.0). Legacy import machinery complete: `docs/IMPORT-API.md` contract, 16-stage wjf loader VM-validated. Air-gapped deploy kit (6534590).
|
||||
|
||||
**1.0 must-haves (ROADMAP.md), honestly scored:**
|
||||
|
||||
1. *Asset model fully wired* - **appears DONE but unrecorded.** `Asset.mapx` (`shopdb/core/models/asset.py:121`), `inheritsposition` (`relationship.py:132`), and propagation logic (`relationship.py`, `core/api/assets.py`, `cli/__init__.py`) are all in code. ROADMAP still lists this as outstanding. Verify the ADR-001 contract tests cover it, then strike it.
|
||||
2. *Equipment data migration one-shot* - **NOT DONE.** `scripts/migration/` contains only `fix_legacy_schema.sql`, `one-offs/`, and a README. No equipment script.
|
||||
3. *Printers legacy-table cleanup* - **NOT DONE.** Recent printers commits (0d40780..c075658) are installer/feature work, not retirement.
|
||||
4. *External plugin UI packaging* - **NOT DONE**, and gate criterion 3 (one external plugin built end-to-end) has no evidence.
|
||||
|
||||
**In-flight:** GE-Enforce HTTPS cutover dominates (~35/60 recent commits). Per the cutover doc's own section 12: only displays/kiosks are on the API; cmm/collections/keyence/genspect/heattreat/partmarker/common fleet still enforce from the SFLD SMB share; loggedinuser resolution unwired; registry cleanup pending; 3DPrintRoom route is a placeholder. Secondary streams: printers install-batch, applications notes, server reclassification, TV dashboard.
|
||||
|
||||
**Pace/scope health:** Velocity is high and test coverage tracks the work (17 of ~29 plugin test files are geenforce). But the project has been at 0.7.0 with "prod pilot is the last big milestone" as the stated goal since mid-July, while shipping ~185 commits of plugin-feature work. That is a real product being used - good - but the 1.0 gate is not moving, and a half-migrated fleet (API for displays, SMB for everything else) is the worst place to pause the cutover.
|
||||
|
||||
## 4. Gaps and Risks
|
||||
|
||||
1. **Untracked cutover playbook** (`docs/geenforce-api-cutover.md`). The single most valuable in-flight document is one `rm` away from gone, and invisible to any other machine or contributor.
|
||||
2. **Prod-ahead-of-git debt.** The `prod-patch-geenforce` fast-path means production behavior may not be reproducible from any commit. This directly undermines ADR-012's "engine is source of truth" and the release discipline of ADR-007.
|
||||
3. **Documentation drift, three concrete instances (all verified):** ROADMAP.md header says contract 0.13.0 (actual 0.15.0); the project brief says 1077 tests (actual 1159 collected) and claims a "lean-build" CI job that does not exist in the internal CI workflow (jobs: backend, naming, frontend, migrations-mysql - lean coverage is folded into pytest via `tests/test_lean_build_guards.py`). Also `.github/workflows/ci.yml` differs from the internal CI workflow - one of them is stale.
|
||||
4. **Split-brain fleet enforcement.** Displays/kiosks on the API, the rest of the fleet on the SMB share, with staged-but-unpushed manifest fixes elsewhere (MTConnect v1 stranding). Two delivery mechanisms means two failure modes and doubles the audit surface until the cutover finishes.
|
||||
5. **1.0 gate criterion 4 unproven:** `docs/DEPLOY.md` has not been validated by an actual fresh-host prod deploy. The air-gapped kit exists; the pilot does not.
|
||||
6. **ADR-013 limbo:** lean builds shipped under ADR-014 while the catalog/signing model that makes external distribution safe remains PROPOSED. Fine short-term, but gate criterion 3 (external plugin) will force the question.
|
||||
|
||||
## 5. Prioritized Recommendations
|
||||
|
||||
1. **Commit `docs/geenforce-api-cutover.md` today.** Zero-cost, eliminates the worst single-point-of-loss risk.
|
||||
2. **Reconcile prod-patched geenforce files back into git** and gate or retire the robocopy fast-path. Until prod == some tag, ADR-007 is fiction for this plugin.
|
||||
3. **One doc-sync pass (30 min):** ROADMAP header to 0.15.0, the project brief test count and CI job list, strike must-have (a) if contract tests confirm the Asset wiring, delete or sync the stale `.github` workflow.
|
||||
4. **Finish the cutover or park it cleanly.** Either drive the remaining fleet groups onto the API per section 12, or write down the frozen state as an ADR-012 amendment so the split-brain period is a documented decision, not drift.
|
||||
5. **Schedule the prodscratch import run and prod pilot.** This is the actual 1.0 milestone and everything is built for it; it validates DEPLOY.md (gate 4) for free.
|
||||
6. **Pair the equipment one-shot migration with printers retirement** (must-haves b and c) - they are coordinated by design; doing them together avoids touching the legacy tables twice.
|
||||
7. **Decide ADR-013** before building the external-plugin end-to-end proof (gate 3); the geenforce client work is the natural seed for that external plugin.
|
||||
202
docs/RELEASING-WINDOWS.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Building and releasing the Windows installer
|
||||
|
||||
For whoever builds releases. If you run a server rather than build releases, see
|
||||
[UPDATES-WINDOWS.md](UPDATES-WINDOWS.md).
|
||||
|
||||
Every release is one self-contained `.exe`. It carries the Python runtime, a
|
||||
hash-checked wheelhouse, MySQL, the IIS modules and the application, so nothing
|
||||
is fetched from the network at install time. That is the point: sites are
|
||||
air-gapped.
|
||||
|
||||
## What you need
|
||||
|
||||
- A checkout of this repository.
|
||||
- Inno Setup 6.6.0 or newer, on Windows. Compiling needs Windows; staging the
|
||||
bundle does not.
|
||||
- PowerShell, for the bundle lock tools.
|
||||
- `uv`, only when dependencies change.
|
||||
|
||||
## The three kinds of change
|
||||
|
||||
Almost every release is the first kind.
|
||||
|
||||
### 1. Application change: bug fix, feature, no new dependency
|
||||
|
||||
```bash
|
||||
# bump __version__ in shopdb/__init__.py first
|
||||
./deploy/windows/installer/build-installer.sh deploy/site-profile-universal.json <repo-path>
|
||||
```
|
||||
|
||||
`deploy/site-profile-universal.json` is the profile released builds come from:
|
||||
every bundled plugin, so one `.exe` serves any site and the operator ticks what
|
||||
that site uses. Build from a narrower profile only when a site genuinely needs a
|
||||
lean build (ADR-013). Do not build a release from a profile that is not in the
|
||||
repository - the build stops being reproducible the moment that file is
|
||||
somewhere else.
|
||||
|
||||
Then on Windows, in `deploy/windows/installer`:
|
||||
|
||||
```
|
||||
iscc ShopDBFlask.iss
|
||||
```
|
||||
|
||||
`build-installer.sh` restages the application, rebuilds the web interface for
|
||||
the chosen feature set, regenerates `plugins.iss` and `version.iss`, and
|
||||
re-verifies the third-party payload against `bundle-lock.json`. The lock is
|
||||
untouched, because nothing third-party changed.
|
||||
|
||||
### 2. A dependency is added, removed or moved
|
||||
|
||||
The only case that touches the lock.
|
||||
|
||||
```bash
|
||||
# edit requirements.in, then
|
||||
uv pip compile requirements.in --universal --generate-hashes -o requirements.txt
|
||||
|
||||
# fetch the wheel for the target runtime, into the wheelhouse
|
||||
pip download <name>==<version> -d deploy/windows/installer/bundle/wheels \
|
||||
--only-binary=:all: --platform win_amd64 --python-version 314 --no-deps
|
||||
```
|
||||
|
||||
Then regenerate and commit the lock:
|
||||
|
||||
```powershell
|
||||
pwsh ./refresh-bundle-lock.ps1 # review the change
|
||||
pwsh ./refresh-bundle-lock.ps1 -Yes # write it
|
||||
```
|
||||
|
||||
**Commit `bundle-lock.json`. That commit is the review.** The lock records every
|
||||
third-party file and its SHA-256; the installer refuses to run if the payload it
|
||||
carries does not match, so an unreviewed substitution cannot reach a server.
|
||||
|
||||
Two traps, both of which have already cost a release:
|
||||
|
||||
- `--universal` is not optional. A resolve done only for the host platform drops
|
||||
packages that exist only on Windows, and the install then fails hash checking
|
||||
on a package with no entry.
|
||||
- Declare **every** runtime import in `requirements.in`, including ones that
|
||||
already work locally. `packaging` reached this project only as a test
|
||||
dependency, so the full suite passed while a production virtual environment,
|
||||
which has no test dependencies, could not import the application at all.
|
||||
`tests/test_runtime_dependencies.py` is the gate for this.
|
||||
|
||||
### 3. A new plugin
|
||||
|
||||
Ordinary plugin work, plus three installer-specific steps:
|
||||
|
||||
1. **Stage it.** Add the plugin to the profile you build from. `plugins.iss` is
|
||||
generated from what is actually in the bundle, so the wizard offers it with
|
||||
no edit to the installer script.
|
||||
2. **Decide whether it is ticked by default.** `PluginDefault()` in
|
||||
`ShopDBFlask.iss` is an exclusion list: a new plugin defaults to **ticked**
|
||||
unless you name it there. This is the one manual edit.
|
||||
3. **Register its migrations.** Update `PLUGIN_TABLE_OWNERS` and
|
||||
`EXPECTED_HEAD_REVISION`, and give the plugin its own migration chain
|
||||
(ADR-008). Existing sites pick up its tables through `plugin upgrade-all`;
|
||||
sites that do not tick it have them pruned (ADR-014).
|
||||
|
||||
## Version numbers
|
||||
|
||||
`build-installer.sh` reads `__version__` from `shopdb/__init__.py` and writes
|
||||
`version.iss` from it. Do not edit `version.iss`: a hardcoded version in the
|
||||
installer script had already drifted two minor versions from the code.
|
||||
|
||||
Bump the version for every release you hand out. The installer compares versions
|
||||
and:
|
||||
|
||||
- refuses a build **older** than what is installed, because migrations only go
|
||||
forwards;
|
||||
- warns and continues on an **equal** version, treating it as a repair.
|
||||
|
||||
Equal-version rebuilds are useful while testing and are a poor idea in the
|
||||
field, since the server cannot then tell you what it is running.
|
||||
|
||||
Follow ADR-007 for what each part means.
|
||||
|
||||
## Do not hand-edit generated files
|
||||
|
||||
Regenerate these; changes are overwritten without warning:
|
||||
|
||||
- `version.iss` - from `shopdb/__init__.py`
|
||||
- `plugins.iss` - from the plugins actually present in the bundle
|
||||
- `requirements.txt` - from `requirements.in` via `uv pip compile`
|
||||
- `bundle-lock.json` - via `refresh-bundle-lock.ps1`
|
||||
|
||||
`waitress` and `tzdata` were once hand-added to `requirements.txt` and vanished
|
||||
on the next compile, taking the Windows runtime with them.
|
||||
|
||||
## Before you hand a build out
|
||||
|
||||
```bash
|
||||
python -m pytest -q # full suite
|
||||
./scripts/check-naming-and-style.sh # naming and style
|
||||
```
|
||||
|
||||
The suite includes gates worth knowing about:
|
||||
|
||||
- `tests/test_runtime_dependencies.py` - every runtime import is declared
|
||||
- `tests/test_bundle_lock.py` - the lock covers the payload
|
||||
- `tests/test_docs_publishable.py` - `docs/` carries no internal references,
|
||||
because it is published
|
||||
|
||||
`build-installer.sh` refuses to finish if the payload does not match the lock.
|
||||
That check is not advisory; do not work around it.
|
||||
|
||||
## Publishing
|
||||
|
||||
Alongside the `.exe`, publish:
|
||||
|
||||
- a `.sha256` file, so the operator can verify what they received;
|
||||
- release notes covering what changed and anything needing attention;
|
||||
- the version in the filename, so a server's build is identifiable on sight.
|
||||
|
||||
The installer is attached to a git-forge release as an ASSET, never committed.
|
||||
Most forges reject files over 100 MB inside a repository while allowing release
|
||||
assets far larger, and a committed binary would sit in every future clone
|
||||
forever. Release creation is scripted next to the other deployment scripts and
|
||||
verifies the SHA-256 before publishing, because a truncated upload is easiest to
|
||||
catch before anyone can download it.
|
||||
|
||||
Tags have to reach the forge for a release to hang off one: a plain
|
||||
`git push <remote> main` does not carry them, which is why the push step uses
|
||||
`--follow-tags`.
|
||||
|
||||
The compiled installer stages a CycloneDX inventory (`sbom.cdx.json`) onto every
|
||||
server, which is what answers a security question about a published
|
||||
vulnerability without anyone guessing.
|
||||
|
||||
## Known gaps
|
||||
|
||||
**The installer is not code-signed.** Every install shows an unknown-publisher
|
||||
warning, and the SHA-256 is the only integrity check. This is the significant
|
||||
remaining gap before wider distribution: a checksum published next to the file
|
||||
protects against corruption, not against someone who can write to that location.
|
||||
|
||||
The decision taken is to wait for a certificate from the organisation's own
|
||||
certificate authority rather than buy one from a public CA. Every server this
|
||||
installer runs on is centrally managed, and that CA's root is already trusted on
|
||||
those machines, so an internally issued Authenticode certificate removes the
|
||||
warning exactly where it matters. A public certificate would buy trust on
|
||||
machines this software never reaches.
|
||||
|
||||
Until then, publish the SHA-256 through a channel SEPARATE from the installer
|
||||
itself. A hash sitting beside the file is only as trustworthy as write access to
|
||||
that location; a hash the operator gets another way means tampering has to
|
||||
succeed twice.
|
||||
|
||||
Wiring it up afterwards is small: Inno has native SignTool support, so a
|
||||
directive in the script and a signtool configuration on the build machine sign
|
||||
the installer and its uninstaller. Include a timestamp server, or signatures
|
||||
stop verifying when the certificate expires.
|
||||
|
||||
**Compiling requires Windows.** `build-installer.ps1` exists so the whole
|
||||
process can run on a Windows workstation. Nothing about it runs in CI, so a
|
||||
release is a deliberate act by a person.
|
||||
|
||||
## See also
|
||||
|
||||
- [UPDATES-WINDOWS.md](UPDATES-WINDOWS.md) - what operators should expect
|
||||
- [INSTALL-WINDOWS.md](INSTALL-WINDOWS.md) - installing a new site
|
||||
- [OPERATE-WINDOWS.md](OPERATE-WINDOWS.md) - running a site
|
||||
- `docs/adr/` - ADR-007 versioning, ADR-008 plugin migrations, ADR-013 and
|
||||
ADR-014 lean per-site builds
|
||||
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
shopdb-flask is at `__contract_version__ = '0.11.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||
shopdb-flask is at `__contract_version__ = '0.13.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||
|
||||
## Phase status
|
||||
|
||||
@@ -20,9 +20,9 @@ The last big milestone before 1.0 is the legacy-ASP data import plus a productio
|
||||
|
||||
### Must-have
|
||||
|
||||
- **Asset model fully wired**. `Asset.mapx, Asset.mapy` columns, `AssetRelationship.inheritsposition`, `AssetRelationship.propagatesthroughid` columns. Models match the locked ADR-001 surface that `resolve_asset_position` already targets.
|
||||
- **Asset model fully wired**. `Asset.mapx, Asset.mapy` columns, `AssetRelationship.inheritsposition` column, and the `relationshiptypepropagations` M:N table (`RelationshipTypePropagation` model; propagation lives on `RelationshipType`, not `AssetRelationship`). Models match the locked ADR-001 surface that `resolve_asset_position` already targets.
|
||||
- **Equipment data migration script** for facilities migrating from legacy ASP shopdb. One-shot script under `scripts/migration/`. Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates.
|
||||
- **Printers retirement**. Legacy `PrinterData` model, `printers_bp` legacy blueprint, and the frontend `PrinterForm.vue` references to `printer.printerdata.*` get removed in lockstep. Coordinated with the equipment migration.
|
||||
- **Printers retirement**. The printers plugin already runs on the asset architecture (blueprint `printers_asset_bp`); any remaining legacy printer-table cleanup is coordinated with the equipment migration.
|
||||
- **External plugin UI packaging**. The Vue-side hook contract ships (ADR-010: get_settings_cards / get_asset_panels / get_map_overlays / get_asset_presentation) and route gating is backend-driven (ADR-009), but plugin routes/views still live in core `frontend/src`. Let an external plugin ship its own Vue bundle so adopters can add UI without editing core.
|
||||
|
||||
### Nice-to-have
|
||||
|
||||
199
docs/UPDATES-WINDOWS.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# What to expect from updates (Windows sites)
|
||||
|
||||
For the people who run a ShopDB-Flask server. It covers how updates arrive, what
|
||||
they do to your data and your server, how long they take, and what happens to
|
||||
anything else running on the same machine.
|
||||
|
||||
If you are the person building releases, see
|
||||
[RELEASING-WINDOWS.md](RELEASING-WINDOWS.md).
|
||||
|
||||
## Updates arrive as one file
|
||||
|
||||
Every release is a single `.exe`, the same kind of file you used to install.
|
||||
There is no patch, no separate updater, and no download step during the install:
|
||||
everything the server needs is inside that one file, including the Python
|
||||
runtime, all library code and the application itself.
|
||||
|
||||
To update, run the newer `.exe` on the server as Administrator. That is the
|
||||
whole procedure.
|
||||
|
||||
The installer works out for itself that this is an update rather than a first
|
||||
install, and skips what is already correct. An update typically takes two to
|
||||
four minutes, most of which is the database migration.
|
||||
|
||||
## What an update changes, and what it leaves alone
|
||||
|
||||
Changed:
|
||||
|
||||
- The application code and the web interface.
|
||||
- The database schema, brought forward by migrations.
|
||||
- The Python runtime and libraries, but only when that release moves them.
|
||||
|
||||
Left exactly as they are:
|
||||
|
||||
- `.env`, which holds your database connection and secret keys.
|
||||
- Your data. Updates migrate the schema; they do not reset or reload content.
|
||||
- `web.config`, if you have edited it. The installer only ever repairs a
|
||||
specific fault in it that older builds created, and copies the file aside
|
||||
first when it does.
|
||||
- Which features are switched on. The feature list opens showing what this site
|
||||
already has.
|
||||
- Uploaded files and anything under `instance\`.
|
||||
|
||||
Unticking a feature during an update does **not** remove it. Adding is a tick;
|
||||
removing is a deliberate, separate step. This is so an upgrade can never quietly
|
||||
delete a feature and its data.
|
||||
|
||||
## Downtime
|
||||
|
||||
The site is down for the length of the update, so two to four minutes. The
|
||||
installer stops the application pool before replacing files, because Windows
|
||||
will not let it overwrite files a running process holds open, and starts it
|
||||
again afterwards.
|
||||
|
||||
There is no reboot. If a release ever needs one, the installer says so rather
|
||||
than restarting the machine itself.
|
||||
|
||||
## Your data is backed up first
|
||||
|
||||
Before applying migrations, the installer takes a database backup and checks the
|
||||
dump is readable. If a migration fails, it restores from that backup and tells
|
||||
you.
|
||||
|
||||
This depends on `mysqldump` being present. Confirm it once, before your first
|
||||
update:
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 check
|
||||
```
|
||||
|
||||
Without it the update still runs, but the pre-update backup is skipped, and that
|
||||
is precisely the backup you would want if a migration went wrong.
|
||||
|
||||
Afterwards:
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 status
|
||||
.\shopdb-admin.ps1 verify
|
||||
```
|
||||
|
||||
## Going backwards is refused
|
||||
|
||||
Installing an older build over a newer one is blocked outright. Once migrations
|
||||
have moved the schema forward, older code cannot read it, and the failures are
|
||||
difficult to unpick.
|
||||
|
||||
To go back you restore a backup taken before the update. Keep the previous
|
||||
`.exe` until you are satisfied with a release.
|
||||
|
||||
## Will an update affect other sites on the same IIS server?
|
||||
|
||||
Mostly no, and the exceptions are listed here rather than glossed over.
|
||||
|
||||
**Isolated from other sites:**
|
||||
|
||||
- The application runs in its own application pool under its own identity, so a
|
||||
crash or a memory leak cannot reach another site's pool.
|
||||
- Its configuration lives in its own folder and applies only to its own URL
|
||||
path. A parent site's own pages, including classic ASP, keep their existing
|
||||
handlers.
|
||||
- File permissions are granted on the application folder only.
|
||||
|
||||
**Shared, and therefore worth knowing about:**
|
||||
|
||||
- **A brief application pool recycle across the server.** Installing the IIS
|
||||
modules and writing server-level configuration causes IIS to reload its
|
||||
configuration, which recycles application pools. Requests in flight at that
|
||||
moment can be dropped, and any session state other sites hold in memory is
|
||||
lost. It is a few seconds and there is no service outage: IIS itself is never
|
||||
stopped and no `iisreset` is issued.
|
||||
- **Two IIS modules are installed machine-wide** the first time:
|
||||
HttpPlatformHandler and URL Rewrite. Both are standard Microsoft modules. They
|
||||
do nothing to a site that does not reference them, and if a site already uses
|
||||
URL Rewrite its rules are untouched.
|
||||
- **One rewrite server variable is permitted machine-wide**,
|
||||
`HTTP_X_FORWARDED_FOR`, so the application can see real client addresses
|
||||
instead of the loopback address. This grants exactly that one variable rather
|
||||
than opening the section up.
|
||||
- **A Microsoft C++ runtime** may be installed, which is shared and backwards
|
||||
compatible.
|
||||
- **The bundled database option installs MySQL on port 3306.** If this server
|
||||
already runs MySQL, choose the existing-database option instead. Two servers
|
||||
will collide on that port. The wizard asks before doing anything.
|
||||
|
||||
Removing ShopDB-Flask takes away its own site, application, pool, folder and
|
||||
firewall rule. It deliberately leaves the shared IIS modules in place, because
|
||||
another site may have started depending on them.
|
||||
|
||||
If your server hosts something critical, schedule updates in a maintenance
|
||||
window for the pool recycle, not for the application itself.
|
||||
|
||||
## Security updates
|
||||
|
||||
Two kinds reach you, both as an ordinary `.exe`.
|
||||
|
||||
**Application fixes** are built from the source and shipped like any other
|
||||
release.
|
||||
|
||||
**Third-party fixes** cover the Python runtime, the libraries, MySQL and the IIS
|
||||
modules. These are pinned to exact versions and checked by cryptographic hash at
|
||||
install time, so a release contains precisely the versions it claims and nothing
|
||||
substituted. When one of them publishes a fix that affects this application, it
|
||||
is picked up and a new release is issued.
|
||||
|
||||
Each release ships a machine-readable inventory of every third-party component
|
||||
and its version, installed on the server as `sbom.cdx.json`. If your security
|
||||
team asks whether you are exposed to a published vulnerability, that file
|
||||
answers it without anyone guessing.
|
||||
|
||||
An update that is only a dependency bump is still worth taking: the version
|
||||
number moves and the application behaviour does not.
|
||||
|
||||
## Check the file before running it
|
||||
|
||||
Each release publishes a SHA-256 checksum beside the `.exe`. Verify it:
|
||||
|
||||
```powershell
|
||||
certutil -hashfile ShopDBFlask_Installer_<version>.exe SHA256
|
||||
```
|
||||
|
||||
Compare with the published `.sha256` file.
|
||||
|
||||
Windows will warn about an unknown publisher, because the installer is not yet
|
||||
code-signed. The checksum is the integrity check to rely on today. Get the file
|
||||
from the agreed location rather than from mail or a message.
|
||||
|
||||
## Version numbers
|
||||
|
||||
Three parts, for example `0.7.0`:
|
||||
|
||||
- The last part changes for bug fixes and security fixes. Nothing you use
|
||||
behaves differently.
|
||||
- The middle part changes for new features. Existing features keep working.
|
||||
- The first part changes for something that needs you to read the notes first.
|
||||
|
||||
Before 1.0 the middle number can still bring changes that need attention. Read
|
||||
the release notes for those.
|
||||
|
||||
## If an update fails
|
||||
|
||||
The installer stops at the first problem rather than continuing, and says what
|
||||
failed and what to do. Nothing is left half-applied: either the change is
|
||||
complete or it is rolled back, and the log records everything either way.
|
||||
|
||||
The log is at:
|
||||
|
||||
```
|
||||
C:\ProgramData\ShopDB-Flask\logs\shopdb-install-<timestamp>.log
|
||||
```
|
||||
|
||||
Re-running the same `.exe` is safe and picks up from where it stopped. If it
|
||||
fails again, send that log with your report; it names the failing step, the
|
||||
exit code and the relevant output.
|
||||
|
||||
## See also
|
||||
|
||||
- [OPERATE-WINDOWS.md](OPERATE-WINDOWS.md) - day-to-day running
|
||||
- [UPGRADE.md](UPGRADE.md) - upgrade notes across all deployment types
|
||||
- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - what to back up and how to restore
|
||||
- [INSTALL-WINDOWS.md](INSTALL-WINDOWS.md) - first-time install
|
||||
@@ -24,7 +24,7 @@ cp -a instance/ instance-backup-$(date +%F)/
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
The application is distributed through the internal GE Aerospace Gitea; pull
|
||||
The application is distributed through the internal GE Aerospace git server; pull
|
||||
from there. There is no external image registry.
|
||||
|
||||
## Step 2: Rebuild
|
||||
@@ -105,12 +105,43 @@ default need to act.
|
||||
To check what your instance points at:
|
||||
|
||||
```bash
|
||||
docker compose exec api flask shell -c "from shopdb.core.models.setting import Setting; print(Setting.query.filter(Setting.key.like('map_blueprint%')).all())"
|
||||
docker compose exec -T api flask shell <<'PY'
|
||||
from shopdb.core.models.setting import Setting
|
||||
print(Setting.query.filter(Setting.key.like('map_blueprint%')).all())
|
||||
PY
|
||||
```
|
||||
|
||||
## Windows sites (installer-built)
|
||||
|
||||
Run a newer installer `.exe` over the existing install. That is the whole
|
||||
procedure - none of the manual steps above apply.
|
||||
|
||||
[UPDATES-WINDOWS.md](UPDATES-WINDOWS.md) is the operator-facing version of this:
|
||||
downtime, what is and is not touched, security updates, and the effect on other
|
||||
sites sharing the same IIS server.
|
||||
|
||||
It backs the database up first and verifies the dump, applies the core and plugin
|
||||
migrations, restores from that backup if a migration fails, and refuses to
|
||||
install an older build over a newer one. Your `.env`, your data and your
|
||||
`web.config` are kept.
|
||||
|
||||
Before the first upgrade, confirm `mysqldump` is available
|
||||
(`.\shopdb-admin.ps1 check`). Without it the pre-upgrade backup is skipped, which
|
||||
is the one you would want if a migration went wrong.
|
||||
|
||||
Afterwards:
|
||||
|
||||
```powershell
|
||||
.\shopdb-admin.ps1 status
|
||||
.\shopdb-admin.ps1 verify
|
||||
```
|
||||
|
||||
See [OPERATE-WINDOWS.md](OPERATE-WINDOWS.md).
|
||||
|
||||
## See also
|
||||
|
||||
- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - what to back up and how to restore
|
||||
- [INSTALL-WINDOWS.md](INSTALL-WINDOWS.md) - Windows Server install
|
||||
- [CONFIG.md](CONFIG.md) - environment variables and Setting keys
|
||||
- [DEPLOY.md](DEPLOY.md) - first-time deploy runbook
|
||||
- `CHANGELOG.md` - what changed in each release
|
||||
|
||||
@@ -183,7 +183,7 @@ Skipped from migration:
|
||||
## References
|
||||
|
||||
- `shopdb/core/models/asset.py`
|
||||
- `shopdb/core/models/machine.py` (legacy, deprecated)
|
||||
- (core `Machine` model retired per this decision; machine data now owned by the machines plugin at `plugins/machines/models/machine.py`)
|
||||
- `shopdb/plugins/base.py`
|
||||
- ADR-002 (versioning of the surface)
|
||||
- ADR-003 (plugin distribution)
|
||||
|
||||
@@ -63,16 +63,21 @@ The framework provides:
|
||||
|
||||
## Migration strategy (resolved)
|
||||
|
||||
Deploys run a single core Alembic chain: `flask db upgrade`. Bundled plugins do
|
||||
NOT carry their own migration chains - their tables are folded into the core
|
||||
chain (migration `7c04_fold_plugin_schema`). This was a deliberate resolution of
|
||||
the Phase 7B footgun where bundled-plugin baselines and the core baseline both
|
||||
created the same tables, so `flask plugin upgrade-all` would conflict. A fresh
|
||||
`flask db upgrade` reproduces the live schema exactly (verified on a scratch DB).
|
||||
Deploys run two commands: `flask db upgrade` then `flask plugin upgrade-all`
|
||||
(lean/ADR-014 sites add an optional `flask plugin prune-schema` at initial
|
||||
provisioning). The core Alembic chain applied by `flask db upgrade` creates the
|
||||
full core AND bundled-plugin schema through the chain head (this includes
|
||||
migration `7c04_fold_plugin_schema`). But every bundled plugin still carries its
|
||||
own Alembic chain per ADR-008: `flask plugin upgrade-all` stamps each plugin's
|
||||
own chain (the `alembic_version_<plugin>` tables) and applies any plugin-specific
|
||||
migrations added after the ownership cutover. The earlier Phase 7B state that
|
||||
folded everything into core with no per-plugin chains was superseded by ADR-008's
|
||||
per-plugin ownership. A fresh `flask db upgrade` reproduces the live core schema
|
||||
exactly (verified on a scratch DB).
|
||||
|
||||
External (out-of-tree) plugins per ADR-003 may still ship their own migrations;
|
||||
the framework supports per-plugin chains for them. Only the in-tree bundled
|
||||
plugins are consolidated into core.
|
||||
External (out-of-tree) plugins per ADR-003 ship their own migrations too; the
|
||||
framework runs the same per-plugin chain mechanism (ADR-008) for both bundled and
|
||||
external plugins.
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -145,5 +145,5 @@ Reclassification is one-shot, run once, archived. Like the original migration sc
|
||||
|
||||
- ADR-001 (Asset is platform contract)
|
||||
- ADR-002 (versioning of the surface)
|
||||
- `plugins/equipment/` (current placeholder)
|
||||
- `plugins/machines/` (equipment plugin, renamed per ADR-011)
|
||||
- `plugins/computers/` (existing example of plugin pattern)
|
||||
|
||||
@@ -145,4 +145,4 @@ Migration path:
|
||||
- `shopdb/plugins/base.py` (`get_collector_schema` + `apply_collector_payload` hooks)
|
||||
- ADR-001 (asset model the collectors target)
|
||||
- ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps)
|
||||
- The PXE project (`/home/camp/projects/pxe/`) which feeds the computers collector
|
||||
- The PXE project (the PXE imaging project) which feeds the computers collector
|
||||
|
||||
@@ -88,8 +88,8 @@ To cut release `X.Y.Z`:
|
||||
|
||||
### Neutral
|
||||
|
||||
- CI (`.gitea/workflows/ci.yml`) runs the backend tests, the naming/style
|
||||
gate, and the frontend build on push and PR. It is best-effort: Gitea
|
||||
- The internal CI workflow runs the backend tests, the naming/style
|
||||
gate, and the frontend build on push and PR. It is best-effort: the internal CI
|
||||
Actions availability on the host is unverified, so the workflow is
|
||||
config-only until a runner is confirmed.
|
||||
|
||||
@@ -112,4 +112,4 @@ To cut release `X.Y.Z`:
|
||||
- `shopdb/__init__.py` (`__version__`, `__contract_version__`)
|
||||
- `CHANGELOG.md` (release record)
|
||||
- `frontend/package.json` (frontend version, kept in lock-step)
|
||||
- `.gitea/workflows/ci.yml` (CI gate)
|
||||
- the internal CI workflow (CI gate)
|
||||
|
||||
@@ -41,7 +41,9 @@ list. This is the whole of what ships now.
|
||||
anonymous callers is safe because `GET /api/dashboard/navigation`
|
||||
already leaks the same enabled/disabled signal, and unauthenticated
|
||||
kiosk routes (`/tv`) need the answer too. It carries no metadata, so it
|
||||
reveals strictly less than the admin-gated `GET /api/plugins`.
|
||||
reveals strictly less than the equally-anonymous but metadata-carrying
|
||||
`GET /api/plugins` (also `jwt_required(optional=True)`; only the
|
||||
`PUT /api/plugins/<name>` toggle is admin-gated).
|
||||
|
||||
2. **Route tagging.** Every plugin-owned route carries `meta.plugin =
|
||||
'<pluginname>'`. This covers the per-plugin route modules
|
||||
|
||||
@@ -43,7 +43,7 @@ one would mean forking a core view:
|
||||
printer detail page, a calibration-status card on a measuring tool. Today
|
||||
`WarrantyPanel.vue` is composed in by hand-editing each detail view.
|
||||
4. **Map marker / overlay contributions.** A calibration-due badge on the
|
||||
shop-floor map (`frontend/src/views/map/MapView.vue`). The map draws type
|
||||
shop-floor map (`frontend/src/views/MapView.vue`). The map draws type
|
||||
colors but has no plugin decoration path.
|
||||
5. **Search-result rendering / routing for plugin asset types.** Global search
|
||||
returns assets, but core hardcodes how each type renders and where its detail
|
||||
|
||||
399
docs/adr/ADR-013-plugin-catalog-and-lean-builds.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# ADR-013: Plugin Catalog, Curated Shelf, and Lean Per-Site Builds
|
||||
|
||||
- Status: PROPOSED
|
||||
- Date: 2026-07-18
|
||||
- Deciders: cproudlock
|
||||
- Relates to: ADR-002 (contract versioning), ADR-003 (plugin distribution), ADR-004 (per-site instances), ADR-008 (per-plugin migrations), ADR-009 (frontend plugin gating), ADR-010 (frontend hook contract)
|
||||
|
||||
## Context
|
||||
|
||||
Every site today ships identical code. The backend image contains all 13 plugin
|
||||
directories (Dockerfile COPY at line 55; the header comment listing "eleven core
|
||||
plugins" is stale), and the SPA compiles every plugin's routes and views via a
|
||||
static glob (frontend/src/router/index.js:11) plus hardcoded imports. A site's
|
||||
"chosen set" exists only as runtime enable flags in instance/plugins.json.
|
||||
Disabled is not absent: a site that never wants printedparts/usb/network still
|
||||
ships, and can execute, that code.
|
||||
|
||||
Distribution per ADR-003 is "drop a directory into plugins/ by hand". There is
|
||||
no artifact format, no signing, no catalog, no validate gate, and the loader
|
||||
trusts whatever it finds on disk (loader.py:61-73 discovers any folder with a
|
||||
plugin.py; loader.py:185-224 loads it; migrations.py:29-62 runs its DDL with
|
||||
full DB privileges). Plugins run in-process with the full shopdb.api surface
|
||||
including db, so the only tenable security model on air-gapped GE networks is
|
||||
curation plus cryptographic provenance, enforced everywhere code can execute,
|
||||
not sandboxing.
|
||||
|
||||
Known defects this ADR also resolves:
|
||||
|
||||
- upgrade_all_plugins checks hasattr(registry, 'list_installed') which never
|
||||
exists (registry has only get_all/get_enabled_plugins), so it always falls
|
||||
back to migrating every folder on disk, adopted or not (__init__.py:94).
|
||||
- PILOT-DEPLOY.md enables plugins that were never installed; enable refuses.
|
||||
There is no declarative "apply this chosen set" operation.
|
||||
- Reverse-dependency checks on uninstall/disable read only LOADED plugin
|
||||
instances, so an installed-but-unloaded dependent is invisible.
|
||||
- Dependency install/enable is check-only; nothing computes a closure, and
|
||||
install-a-dependent can fail at its own load step because the dependency was
|
||||
installed disabled (default_enabled=false on employees).
|
||||
- The dependency sort has no cycle detection.
|
||||
- Soft couplings (geenforce -> computers, notifications -> employees) are
|
||||
invisible to the manifest graph.
|
||||
|
||||
Frontend reality check (this drove the design below): plugin UI is NOT one
|
||||
folder per plugin. Routes live in routes/<plugin>.js (slides has none), inside
|
||||
the shared core.js (computers report, printers toner report, employees detail,
|
||||
slides settings), and as six hardcoded top-level imports in index.js itself
|
||||
(/parts-kiosk, /tv, /print/printer-qr x2, /print/usb-labels,
|
||||
/print/printedparts-labels). View dirs mismatch plugin names (computers ->
|
||||
views/pcs). Plugin settings cards sit in shared views/settings/
|
||||
(DellWarrantySettings, ZabbixSettings, SlideManager, EmployeeDirectory,
|
||||
MeasuringToolTypesList, PrintedPartsSettings), plugin print views in shared
|
||||
views/print/, and some views span plugins (AssetLabel.vue serves five asset
|
||||
types; PCDetail imports WarrantyPanel). Any lean-frontend design that only
|
||||
moves routes/*.js and views/<plugin>/ fails the build the moment a plugin is
|
||||
pruned. This ADR scopes that work honestly instead of calling it mechanical.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Tiering: mandatory core is the core package; all plugins are catalog-optional
|
||||
|
||||
- The mandatory core is the non-plugin shopdb/core/ package (auth, users,
|
||||
assets, locations, vendors, models, settings, audit, dashboard, search,
|
||||
reports, plugin management). It already survives every plugin being absent
|
||||
via hasattr/lazy-import guards. No plugin is promoted into it.
|
||||
- New optional manifest field `tier: "core" | "optional"`, default "optional".
|
||||
All 13 existing manifests are unchanged and unchanged in meaning. The
|
||||
lifecycle gains a guard: uninstall_plugin and disable_plugin refuse a
|
||||
tier:core plugin (alongside the reverse-dependency checks at
|
||||
__init__.py:269-278 and :351-360). No plugin ships tier:core initially; the
|
||||
field and guard exist so a future curation decision is a manifest edit, not a
|
||||
framework change.
|
||||
- Per-site mandates live in the site profile (section 5): a `locked` list the
|
||||
profile applier refuses to remove. This preserves ADR-004 site autonomy: a
|
||||
wing site can mandate usb without the framework mandating it fleet-wide.
|
||||
- New manifest field `optional_dependencies: []` (names only, loader-ignored).
|
||||
Declared for the verified soft couplings: geenforce lists computers
|
||||
(service.py:32-43 loses app-detection gates without it), notifications lists
|
||||
employees (routes.py:151-171 loses name/photo enrichment). Catalog listing
|
||||
and adopt WARN on unmet optional deps; nothing blocks.
|
||||
- Hard `dependencies` gains optional PEP440 ranges ("employees>=1.1").
|
||||
validate/adopt honor ranges; the runtime loader keeps name-only semantics
|
||||
(specifier stripped) so no loader behavior changes. The single existing hard
|
||||
edge printedparts -> employees stays as-is; whether it can relax to optional
|
||||
(badges.py has an external HR fallback) is a follow-up product question, not
|
||||
blocked on this ADR.
|
||||
- Dependency plumbing fixes: _sort_by_dependencies gains cycle detection
|
||||
(raise PluginDependencyError on a back edge); reverse-dependency checks read
|
||||
manifests of ALL installed plugins from disk, not loaded instances.
|
||||
|
||||
### 2. Packaging: signed, versioned artifacts
|
||||
|
||||
Artifact: `<name>-<version>.shopdbplugin` (a zip of the plugin directory:
|
||||
manifest.json, plugin.py, api/, models/, migrations/, and frontend/ once
|
||||
section 6 lands) plus two members generated at pack time:
|
||||
|
||||
- `PROVENANCE.json`: plugin name, version, publisher id, build timestamp, and
|
||||
a sorted map of every packaged file path to its SHA-256. PROVENANCE.json is
|
||||
not listed in its own map, so there is no circular-hash problem and no zip
|
||||
canonicalization needed; determinism comes from sorted per-file hashes.
|
||||
- `PROVENANCE.sig`: detached ed25519 signature over the exact PROVENANCE.json
|
||||
bytes.
|
||||
|
||||
New CLI:
|
||||
|
||||
- `flask plugin pack <name> --key <path>` (producer side): runs validate on the
|
||||
directory, then emits the artifact.
|
||||
- `flask plugin validate <dir|artifact>` (the missing pre-publish gate),
|
||||
fail-closed pipeline: signature (artifact mode) -> per-file hashes -> manifest
|
||||
against a new docs/plugin-manifest.schema.json -> name == directory ->
|
||||
core_version parses as a specifier and admits the target contract version ->
|
||||
static import-surface scan reusing tests/test_plugin_contract.py logic ->
|
||||
alembic versions parse. The schema types the known fields (name, version,
|
||||
description, dependencies, optional_dependencies, tier, core_version,
|
||||
api_prefix, display_name, default_enabled, provides, settings) and PERMITS
|
||||
additional properties, so all 13 existing manifests pass unmodified.
|
||||
|
||||
The import-surface scan is documented as a lint, not a security control; it is
|
||||
trivially bypassed by dynamic import. The security control is human review
|
||||
before signing (section 4).
|
||||
|
||||
### 3. The shelf: a read-only folder, transport-agnostic by design
|
||||
|
||||
- One config knob: `PLUGIN_SHELF_DIR`. The app only ever reads this folder. It
|
||||
never speaks SharePoint, OneDrive, or any network protocol.
|
||||
- Transport is explicitly out of scope and explicitly untrusted. On networks
|
||||
that can reach corporate M365, a SharePoint document library sync populates
|
||||
the folder. On strictly air-gapped floors where no sync agent can run, the
|
||||
folder is populated by robocopy/USB. Both are equally supported and equally
|
||||
untrusted, because every decision-bearing byte is signed: swapping transport
|
||||
changes nothing about the trust model.
|
||||
- Layout: `<shelf>/<name>/<name>-<version>.shopdbplugin` plus
|
||||
`shelf-index.json` and `shelf-index.sig`.
|
||||
- The index is SIGNED with the same publisher key and carries a monotonically
|
||||
increasing `serial` plus a `revoked` list of name-version pairs. Each site
|
||||
records the last-seen serial in instance state and refuses an index with a
|
||||
lower serial (anti-rollback of the catalog itself). The index also carries
|
||||
per-entry version/tier/core_version so `flask plugin shelf-list` can display
|
||||
compatibility without unpacking, but the index is a BROWSE layer only:
|
||||
adopt reads dependencies, tier, and core_version from the signed manifest
|
||||
inside the verified artifact, never from the index.
|
||||
- Trusted keys: `PLUGIN_TRUSTED_KEYS` is a list of pinned public keys delivered
|
||||
out-of-band in the site's deployed config/image. Keys are NEVER read from the
|
||||
shelf; a folder that can be written by an attacker must not also carry the
|
||||
keys that authenticate it. Multiple pinned keys allow overlap rotation.
|
||||
Revocation of an artifact rides the signed index `revoked` list; a
|
||||
`flask plugin audit` command warns when an installed version appears there.
|
||||
- Partial-sync robustness: adopt copies the artifact to a temp location,
|
||||
verifies signature and every file hash there, then unpacks to
|
||||
plugins/.staging/<name> and renames into place atomically. OneDrive
|
||||
placeholder stubs, zero-byte files, or an index referencing not-yet-synced
|
||||
artifacts all fail closed with a clear "artifact not fully synced/verified"
|
||||
error.
|
||||
- `flask plugin adopt <name>[==version]`: resolve version from the shelf,
|
||||
verify, compute the hard-dependency closure from signed manifests, then for
|
||||
each closure member in topological order: unpack, INSTALL, and ENABLE (not
|
||||
install-only; the load gate at loader.py:201-206 checks is_enabled, so an
|
||||
install-only closure with default_enabled=false deps would fail its own
|
||||
load). Migrations run via the unchanged per-plugin chain (ADR-008). Refuses
|
||||
to adopt a version lower than the installed one unless
|
||||
`--force-downgrade` is given interactively. Prints the restart notice.
|
||||
- Adopt/install/uninstall remain CLI-only. The admin HTTP surface stays a
|
||||
read-only catalog view plus the existing enable/disable toggle; because
|
||||
Flask cannot register blueprints after the first request, any adopt or
|
||||
enable takes full effect only on restart, and the UI says so. There is no
|
||||
"install button" that pretends otherwise.
|
||||
|
||||
### 4. Trust model: verify at adopt AND at every load and migrate
|
||||
|
||||
Signing that gates only adoption is bypassable through every other write path
|
||||
into plugins/ (git clone, symlink, USB drop) and defeated by post-adoption
|
||||
tampering. Therefore verification is enforced where code executes:
|
||||
|
||||
- Adoption leaves PROVENANCE.json and PROVENANCE.sig inside plugins/<name>/
|
||||
and records publisher + artifact hash in the registry entry.
|
||||
- load_plugin verifies the signature against PLUGIN_TRUSTED_KEYS and re-hashes
|
||||
the plugin tree against the provenance file map BEFORE importing plugin.py
|
||||
(new step ahead of loader.py:185). Missing or invalid provenance is a
|
||||
fail-closed refusal in production.
|
||||
- run_plugin_migrations performs the same verification before executing any
|
||||
revision, so a routine `flask plugin upgrade-all` can never run DDL from an
|
||||
unverified folder.
|
||||
- upgrade_all_plugins iterates registry.get_all() (fixing the phantom
|
||||
list_installed fallback at __init__.py:94), so unadopted on-disk folders are
|
||||
never migrated as a side effect of deploys.
|
||||
- Development and the ADR-003 external-repo/symlink workflow (including
|
||||
scripts/test-external-plugin.sh) are preserved via `PLUGIN_DEV_TRUST_DIRS`,
|
||||
honored ONLY when DEBUG or TESTING is set. Production ignores it.
|
||||
- Cost: hashing 13 small plugin trees at boot is milliseconds; accepted.
|
||||
|
||||
What signing does NOT claim: a valid signature proves the artifact is exactly
|
||||
what a curator reviewed and signed, nothing more. Plugins remain in-process
|
||||
Python with full DB access. The actual safety control is the human review
|
||||
before signing; the signature makes that review's verdict tamper-evident all
|
||||
the way to execution.
|
||||
|
||||
### 5. Declarative site profiles and lean backend builds
|
||||
|
||||
- `site-profile.json` per site (kept in the site's deploy config): site name,
|
||||
list of chosen plugins, optional `locked` list. `flask plugin apply-profile
|
||||
<file>` resolves the closure, installs AND enables in dependency order, runs
|
||||
migrations, reports which changes need a restart. This replaces the
|
||||
imperative CLI sequences in DEPLOY.md/PILOT-DEPLOY.md and fixes the
|
||||
enable-without-install bug.
|
||||
- Lean backend image: `scripts/build-site.sh` reads the profile and stages
|
||||
only core + chosen plugin directories into the Docker build context
|
||||
(correcting the Dockerfile COPY and its stale header comment). Discovery
|
||||
needs no change; it already scans whatever exists.
|
||||
- Prerequisite the naive version misses: core hardcodes plugin imports.
|
||||
shopdb/core/api/search.py (~15 sites), reports.py, assets.py, collector.py,
|
||||
applications.py, auditlogs.py, and shopdb/cli/__init__.py import
|
||||
plugins.<name>.* lazily. Some already guard ImportError; ALL must, with
|
||||
graceful degradation, before any site prunes a folder. This is audited and
|
||||
enforced by a new CI job that deletes one plugin directory and runs the full
|
||||
test suite (repeated per plugin). Longer term these aggregators should move
|
||||
to registry-driven contract hooks (get_search_providers/get_report_sources)
|
||||
so a new catalog plugin can join search/reports without core edits; that is
|
||||
scoped as follow-up work, not a blocker for lean builds.
|
||||
- Schema-lean is DEFERRED to its own ADR. The core baseline 68b3947ae14f
|
||||
unconditionally creates the 10 pre-cutover plugins' tables, and lifting them
|
||||
into plugin baselines collides with cross-plugin foreign keys (the
|
||||
computers-owned installedapps table FKs machines.machineid while computers
|
||||
declares no dependency on machines). Reversing the cutover would either
|
||||
introduce undeclared hard deps or drop FKs; neither is decided here. A lean
|
||||
site therefore carries a handful of empty pre-cutover tables. Accepted.
|
||||
|
||||
### 6. Frontend delivery: Path C for rich UIs, Path A for simple ones, Path B rejected
|
||||
|
||||
Path B (runtime-loaded JS / module federation) is REJECTED: it moves executable
|
||||
UI delivery from a signed, statically auditable build artifact to runtime
|
||||
fetching, which is exactly the wrong direction for an air-gapped,
|
||||
review-then-sign posture, for zero benefit given restarts are already required.
|
||||
|
||||
Path A (declarative JSON UI over generic renderers) is COMMITTED and scheduled
|
||||
EARLY: the three unwired ADR-010 endpoints (pluginui.py asset-panels:62,
|
||||
map-overlays:88, asset-presentation:100) get generic core renderers, joining
|
||||
the already-consumed settings-cards. After this, a simple plugin ships JSON-only
|
||||
UI with zero frontend build involvement. Sequencing this before the relocation
|
||||
gives every plugin an escape hatch during the migration instead of after it.
|
||||
|
||||
Path C (self-contained plugin frontend) is the primary mechanism, scoped
|
||||
against the real code, not the idealized layout:
|
||||
|
||||
- Canonical home: plugins/<name>/frontend/ containing routes.js (the plugin's
|
||||
complete route array, INCLUDING routes currently embedded in index.js and
|
||||
core.js), views/, and settings views.
|
||||
- A pre-Vite staging step (scripts/stage-frontend.mjs, run by build-site.sh
|
||||
and the dev script) copies the CHOSEN plugins' frontend/ into
|
||||
frontend/src/.plugins-staged/<name>/ (gitignored) and generates two files
|
||||
inside the Vite root: routes.gen.js (aggregated plugin routes) and
|
||||
meta.gen.js (plugin-supplied icon names, title spellings, settings-standalone
|
||||
flags, replacing the hardcoded iconMap/TITLE_SPELLINGS/SETTINGS_STANDALONE in
|
||||
AppLayout.vue, settingsCatalog.js, and index.js). This exists because
|
||||
import.meta.glob requires a static literal inside the project root and
|
||||
cannot select a per-site subset by itself.
|
||||
- ONE-TIME core-router surgery, done first and called what it is: the six
|
||||
hardcoded plugin-view imports in index.js (PartsKiosk, TVDashboard,
|
||||
PrinterQRBatch/Single, USBLabelBatch, PrintedPartsLabels) and the plugin
|
||||
routes embedded in core.js move into their owning plugins' routes.js. Without
|
||||
this, pruning slides/printers/usb/printedparts fails the Vite build on
|
||||
unresolvable imports; no amount of glob work fixes it.
|
||||
- Per-plugin relocation PRs (13), each REAL WORK, not a file move: carve routes
|
||||
out of shared files, move views (handling name mismatches like computers ->
|
||||
views/pcs), move the plugin's settings views out of shared views/settings/,
|
||||
and rewrite relative ../../ imports of core shared code to the @/ alias
|
||||
(relative paths break at the staged depth). A lint rule enforces alias-only
|
||||
core imports in plugin frontend code from then on.
|
||||
- Shared plugin-aware code STAYS CORE and ships to every site: AssetLabel.vue
|
||||
(spans five asset types), views/print helpers (assetLabel.js, qrLogo.js),
|
||||
MachineBadge.vue, and cross-plugin panels like WarrantyPanel used by
|
||||
PCDetail. These already null-guard or gate via isPluginEnabled and must keep
|
||||
degrading when a peer plugin is absent; over time they migrate to ADR-010
|
||||
asset-panels so the data becomes plugin-supplied. Lean v1 therefore prunes
|
||||
plugin-EXCLUSIVE code; a small plugin-aware core remainder is accepted and
|
||||
shrinks as Path A absorbs it.
|
||||
- Dual-location transition: the staging step unions legacy locations
|
||||
(routes/*.js glob, views/<plugin>/) with plugins/<name>/frontend/ until each
|
||||
plugin has moved. The SPA builds green at every commit; each plugin's move is
|
||||
independently revertable until the legacy glob is removed at the end.
|
||||
- Nav and settings cards are already server-driven (dashboardApi.navigation,
|
||||
settings-cards); the remaining hardcoded plugin entries in settingsNav.js
|
||||
(/settings/zabbix, /settings/dellwarranty) move to those plugins'
|
||||
get_settings_cards so pruning leaves no dead links.
|
||||
|
||||
### 7. Effect on the 13 existing plugins
|
||||
|
||||
- Backend: ZERO code changes required. tier/optional_dependencies/provenance
|
||||
are additive; pack zips the directory as-is; all plugins keep passing
|
||||
tests/test_plugin_contract.py. Bundled plugins in a site's image get
|
||||
provenance stamped at build time by pack, so verify-at-load applies to them
|
||||
identically.
|
||||
- Frontend: one relocation PR each, of the honest scope above. Until a
|
||||
plugin's PR lands it keeps working from its legacy location.
|
||||
- Operationally nothing changes for a site that does nothing: default builds
|
||||
remain all-plugins, apply-profile is opt-in, and enable/disable semantics
|
||||
(including the restart requirement) are unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- A real catalog: sites declare their set in site-profile.json and apply it in
|
||||
one idempotent command; the chosen set drives backend image, SPA bundle, and
|
||||
runtime state from one source of truth.
|
||||
- Curated marketplace with end-to-end provenance: review -> sign -> any
|
||||
transport -> verify at adopt, at load, and at migrate. Transport (SharePoint
|
||||
sync or sneakernet) is untrusted and interchangeable, which is exactly right
|
||||
for air-gapped sites.
|
||||
- Lean per-site builds: unchosen plugins exist in neither the image nor the
|
||||
bundle, shrinking attack surface and download size.
|
||||
- Fixes shipped along the way: upgrade-all migrating unadopted folders,
|
||||
PILOT-DEPLOY install/enable ordering, reverse-dep checks blind to unloaded
|
||||
plugins, missing cycle detection, missing dependency closure, hardcoded
|
||||
frontend plugin metadata.
|
||||
- Path A completion makes simple plugins UI-capable with no build glue, which
|
||||
is the cheapest possible marketplace onboarding.
|
||||
|
||||
### Negative
|
||||
|
||||
- Key management is a per-site operational burden: pinned keys delivered
|
||||
out-of-band, rotation is a config change everywhere. Accepted as the price of
|
||||
not trusting the distribution folder.
|
||||
- The frontend re-org is the long pole: one core-router surgery plus 13
|
||||
non-trivial PRs. It is sequenced to be always-green and per-plugin
|
||||
revertable, but it is weeks of work, not a rename.
|
||||
- Schema is not lean: pre-cutover plugin tables still appear at every site
|
||||
until the deferred baseline re-org ADR.
|
||||
- Restarts remain required after adopt/enable (Flask blueprint constraint);
|
||||
the marketplace UX is honest about it rather than working around it.
|
||||
- Boot adds a signature + tree-hash check per enabled plugin (milliseconds,
|
||||
but nonzero).
|
||||
|
||||
### Risks
|
||||
|
||||
- Key compromise or curation failure: a signature proves provenance, not
|
||||
safety; a compromised pinned key or a rubber-stamp review signs malware that
|
||||
every gate will happily pass. Mitigations: multi-key pinning with overlap
|
||||
rotation, signed revocation list with monotonic index serial, and keeping the
|
||||
signing key offline with the curator. The static import scan is a lint and
|
||||
must never be presented as a boundary.
|
||||
- Rollback/downgrade: mitigated three ways: index serial monotonicity, adopt
|
||||
refusing version downgrades without interactive --force-downgrade, and the
|
||||
signed revoked list. Residual risk: a site that never syncs a newer index
|
||||
cannot learn of revocations; `flask plugin audit` at deploy time narrows the
|
||||
window.
|
||||
- Version skew across ADR-004 sites: one shelf serves sites at different
|
||||
contract versions. Adopt checks core_version from the signed manifest against
|
||||
the site's own __contract_version__ (authoritative); shelf-list shows an
|
||||
advisory compatibility column from the index. Incompatible artifacts are
|
||||
listable but not adoptable.
|
||||
- Partial/placeholder sync files: fail closed on hash verification; the error
|
||||
message distinguishes "not fully synced" from "tampered" only by wording,
|
||||
intentionally, since the app cannot tell.
|
||||
- Dev-trust misuse: PLUGIN_DEV_TRUST_DIRS silently ignored outside
|
||||
DEBUG/TESTING; a prod config carrying it gets a startup warning.
|
||||
- Frontend closure drift: plugin views importing cross-plugin components is a
|
||||
graph the manifest does not model. The lint rule (plugin frontend may import
|
||||
core @/ paths and its own tree only, never another plugin's) prevents new
|
||||
edges; existing shared plugin-aware code is explicitly core-owned.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
- Phase 0, groundwork (small, days): upgrade_all_plugins uses
|
||||
registry.get_all(); reverse-dep checks read installed manifests from disk;
|
||||
cycle detection in _sort_by_dependencies; shopdb/plugins/manifest_schema.json +
|
||||
`flask plugin validate` (directory mode); `flask plugin apply-profile` with
|
||||
install+enable closure ordering; fix Dockerfile stale comment. All additive,
|
||||
zero risk to running sites.
|
||||
- Phase 1, packaging and signing (medium, about a week): PROVENANCE format,
|
||||
`flask plugin pack`, validate artifact mode, PLUGIN_TRUSTED_KEYS config,
|
||||
ed25519 signing tooling and curator docs. No runtime behavior change yet.
|
||||
- Phase 2, shelf and enforcement (medium-large, one to two weeks):
|
||||
PLUGIN_SHELF_DIR, signed shelf-index with serial + revoked list,
|
||||
`flask plugin shelf-list` / `adopt` / `audit` with atomic verified unpack;
|
||||
verify-at-load in load_plugin and verify-at-migrate in
|
||||
run_plugin_migrations, fail-closed in prod; PLUGIN_DEV_TRUST_DIRS for
|
||||
dev/test and the external-repo harness; tier:core lifecycle guard;
|
||||
provenance stamping of bundled plugins at build. This phase completes the
|
||||
security model; everything after it is delivery optimization.
|
||||
- Phase 3, Path A completion (medium, one to two weeks): generic renderers for
|
||||
asset-panels, map-overlays, asset-presentation; migrate settingsNav.js
|
||||
hardcoded plugin cards to get_settings_cards. Done BEFORE relocation so
|
||||
JSON-only UI is available during the migration.
|
||||
- Phase 4, frontend re-org (large, the long pole, several weeks elapsed):
|
||||
stage-frontend.mjs staging + routes.gen.js/meta.gen.js codegen; ONE core PR
|
||||
moving the six index.js hardcoded plugin imports and the core.js-embedded
|
||||
plugin routes into plugin route files; then 13 per-plugin relocation PRs
|
||||
(views, settings views, name-mismatch dirs, @/ alias rewrite) under the
|
||||
dual-location union; lint rule for plugin frontend imports. Always-green,
|
||||
per-plugin revertable.
|
||||
- Phase 5, lean builds end to end (medium, about a week after Phase 4):
|
||||
build-site.sh staging backend dirs + frontend staging from site-profile.json;
|
||||
core lazy-import guard audit finished, enforced by the delete-a-plugin CI
|
||||
matrix; remove the legacy glob; pilot one real lean site (a location without
|
||||
printedparts/usb/network) and diff its image and bundle against a full build.
|
||||
|
||||
Deferred, each to its own future decision: schema-lean core-baseline re-org
|
||||
(blocked on the installedapps -> machines FK question), pip/entry-point
|
||||
distribution (ADR-003 v2), hook-based search/report aggregation contract, and
|
||||
any revisit of Path B.
|
||||
142
docs/adr/ADR-014-schema-lean-per-site.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables)
|
||||
|
||||
- Status: ACCEPTED
|
||||
- Date: 2026-07-19
|
||||
- Deciders: cproudlock
|
||||
- Relates to: ADR-008 (per-plugin migration ownership), ADR-013 (plugin catalog + lean per-site builds), ADR-001 (asset model)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-013 delivered lean per-site builds for plugin CODE (backend tree + frontend
|
||||
bundle carry only chosen plugins). One residual was left, explicitly deferred:
|
||||
the DATABASE. The core Alembic baseline (68b3947ae14f) creates EVERY table,
|
||||
including ~30 plugin-owned tables (PLUGIN_TABLE_OWNERS). Each plugin's own
|
||||
baseline is a stamp-only no-op (the core chain already made its tables, per
|
||||
ADR-008). So a lean site that omits a plugin still creates that plugin's tables,
|
||||
empty and unused.
|
||||
|
||||
The deferral cited a blocker: "the computers-owned installedapps table FKs
|
||||
machines.machineid while computers declares no dependency on machines; reversing
|
||||
the cutover would introduce undeclared hard deps or drop FKs; neither is
|
||||
decided." Investigation refined this:
|
||||
|
||||
The cross-boundary foreign keys into the machines plugin table are ALL held by
|
||||
DEAD legacy tables/columns that predate the asset model (ADR-001) and the
|
||||
per-plugin cutover (ADR-008), and are queried nowhere in the codebase:
|
||||
|
||||
- `machinerelationships` (child/parentmachineid -> machines) - superseded by
|
||||
`assetrelationships`. No model, no query.
|
||||
- `printerdata` (machineid -> machines) - the pre-cutover printers table,
|
||||
superseded by the printers plugin. No model, no query.
|
||||
- `installedapps` (machineid -> machines) - a standalone machine-app link table;
|
||||
the live relationship is `computerinstalledapps` (FK to computers only). The
|
||||
standalone table has no model, no query.
|
||||
- `communications.machineid` (-> machines) - a legacy column on the core
|
||||
communications table (which is now assetid-based). Not read anywhere.
|
||||
|
||||
No LIVE plugin table hard-FKs another plugin's table. computerinstalledapps FKs
|
||||
only computers.computerid (intra-plugin). So the blocker is dead cruft, not
|
||||
live design.
|
||||
|
||||
## Decision
|
||||
|
||||
Two phases, both leaving existing databases correct.
|
||||
|
||||
### Phase 1: retire the dead cross-boundary cruft - ALREADY DONE
|
||||
|
||||
Investigation found this is already accomplished by existing migrations:
|
||||
`7a01_adr001_position_contract` and `7c01_drop_legacy_machine` drop
|
||||
`machinerelationships`, `printerdata`, `installedapps`, and
|
||||
`communications.machineid` (with its FK). The current schema (verified on the
|
||||
dev database) has none of them. So the cross-plugin FK blocker ADR-013 cited no
|
||||
longer exists in the live schema - only in the baseline's transient
|
||||
create-then-later-drop. No new migration is needed for Phase 1.
|
||||
|
||||
Precedent: ADR-001 dropped a cross-plugin FK the same way
|
||||
(usbcheckouts.machineid -> machines became a soft sentinel).
|
||||
|
||||
### Enabling change (executed now): idempotent create_plugin_tables
|
||||
|
||||
`shopdb/plugins/alembic_template.py:create_plugin_tables` now skips any table
|
||||
that already exists (inspects the bind first) instead of raising. This is the
|
||||
mechanism Phase 2 needs: a plugin anchor can create its tables on a fresh lean
|
||||
install AND be a safe no-op on an existing database that already has them from
|
||||
the pre-cutover core baseline. Correct and inert regardless of Phase 2 (no
|
||||
current caller creates against a populated schema). Verified against the
|
||||
plugin-migration suite.
|
||||
|
||||
### Phase 2 (executed): prune not-installed plugin tables after upgrade
|
||||
|
||||
Two mechanisms were weighed to make a lean site's database carry only
|
||||
core + chosen-plugin tables:
|
||||
|
||||
- **Relocate** (rejected): pull every plugin-table create/alter out of the core
|
||||
chain into the plugin baselines, so the core chain never creates a
|
||||
not-installed plugin's table. Measurement killed this: plugin tables are
|
||||
created and altered across ~15 released core migrations (baseline plus 7c04,
|
||||
7d05, 7d08, 7d13, 7d15, 7d16, 7d17, ...), not just the baseline. Because the
|
||||
whole core chain runs before any plugin chain, removing a table's create from
|
||||
core while a later core migration still alters it breaks FULL installs too, so
|
||||
relocation means surgically rewriting ~15 released migrations - the highest
|
||||
blast radius in the project - for a purely cosmetic gain (the omitted tables
|
||||
are empty and the lean CODE build already never loads the plugin).
|
||||
|
||||
- **Prune-after-upgrade** (chosen): leave the entire core chain untouched. Add
|
||||
`flask plugin prune-schema`, which drops the tables of every plugin in
|
||||
PLUGIN_TABLE_OWNERS that is not installed on this site. Run once at deploy,
|
||||
after `flask db upgrade` and `flask plugin upgrade-all`. Same end state
|
||||
(core + chosen tables) with near-zero blast radius: no released migration is
|
||||
edited, and an existing full site is unaffected because it never runs the
|
||||
command.
|
||||
|
||||
`prune-schema` drops by table name (no plugin-code import), so it works on a
|
||||
lean image where the omitted plugin's directory is absent. It is a dry-run by
|
||||
default and refuses to drop a table that holds rows unless `--force`, so a
|
||||
misfire on a populated site cannot silently delete data. Because the core chain
|
||||
seeds a few plugin reference tables (e.g. 7d05 inserts default access
|
||||
protocols), initial lean provisioning uses `--force` - at that point the tables
|
||||
hold only migration-seeded defaults, before any site data exists.
|
||||
|
||||
The idempotent `create_plugin_tables` (enabling change above) is what lets a
|
||||
lean site later ADD an omitted plugin: its anchor recreates the pruned tables.
|
||||
|
||||
Verified end to end on MySQL: fresh full install (86 tables) then prune is a
|
||||
no-op; fresh lean install (machines + printers) then prune drops the other 19
|
||||
plugin tables, leaving core + chosen; second prune is a no-op; the non-empty
|
||||
guard refuses without `--force`. Four SQLite regression tests pin the behavior
|
||||
(tests/test_plugin_prune_schema.py), running in the backend CI job via the real
|
||||
CLI runner: drop-only-not-installed, full-site no-op, refuse-non-empty, and
|
||||
force-drops-non-empty.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- A lean site's database contains only core + chosen-plugin tables, with no edit
|
||||
to any released migration (near-zero blast radius).
|
||||
- The cross-plugin FK blocker ADR-013 cited is gone (dead cruft, dropped by
|
||||
existing migrations), so plugin schemas are already FK-independent.
|
||||
- Adding an omitted plugin to a lean site later just works: the idempotent
|
||||
anchor recreates its tables.
|
||||
|
||||
### Negative / risk
|
||||
|
||||
- prune-schema is destructive by nature; the row-count guard + dry-run default +
|
||||
required `--force` for non-empty tables contain that. It is a deploy-time
|
||||
provisioning step, not something to run casually on a live populated site.
|
||||
- A lean fresh install still transiently creates then drops the omitted plugins'
|
||||
tables (the core chain builds them, prune removes them). Harmless and one-time
|
||||
at provisioning; the trade for not touching the released baseline.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Phase 1: nothing to do - the dead cross-boundary FK objects were already
|
||||
dropped by existing migrations `7a01_adr001_position_contract` and
|
||||
`7c01_drop_legacy_machine`; verified absent on a fresh full MySQL upgrade.
|
||||
- Enabling change: `create_plugin_tables` made idempotent
|
||||
(`shopdb/plugins/alembic_template.py`).
|
||||
- Phase 2: `flask plugin prune-schema` (`shopdb/plugins/cli.py`), dry-run by
|
||||
default, `--yes` to execute, `--force` for non-empty tables. Deploy order:
|
||||
`flask db upgrade` -> `flask plugin upgrade-all` -> `flask plugin prune-schema
|
||||
--yes --force`. Regression tests in `tests/test_plugin_prune_schema.py` (run in
|
||||
the backend CI job).
|
||||
@@ -25,6 +25,8 @@ Each ADR captures a single architectural decision: the context, the decision its
|
||||
| [010](ADR-010-frontend-plugin-hooks.md) | Frontend plugin hook contract | ACCEPTED |
|
||||
| [011](ADR-011-machines-rename.md) | Machines rename + modeltypes retyping | ACCEPTED |
|
||||
| [012](ADR-012-geenforce-manifest-ownership.md) | GE-Enforce manifest ownership in shopdb | ACCEPTED |
|
||||
| [013](ADR-013-plugin-catalog-and-lean-builds.md) | Plugin catalog, curated shelf, and lean per-site builds | PROPOSED |
|
||||
| [014](ADR-014-schema-lean-per-site.md) | Schema-lean per-site builds (retire cross-plugin FKs, prune not-installed plugin tables) | ACCEPTED |
|
||||
|
||||
## Authoring
|
||||
|
||||
|
||||
2978
docs/api-inventory.json
Normal file
751
docs/geenforce-api-cutover.md
Normal file
@@ -0,0 +1,751 @@
|
||||
# GE-Enforce over HTTPS API: cutover reference
|
||||
|
||||
This is the operator/developer reference for moving a shopfloor PC type off the
|
||||
SMB-share GE-Enforce delivery and onto the shopdb HTTPS API. The first cohort
|
||||
cut over was the displays/kiosks (`gea-shopfloor-display`): share-less,
|
||||
Intune/Entra-joined PCs with no SFLD share credentials. This doc captures
|
||||
everything learned doing that, so extending the cutover to the other pc-types
|
||||
(`gea-shopfloor-cmm`, `-collections`, `-keyence`, `-genspect`, `-heattreat`,
|
||||
`-partmarker`, `-nocollections`, `common`) does not require re-learning it.
|
||||
|
||||
It pairs with the existing docs (which describe the pieces; this one describes
|
||||
the CUTOVER):
|
||||
|
||||
- `docs/GE-ENFORCE.md` - concepts and the plugin
|
||||
- `docs/GE-ENFORCE-CLIENT.md` - client fetch/report contract
|
||||
- `docs/GE-ENFORCE-DEPLOY.md` - what must land on a PC
|
||||
- `docs/GE-ENFORCE-DISPLAY.md` - the display scope specifics
|
||||
- the PXE repo (`docs/ge-enforce-v2-architecture.md`) - the SMB world
|
||||
being cut away from
|
||||
|
||||
Contents:
|
||||
|
||||
1. [Overview and why](#1-overview-and-why)
|
||||
2. [Server architecture](#2-server-architecture)
|
||||
3. [Delivery models: smb vs http/inline payloads](#3-delivery-models-smb-vs-httpinline-payloads)
|
||||
4. [Authoring a scope](#4-authoring-a-scope)
|
||||
5. [The on-PC client and engine](#5-the-on-pc-client-and-engine)
|
||||
6. [Bootstrap for share-less PCs](#6-bootstrap-for-share-less-pcs)
|
||||
7. [Asset reporting via the collector](#7-asset-reporting-via-the-collector)
|
||||
8. [HARD-WON GOTCHAS](#8-hard-won-gotchas)
|
||||
9. [How this was verified](#9-how-this-was-verified)
|
||||
10. [Deploy](#10-deploy)
|
||||
11. [PLAYBOOK: extending to a new pc-type](#11-playbook-extending-to-a-new-pc-type)
|
||||
12. [Open items / TODO](#12-open-items--todo)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview and why
|
||||
|
||||
GE-Enforce v2 delivers desired-state manifests and installer payloads from the
|
||||
SFLD SMB share (`\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\`).
|
||||
Every PC mounts the share with Azure-DSC-provisioned SFLD credentials, reads
|
||||
`<scope>\manifest.json`, and runs the engine
|
||||
(`Install-FromManifest.ps1`). That works for the domain fleet but is a hard
|
||||
dead end for share-less PCs.
|
||||
|
||||
The HTTPS API path replaces the share as the transport, keeping the engine and
|
||||
its detection/self-heal behavior untouched:
|
||||
|
||||
| Concern | SMB share | HTTPS API |
|
||||
|---------|-----------|-----------|
|
||||
| Manifest source | `<scope>\manifest.json` on the share | `GET /api/geenforce/manifest?pctype=<scope>` (published snapshot) |
|
||||
| Payload source | share paths (`apps\...`) | `GET /api/geenforce/payload/<sha256>` (content-addressed) |
|
||||
| Auth | SFLD share credential (DSC) | `geenforce.fetch` service token OR source-IP allowlist |
|
||||
| Result visibility | log files on the PC | `POST /api/geenforce/report` -> Enforcement Reports UI |
|
||||
| Versioning | file overwrite + `_meta/history` | immutable published versions, rollback, ETag |
|
||||
|
||||
Which PCs MUST use the API: the share-less ones. Displays/kiosks are
|
||||
Intune/Entra-joined with no SFLD credentials and no domain trust, so SMB is not
|
||||
an option at all. The rest of the fleet CAN stay on the share (and currently
|
||||
does); for them the API is an opt-in migration, not a forced one.
|
||||
|
||||
West Jefferson facts used throughout this doc:
|
||||
|
||||
| Fact | Value |
|
||||
|------|-------|
|
||||
| Prod host | `tsgwp00525.wjs.geaerospace.net` |
|
||||
| App mount | `/shopdb` (IIS, app dir `C:\inetpub\wwwroot\shopdb`, pool `shopdbflask-prod`) |
|
||||
| BaseUrl clients use | `https://tsgwp00525.wjs.geaerospace.net/shopdb` |
|
||||
| Prod DB | `shopdb_flask` (MySQL) |
|
||||
| Client allowlist CIDRs | `10.134.48.0/23,10.48.249.0/26` (the WJ corp/AESFMA shopfloor subnets) |
|
||||
| Dev/staging instance | `/ops` mount, DB `shopdb_flask_dev`, pool `shopdbflask` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Server architecture
|
||||
|
||||
All server code is in `plugins/geenforce/` (routes: `plugins/geenforce/api/routes.py`).
|
||||
|
||||
### Client-facing endpoints (three)
|
||||
|
||||
| Endpoint | Method | Auth | What it does |
|
||||
|----------|--------|------|--------------|
|
||||
| `/api/geenforce/manifest?pctype=<scope>&phase=runtime` | GET | `geenforce.fetch` token OR IP allowlist | Serves the CURRENT PUBLISHED manifest snapshot for a scope (never the draft). `ETag: "<scopeid>-v<version>"`, `X-Manifest-Version` header, 304 on `If-None-Match`. |
|
||||
| `/api/geenforce/payload/<sha256>` | GET | `geenforce.fetch` token OR IP allowlist | Streams a payload blob by content hash: blob store first (`service.blob_path`), then inline `ManifestPayload`. ETag = the hash. Per-IP rate limited (120/min default) and size-capped (512 MB default, 413 above). |
|
||||
| `/api/geenforce/report` | POST | `geenforce.report` token OR IP allowlist | Records one enforcement cycle via `service.record_enforcement_report`: hostname, scopename, appliedversion, enforcerversion, counts, per-entry results. Upserts the current report per (hostname, scopename, phase); older reports kept as history. |
|
||||
|
||||
Plus the collector for asset reporting (section 7): `POST
|
||||
/api/collector/computers` in `shopdb/core/api/collector.py` - a DIFFERENT auth
|
||||
domain (`collector.ingest`), NOT covered by the geenforce allowlist.
|
||||
|
||||
### Auth model
|
||||
|
||||
`_require_service_token(scope)` in `routes.py` is the decorator factory. Two
|
||||
paths, fail-closed (neither -> 401):
|
||||
|
||||
1. A managed service token with the scope (`geenforce.fetch` for
|
||||
manifest/payload, `geenforce.report` for report), sent as `X-API-Key` or a
|
||||
Bearer PAT (`authorized_service_token`). A token may carry
|
||||
`resourcescopelist` bindings: a bound token can only fetch its own scope's
|
||||
manifest (403 otherwise) and only blobs those scopes' published manifests
|
||||
reference (`service.blob_referenced_by_scopes`, 404 so hashes cannot be
|
||||
probed). Displays get a token bound to `gea-shopfloor-display` (see
|
||||
GE-ENFORCE-DISPLAY.md).
|
||||
2. The IP allowlist: `_ip_allowlisted()` checks the caller against the setting
|
||||
`geenforce_allowed_cidrs` (comma-separated CIDRs/IPs, empty = disabled).
|
||||
Network trust replaces the shared secret for a vaulted fleet. The
|
||||
allowlisted path has no resource-scope binding (unrestricted).
|
||||
|
||||
The token paths use RBAC permissions the plugin registers in
|
||||
`GeEnforcePlugin.get_permissions()`: `geenforce.manage` (edit),
|
||||
`geenforce.publish` (ship), `geenforce.fetch` and `geenforce.report`
|
||||
(client service tokens). Admin CRUD/publish/simulate/compliance routes are JWT
|
||||
plus `geenforce.manage`/`geenforce.publish`.
|
||||
|
||||
### Why the allowlist uses remote_addr (the IIS XFF dependency)
|
||||
|
||||
`_trusted_client_ip()` returns `request.remote_addr`, NOT the raw
|
||||
`X-Forwarded-For` header. Proxies APPEND to X-Forwarded-For, so its first hop
|
||||
is attacker-controlled: parsing it (as `_client_ip()` does, acceptably, for
|
||||
rate limiting only) would let any caller send `X-Forwarded-For:
|
||||
<allowlisted-ip>` and bypass the token entirely.
|
||||
|
||||
`remote_addr` is trustworthy only because of a two-piece chain that MUST stay
|
||||
in place:
|
||||
|
||||
1. The IIS URL-Rewrite rule in the `/shopdb` web.config OVERWRITES (not
|
||||
appends) the inbound `X-Forwarded-For` with `REMOTE_ADDR`, the real TCP
|
||||
peer.
|
||||
2. waitress runs with `--trusted-proxy=127.0.0.1
|
||||
--trusted-proxy-headers=x-forwarded-for`, so it derives `remote_addr` from
|
||||
that overwritten header only when the request comes from IIS on localhost.
|
||||
A client that somehow hits waitress directly is not a trusted proxy, so its
|
||||
`remote_addr` is its own real peer address. Either way, real client IP.
|
||||
|
||||
**If the IIS rule is ever removed, the allowlist becomes SPOOFABLE.** This
|
||||
document previously claimed the opposite; it was wrong, and the reasoning matters.
|
||||
|
||||
IIS does not set `X-Forwarded-For` on its own - the rewrite rule is the only
|
||||
thing that does. Remove the rule and IIS still *forwards* whatever
|
||||
`X-Forwarded-For` the caller sent. waitress trusts that header because it arrives
|
||||
from `127.0.0.1`, which is IIS, and sets `remote_addr` from it. So a caller who
|
||||
sends `X-Forwarded-For: 10.134.48.5` gets `remote_addr = 10.134.48.5`, matches
|
||||
the allowlist and fetches manifests token-less from anywhere on the network.
|
||||
|
||||
The rule is not a nicety that improves logging. It is the control that makes
|
||||
`remote_addr` trustworthy, and everything downstream - the allowlist, the
|
||||
dashboard visitor-location lookup, per-host login rate limiting - depends on it.
|
||||
|
||||
Three consequences worth stating plainly:
|
||||
|
||||
- The Windows installer enables the rule when told IIS faces clients directly
|
||||
(`-ClientIpSource direct`), installs URL Rewrite from the bundle to make that
|
||||
possible offline, and its stage-5 check fails if the rule is not live.
|
||||
- On a hand-built server, verify it: `deploy/windows/web.config` must have the
|
||||
`<rewrite>` block ACTIVE, not inside the `SHOPDB-CLIENTIP` comment markers.
|
||||
- Behind a real reverse proxy the rule is the wrong answer, because `REMOTE_ADDR`
|
||||
is then the proxy. There, the proxy must set `X-Forwarded-For` itself and be
|
||||
the only thing that can reach IIS. `-ClientIpSource proxy` covers that case.
|
||||
|
||||
---
|
||||
|
||||
## 3. Delivery models: smb vs http/inline payloads
|
||||
|
||||
Every `ManifestEntry` carries a `payloadsource` (`plugins/geenforce/serializer.py`
|
||||
and `importer.py`):
|
||||
|
||||
| PayloadSource | Meaning | Manifest emission |
|
||||
|---------------|---------|-------------------|
|
||||
| `smb` (default) | Entry installs from the share exactly as v2 does; the entry's `Installer`/`Script`/`Source` is a share-relative path. | Nothing emitted - share manifests round-trip byte-identical, parity preserved. |
|
||||
| `http` | Payload lives in the server's content-addressed blob store (`instance/geenforce/payloads/<sha256>`, registry row `ManifestBlob`). For big files (MSIs, EXEs). Upload via `flask geenforce add-payload <file>` or `service.store_blob`. | `PayloadSource`, `PayloadSha256`, `PayloadRef` keys on the entry. |
|
||||
| `inline` | Payload bytes live IN the DB (`ManifestPayload`, <= 1 MB) - small scripts and configs. Attach via `service.store_inline_payload(entry, filename, contenttype, rawbytes)` or `POST /api/geenforce/entries/<id>/payload`. | Same three keys. |
|
||||
|
||||
Both `http` and `inline` are served from the same client URL:
|
||||
`GET /api/geenforce/payload/<sha256>` (blob store checked first, then inline).
|
||||
The sha256 IS the integrity contract - the client re-hashes after download.
|
||||
|
||||
### How the client stages payloads (Resolve-ShopdbPayloads)
|
||||
|
||||
`Resolve-ShopdbPayloads` in `plugins/geenforce/client/ShopdbEnforceClient.psm1`
|
||||
is the bridge that lets the UNCHANGED engine install share-less:
|
||||
|
||||
1. For each entry with `PayloadSha256` and `PayloadSource` http/inline, call
|
||||
`Get-ShopdbPayload`: download to
|
||||
`C:\ProgramData\ShopDB\geenforce\payloads\<sha><ext>` (ext from
|
||||
`PayloadRef`), verify the sha256, keep it as a content-addressed
|
||||
last-known-good cache (a cache hit only counts if the bytes still hash
|
||||
right).
|
||||
2. Rewrite the entry's path field to the LEAF filename of the staged file
|
||||
(`Split-Path -Leaf`) - NOT the absolute path. Field by Type:
|
||||
`Installer` for MSI/EXE/CMD/BAT/INF, `Script` for PS1, `Source` for File.
|
||||
3. Write a sibling `<scope>.resolved.json` manifest and return its path (or
|
||||
the original path if nothing needed resolving). A payload that cannot be
|
||||
fetched/verified THROWS - the runner's fail-safe catch decides what happens.
|
||||
|
||||
The runner (`Invoke-ShopdbEnforce.ps1`) then sets the engine's
|
||||
`-InstallerRoot` to that same payloads directory, so the engine's
|
||||
`Join-Path $InstallerRoot <leaf>` resolves to the staged file. `smb` entries in
|
||||
a mixed manifest are left untouched and still resolve against the share (a PC
|
||||
that has it).
|
||||
|
||||
---
|
||||
|
||||
## 4. Authoring a scope
|
||||
|
||||
Two authoring paths, both ending in `service.replace_scope_draft(scopename,
|
||||
phase, manifest_dict)` (idempotent draft rebuild - published versions are never
|
||||
touched by a re-import):
|
||||
|
||||
### A. import-share: adopt an existing SMB manifest
|
||||
|
||||
```
|
||||
flask geenforce import-share --shareroot <path> [--scope <name>] [--preinstall <path>]
|
||||
flask geenforce publish <scopename> [--phase runtime] [--notes "..."]
|
||||
```
|
||||
|
||||
`importer.discover_share` walks the share root and ingests
|
||||
`common/manifest.json`, `display/manifest.json`, and every
|
||||
`gea-shopfloor-*/manifest.json` (skipping `.bak` variants). Entries come in as
|
||||
`smb` payloads. Run `flask geenforce parity --shareroot <path>` first (Gate A):
|
||||
proves import+re-export is behaviorally lossless before anything ships.
|
||||
|
||||
### B. authoring in code: seed_display_scope as the template
|
||||
|
||||
`plugins/geenforce/seed_display_scope.py` is the reference for a scope that
|
||||
never existed on the share. Pattern:
|
||||
|
||||
- Build the manifest dict in Python (`build_display_manifest()`): four
|
||||
`Type=Registry` drift-heal entries re-asserting the Edge kiosk relaunch
|
||||
policies from imaging (`09-Setup-Display.ps1`), one inline PS1 dispatcher,
|
||||
one inline PS1 always-on script. Registry heals use
|
||||
`DetectionMethod=ValueMatches` against the same path/name they write, so
|
||||
drift self-heals; the PS1s use `DetectionMethod=Always` and are idempotent.
|
||||
- The dispatcher (`Invoke-DisplayKioskDispatch.ps1`, generated by
|
||||
`build_dispatcher_script()`) reads `C:\Enrollment\display-type.txt`, maps
|
||||
the subtype through the data-driven `DISPLAY_TYPE_TARGETS` table
|
||||
(Dashboard -> `/shopfloor`, Lobby -> `/tv`, 3DPrintRoom -> `/parts-kiosk`),
|
||||
and writes an all-users Startup shortcut (`ShopDB Kiosk.lnk`) launching Edge
|
||||
`--kiosk` fullscreen at `{BaseUrl}{route}`. It does NOT Start-Process Edge
|
||||
(see gotchas). Base URL comes from HKLM `BaseUrl`, falling back to the WJ
|
||||
host.
|
||||
- `seed_display_scope(publish=False)`: `replace_scope_draft`, flush (entries
|
||||
need entryids), then `service.store_inline_payload(...)` for each script
|
||||
entry (sets `payloadsource='inline'`, `payloadsha256`, `payloadref`),
|
||||
optionally `service.publish_scope(...)`, commit. Draft rebuild is
|
||||
idempotent; publish always creates a NEW version.
|
||||
|
||||
Run it on the server:
|
||||
|
||||
```
|
||||
cd C:\inetpub\wwwroot\shopdb
|
||||
$env:FLASK_APP = 'shopdb'
|
||||
'from plugins.geenforce.seed_display_scope import seed_display_scope; print(seed_display_scope(publish=True))' | venv\Scripts\python -m flask shell
|
||||
```
|
||||
|
||||
Expected: `{scopeid, entrycount: 6, entrytypes: [Registry x4, PS1, PS1],
|
||||
dispatchersha256, alwaysonsha256, publishedversion: N}`.
|
||||
|
||||
### Publishing
|
||||
|
||||
`service.publish_scope` freezes the draft (rendered by
|
||||
`serializer.scope_to_json`) into an immutable `ManifestPublishedVersion` and
|
||||
flips `iscurrent`. Clients only ever see published versions.
|
||||
`rollback_scope` re-currents an older version. Also available over the API
|
||||
(`POST /scopes/<id>/publish`, permission `geenforce.publish`) and the
|
||||
GE-Enforce UI.
|
||||
|
||||
### Attaching payloads
|
||||
|
||||
- Inline (<= 1 MB): `service.store_inline_payload` in code, or
|
||||
`POST /api/geenforce/entries/<entryid>/payload` (multipart file).
|
||||
- Blob (`http`): `flask geenforce add-payload <filepath>` prints the sha256;
|
||||
set `PayloadSource=http` + `PayloadSha256` (+ `PayloadRef` for the
|
||||
filename/extension) on the entry.
|
||||
|
||||
Publish AFTER attaching - the published JSON is what carries the
|
||||
`PayloadSha256` the client fetches, and blob access for resource-bound tokens
|
||||
is checked against the CURRENT published manifest.
|
||||
|
||||
---
|
||||
|
||||
## 5. The on-PC client and engine
|
||||
|
||||
### Config: HKLM:\SOFTWARE\GE\ShopDB
|
||||
|
||||
| Value | Used by | Notes |
|
||||
|-------|---------|-------|
|
||||
| `BaseUrl` | enforce client + kiosk dispatcher | e.g. `https://tsgwp00525.wjs.geaerospace.net/shopdb`. Required. |
|
||||
| `ApiToken` | enforce client | `geenforce.fetch` (+ report) PAT. OPTIONAL - a token-less client relies on the IP allowlist (`Get-ShopdbConfig` treats BaseUrl-only as valid). |
|
||||
| `CollectorKey` | `Report-AssetToShopDB.ps1` | `collector.ingest` PAT. REQUIRED for asset reporting (allowlist does not cover the collector). |
|
||||
|
||||
The key's ACL is restricted to SYSTEM + Administrators (the bootstrap does
|
||||
this) so the kiosk auto-login user cannot read the PATs.
|
||||
|
||||
### The pieces on disk (kiosk layout, `C:\ProgramData\GE-Enforce`)
|
||||
|
||||
- `Invoke-ShopdbEnforce.ps1` - the runner
|
||||
- `ShopdbEnforceClient.psm1` - the client module
|
||||
- `lib\Install-FromManifest.ps1` - the engine (>= 2.6)
|
||||
- `Report-AssetToShopDB.ps1` - the asset collector
|
||||
- Cache: `C:\ProgramData\ShopDB\geenforce\` (`<scope>.json`, `.etag`,
|
||||
`.version`, `payloads\`), logs `C:\Logs\Shopfloor\`
|
||||
|
||||
### Scheduled tasks (SYSTEM, RunLevel Highest)
|
||||
|
||||
| Task | Runs | Interval |
|
||||
|------|------|----------|
|
||||
| `ShopDB GE-Enforce` | `Invoke-ShopdbEnforce.ps1 -Scope <scope> -EnginePath <engine> -BaseUrl <url>` | AtStartup + every 15 min |
|
||||
| `ShopDB Asset Report` | `Report-AssetToShopDB.ps1` | AtStartup + every 60 min |
|
||||
|
||||
Tokens are NOT in the task arguments (visible in task XML) - the scripts read
|
||||
them from HKLM.
|
||||
|
||||
### The runner flow (Invoke-ShopdbEnforce.ps1)
|
||||
|
||||
1. `Get-ShopdbConfig` (params override registry). No BaseUrl -> exit 0, retry
|
||||
next cycle.
|
||||
2. `Sync-ShopdbManifest -Scope <scope>`: ETag-conditional GET; 200 validates
|
||||
the JSON before overwriting the cache (a proxy error page served as 200
|
||||
must not clobber last-known-good); 304 or any network failure -> cached
|
||||
copy. Nothing at all -> Windows event log entry (source `ShopdbEnforce`,
|
||||
id 1001) plus a best-effort failure report so it is visible server-side,
|
||||
then exit 0.
|
||||
3. `-ShadowMode` (with `-ShareManifestPath`): `Compare-ShopdbShadow` logs
|
||||
name/order diffs, engine runs against the SHARE (zero behavior change).
|
||||
This is the first step of every cutover.
|
||||
4. Cutover mode: optional `-IncludeCommon` merges the fleet `common` scope
|
||||
(`Merge-ShopdbManifests`: common's unique entries first, pctype wins on
|
||||
Name conflict). OFF by default - a scope is enforced ALONE and displays are
|
||||
self-sufficient. Then `Resolve-ShopdbPayloads` stages http/inline payloads
|
||||
(section 3).
|
||||
5. Engine call (the integration point):
|
||||
`& $EnginePath -ManifestPath <resolved> -PCType $Scope -InstallerRoot <payloads dir> -LogFile <log>`.
|
||||
6. `ConvertTo-ShopdbSummary` normalizes whatever came back (summary object,
|
||||
array of emitted objects, bare int, $null) into
|
||||
`@{Installed;Skipped;Failed;Filtered;Results;EnforcerVersion}`, then
|
||||
`New-ShopdbReport` maps to the lowercase wire contract and
|
||||
`Send-ShopdbReport` POSTs it. All best-effort; the whole script exits 0 no
|
||||
matter what (fail-safe: a broken web app never breaks a PC).
|
||||
|
||||
### The engine contract (Install-FromManifest.ps1, lib 2.6)
|
||||
|
||||
Mandatory params: `-ManifestPath`, `-InstallerRoot`, `-LogFile`; optional
|
||||
`-PCType`, `-PCSubType`. Entry Types: MSI, EXE, CMD/BAT, PS1, INF, File,
|
||||
Registry. Detection: Registry, File, FileVersion, Hash, MarkerFile,
|
||||
ValueMatches, pnputil, Always. Filters: PCTypes (with old/new-name alias
|
||||
groups), TargetHostnames, TargetMachineNumbers, `_CmmVersion`. Exit 0/1/2
|
||||
unchanged for the SMB path; NEW in the API cutover: the engine ends with
|
||||
`Write-Output` of a summary pscustomobject
|
||||
(`Installed/Skipped/Failed/Filtered/EnforcerVersion/Results`), which is the
|
||||
only thing on the success stream (logs go via Write-Host), so
|
||||
`& $EnginePath ...` captures it cleanly.
|
||||
|
||||
`SelfHealed` on a result means a REAL drift correction (a detected-missing
|
||||
entry that got reinstalled). `Always`/no-detection entries install every cycle
|
||||
by design and are not flagged, so the server-side status derivation
|
||||
(`service.record_enforcement_report`: failed > selfhealed > ok) stays honest.
|
||||
|
||||
### The display dispatcher: server-resolved role, file fallback
|
||||
|
||||
For kiosks, per-subtype behavior does not fork the scope: ONE scope
|
||||
(`gea-shopfloor-display`), one inline dispatcher entry (built by
|
||||
`plugins/geenforce/seed_display_scope.py`) that resolves what the display should
|
||||
show at enforce time, in two steps:
|
||||
|
||||
1. **Server (authoritative):** `GET
|
||||
$KioskBaseUrl/api/dashboarddefaults/display-role?fqdn=<own-fqdn>`. This is a
|
||||
PUBLIC endpoint (no token). The server matches the FQDN against the
|
||||
`dashboarddefaults` table (IP fallback) and returns `{role, path,
|
||||
businessunitid, businessunit}`. Roles: `dashboard`, `lobby`, `partskiosk`.
|
||||
Changing a display's job is now a server-side edit; no touch on the PC.
|
||||
2. **Fallback (offline, or unmapped):** the local
|
||||
`C:\Enrollment\display-type.txt` value against the `DISPLAY_TYPE_TARGETS` map
|
||||
baked into the script. If neither resolves, the dispatcher logs and
|
||||
configures nothing.
|
||||
|
||||
The FQDN is built as `F<BIOS serial>.<domain>` (GE device naming); the domain
|
||||
comes from HKLM `DisplayFqdnDomain` or the built-in default
|
||||
(`device.geaerospace.net`). `DetectionMethod = Always`, but the script is
|
||||
idempotent: it rewrites the all-users Startup shortcut (never Start-Process -
|
||||
see gotchas) only when the resolved target changed.
|
||||
|
||||
### Legacy autostart self-heal
|
||||
|
||||
The old GE Aerospace Dashboard / Lobby Display Inno installers planted three
|
||||
autostarts each: a Public-Desktop `.lnk`, an all-users Startup `.lnk`, and an
|
||||
`HKLM ...\CurrentVersion\Run` value, all launching Edge at now-dead URLs
|
||||
(`/shopfloor-dashboard/`, `/tv-dashboard/`) which 404 to a white screen. Because
|
||||
those installers were 32-bit, the Run value was WOW64-redirected into
|
||||
`HKLM\SOFTWARE\Wow6432Node\...\Run`, invisible to 64-bit tooling - the reason it
|
||||
survived earlier cleanup. The dispatcher (`build_dispatcher_script` in
|
||||
`seed_display_scope.py`) now sweeps, every enforce cycle: both the native and
|
||||
Wow6432Node registry views, every loaded user hive (HKU), Run + RunOnce +
|
||||
Policies\Explorer\Run, matching by the legacy value names AND by any value
|
||||
pointing at the old URLs; plus every per-user and common Startup folder; then
|
||||
kills any running old-URL Edge. A read-only locator,
|
||||
`pxe-images/github/find-legacy-kiosk-autostart.ps1`, hunts all these locations
|
||||
(and Edge startup-URL policy, scheduled tasks, Assigned Access) when a straggler
|
||||
persists.
|
||||
|
||||
The kiosk shortcut is a direct Edge shortcut (no launcher/VBS); the fix ships by
|
||||
re-publishing the code-authored `gea-shopfloor-display` scope
|
||||
(`seed_display_scope(publish=True)`), not an import-share.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bootstrap for share-less PCs
|
||||
|
||||
A share-less PC cannot pull its first files from the share, so the bootstrap
|
||||
itself is downloadable from the web app.
|
||||
|
||||
`Install-ShopdbKiosk.ps1` (source of truth:
|
||||
the imaging share (`shopdb-migration/kiosk-installer/`)) is hosted at
|
||||
`C:\inetpub\wwwroot\shopdb\installers\kiosk\` and downloadable at
|
||||
`{BaseUrl}/installers/kiosk/Install-ShopdbKiosk.ps1`. Run elevated on the PC:
|
||||
|
||||
```
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
$u = 'https://tsgwp00525.wjs.geaerospace.net/shopdb/installers/kiosk/Install-ShopdbKiosk.ps1'
|
||||
Invoke-RestMethod $u -OutFile "$env:TEMP\Install-ShopdbKiosk.ps1"
|
||||
& "$env:TEMP\Install-ShopdbKiosk.ps1" -DisplayType Lobby -CollectorKey 'shopdb_pat_...'
|
||||
# add -ShopdbToken 'shopdb_pat_...' only if the subnet is NOT allowlisted
|
||||
```
|
||||
|
||||
What it does (idempotent; re-running is also the manual update path):
|
||||
|
||||
1. Writes `C:\Enrollment\display-type.txt` (the subtype) and
|
||||
`C:\Enrollment\pc-type.txt` (the scope, default `gea-shopfloor-display`).
|
||||
2. Writes HKLM `BaseUrl` [+ `ApiToken`] + `CollectorKey`, then locks the key
|
||||
ACL to SYSTEM + Administrators.
|
||||
3. Downloads runner + module + engine + collector from
|
||||
`{BaseUrl}/installers/kiosk/` over HTTPS (TLS 1.2 forced).
|
||||
4. Registers the two SYSTEM tasks (section 5).
|
||||
5. Starts both once so the PC is live immediately.
|
||||
|
||||
IIS prerequisite: the `installers\kiosk` web.config MUST carry
|
||||
`<staticContent>` MIME maps for `.ps1`/`.psm1` (`text/plain`) or IIS 404.3s
|
||||
the downloads (see gotchas).
|
||||
|
||||
Delivery options for the bootstrap itself:
|
||||
|
||||
- Imaging-baked: the display image runs it (or lays down the same state) at
|
||||
imaging time - see `project-display-self-contained`.
|
||||
- Installer push: Intune/hand-run the one-liner above on an already-deployed
|
||||
PC. This is how the pilot kiosks were done
|
||||
(`shopdb-migration/run-on-kiosk-F.txt`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Asset reporting via the collector
|
||||
|
||||
`Report-AssetToShopDB.ps1` (in the kiosk bundle; also deployed in the SMB
|
||||
`common\` scope for the share fleet) POSTs the PC's identity to:
|
||||
|
||||
```
|
||||
POST {BaseUrl}/api/collector/computers
|
||||
X-API-Key: <collector.ingest PAT or COLLECTOR_API_KEY env key>
|
||||
```
|
||||
|
||||
Auth (`shopdb/core/api/collector.py`, `_check_collector_auth`): a managed
|
||||
token scoped `collector.ingest` (Bearer or X-API-Key) OR the
|
||||
`COLLECTOR_API_KEY`/`COLLECTOR_API_KEY_COMPUTERS` env key. The GE-Enforce IP
|
||||
allowlist does NOT apply here - the collector always needs a key, read from
|
||||
HKLM `CollectorKey` (or the manifest entry's `Args -ApiKey`).
|
||||
|
||||
Schema (`plugins/computers/plugin.py`, `get_collector_schema`): identity field
|
||||
`hostname` (required); optional `machinenumber`, `pctype`, `pcsubtype`,
|
||||
`serialnumber`, `loggedinuser`, `lastboottime`, `lastcheckin`, `ipaddress`,
|
||||
`vendorname`, `modelnumber`, `osname`, `installedsoftware`, `defaultprinter`,
|
||||
`printers`. All lowercase concatenated (the project naming convention).
|
||||
|
||||
`apply_collector_payload` upserts idempotently by hostname (falls back to
|
||||
`Asset.assetnumber`), creates the Asset+Computer when missing, maps
|
||||
`machinenumber` -> `Asset.assetnumber` (imaging placeholder `9999` skipped both
|
||||
client- and server-side), `pctype` -> ComputerType via the settings mapping,
|
||||
and creates Vendor/Model/OS rows as needed. Fields not posted are not touched -
|
||||
a partial read never blanks a good value, so the script only includes fields it
|
||||
actually resolved.
|
||||
|
||||
Client details worth keeping: machine number resolution order is eDNC registry
|
||||
`MachineNo` (WOW6432Node then native) -> `C:\Enrollment\cmm\cmmid.txt` ->
|
||||
`C:\Enrollment\machine-number.txt`; the reported `ipaddress` is filtered to
|
||||
the corp ranges (same two CIDRs as the allowlist) so a machine-LAN controller
|
||||
NIC never lands in shopdb.
|
||||
|
||||
---
|
||||
|
||||
## 8. HARD-WON GOTCHAS
|
||||
|
||||
Read this section before touching ANY of the moving parts. Every bullet cost
|
||||
real debugging time. Format: symptom -> cause -> fix.
|
||||
|
||||
- **PS crash "property 'X' cannot be found" under Set-StrictMode** ->
|
||||
the module runs `Set-StrictMode -Version Latest`, and engine
|
||||
results/summaries arrive as EITHER hashtables or PSCustomObjects with
|
||||
varying key casing; direct `$obj.Key` access on an absent key throws ->
|
||||
route every dynamic property read through `Get-ShopdbProperty` (handles
|
||||
both shapes, case-insensitive, returns $null when absent). Never dot into
|
||||
parsed JSON or engine output directly.
|
||||
- **Register-ScheduledTask rejects the repeating trigger** -> passing
|
||||
`-RepetitionDuration [TimeSpan]::MaxValue` serializes to an out-of-range
|
||||
Duration the Task Scheduler XML schema rejects -> use
|
||||
`-RepetitionInterval` ALONE; it defaults to indefinite repetition
|
||||
(verified Win11 / PS 5.1). See `Register-SystemTask` in
|
||||
`Install-ShopdbKiosk.ps1`.
|
||||
- **Engine exits 2 / "InstallerRoot not found"** -> `-InstallerRoot` and
|
||||
`-LogFile` are MANDATORY engine params and InstallerRoot must EXIST ->
|
||||
the runner always passes both and pre-creates the payloads dir before the
|
||||
engine call. Any new caller must do the same.
|
||||
- **http payload "not found: C:\...\C:\..." (path doubling)** -> the engine
|
||||
resolves entry paths as `Join-Path $InstallerRoot <field>`; writing the
|
||||
staged payload's ABSOLUTE path into the entry made the engine double it ->
|
||||
`Resolve-ShopdbPayloads` writes the LEAF filename only, and the runner sets
|
||||
`-InstallerRoot` to the payloads cache dir. Keep those two in lockstep.
|
||||
- **Manifest fetch "works" but parsing fails / cache garbage** -> if the
|
||||
manifest is served with a non-JSON content type, PowerShell 5.1
|
||||
`Invoke-WebRequest` `.Content` comes back as a `byte[]` instead of a string
|
||||
-> the server route returns `mimetype='application/json'` (see
|
||||
`get_manifest`); any mock server or proxy in the chain must do the same.
|
||||
The client also validates JSON before overwriting last-known-good.
|
||||
- **Bootstrap download 404 (HTTP 404.3)** -> IIS refuses to serve unknown
|
||||
static extensions; `.ps1`/`.psm1` have no default MIME map -> add
|
||||
`<staticContent><mimeMap fileExtension=".ps1" mimeType="text/plain" />`
|
||||
(and `.psm1`) in the `installers\kiosk` web.config, with `<remove>` first
|
||||
if inherited.
|
||||
- **IP allowlist spoofable / mysteriously not matching** -> raw
|
||||
`X-Forwarded-For` is attacker-controlled (proxies append; first hop is the
|
||||
caller's to write) -> `_ip_allowlisted` uses `request.remote_addr` via
|
||||
`_trusted_client_ip`, which is only correct because the IIS URL-Rewrite
|
||||
rule OVERWRITES X-Forwarded-For with REMOTE_ADDR and waitress trusts only
|
||||
127.0.0.1 as proxy. The rule is a hard dependency: never remove it, and
|
||||
verify the spoof is closed after server changes
|
||||
(`curl -H "X-Forwarded-For: 10.134.48.10"` from a non-allowlisted host
|
||||
must get 401).
|
||||
- **Kiosk browser never appears though the dispatcher "ran fine"** -> the
|
||||
enforce task runs as SYSTEM in session 0, which has no interactive
|
||||
desktop; `Start-Process msedge.exe` opens INVISIBLY there -> write an
|
||||
all-users Startup shortcut
|
||||
(`C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup\ShopDB Kiosk.lnk`)
|
||||
and let the auto-login user launch it in a visible session. This is why
|
||||
the dispatcher is shortcut-based.
|
||||
- **TLS/transport errors on older images ("could not create SSL/TLS secure
|
||||
channel")** -> Windows PowerShell 5.1 does not reliably negotiate TLS 1.2
|
||||
by default -> every network helper calls `Set-ShopdbTls`
|
||||
(`[Net.ServicePointManager]::SecurityProtocol = Tls12`) first; the
|
||||
bootstrap and collector force it too. Any new script that touches the API
|
||||
must do the same.
|
||||
- **Secrets readable by the kiosk user / visible in Task Scheduler** ->
|
||||
default HKLM\SOFTWARE ACL grants BUILTIN\Users read, and task arguments
|
||||
are world-readable in the task XML -> restrict the
|
||||
`HKLM:\SOFTWARE\GE\ShopDB` key ACL to SYSTEM + Administrators (the
|
||||
bootstrap does), and NEVER put a token in a task's `-Argument` string -
|
||||
scripts read `ApiToken`/`CollectorKey` from HKLM at run time.
|
||||
- **Enforcement Reports show 0/0/0 with no per-entry detail** -> the engine
|
||||
historically returned nothing on the success stream, so the runner had no
|
||||
counts to report -> the 2.6 engine emits the summary object as its ONLY
|
||||
`Write-Output` (all logging is Write-Host), and
|
||||
`ConvertTo-ShopdbSummary` tolerates non-compliant engines by zero-filling.
|
||||
If reports go 0/0/0 again, the engine on that PC is pre-summary - update
|
||||
it.
|
||||
- **Two kiosk browsers fighting / stale kiosk launch after retarget** -> old
|
||||
installs left their own Startup launchers behind, in several flavors ->
|
||||
the dispatcher's sweep must match ALL of: single- AND double-dash `-kiosk`
|
||||
arguments (the regex `-kiosk` matches both), shortcuts whose args carry
|
||||
shopdb URLs (`tsgwp00525`, `/shopdb/`, the dead `shopfloor-dashboard`
|
||||
route), the imaging installers' `GE Aerospace Dashboard*` / `GE Aerospace
|
||||
Lobby*` shortcut names, and `.url` files pointing at the kiosk routes.
|
||||
Extend the sweep whenever a new launcher naming appears.
|
||||
|
||||
---
|
||||
|
||||
## 9. How this was verified
|
||||
|
||||
Two complementary verification passes; keep BOTH for future cohorts because
|
||||
they catch different bug classes:
|
||||
|
||||
- **Code review** finds logic bugs: the XFF spoof hole, the StrictMode
|
||||
absent-key crashes, the status-derivation trap (`installed>0` is not
|
||||
self-heal), the report casing mismatch.
|
||||
- **VM smoke test** finds integration/OS-version bugs: the
|
||||
RepetitionDuration serialization rejection, the .ps1 MIME 404, session-0
|
||||
invisibility, TLS negotiation, byte[] vs string response content - none of
|
||||
which a read of the code surfaces.
|
||||
|
||||
The VM rig:
|
||||
|
||||
- The win11 virt-manager VM (see `project-win11-vm` /
|
||||
`reference-vm-qga-as-system` memory), driven by
|
||||
the imaging share (`ednc-bins/qga-run.py`) - qemu guest agent
|
||||
`guest-exec`, which runs PowerShell AS SYSTEM. That matters: the scheduled
|
||||
tasks run as SYSTEM, so testing as SYSTEM reproduced the session-0 and
|
||||
profile-less behaviors an interactive test would have masked.
|
||||
- A mock HTTP server on the host exposing `/api/geenforce/manifest` (served
|
||||
`application/json`), `/api/geenforce/payload/<sha>`, and capturing the
|
||||
`/api/geenforce/report` POST body. This exercised the full client loop -
|
||||
ETag/304, cache fallback (kill the server mid-test), payload hash
|
||||
verification, resolved-manifest rewrite, engine run, summary -> report
|
||||
mapping - without touching prod.
|
||||
- Full pass = bootstrap installer run end to end in the VM, then assert: both
|
||||
tasks registered, HKLM written + ACLed, manifest cached, payloads staged by
|
||||
sha, Startup shortcut written, report captured with real counts.
|
||||
|
||||
---
|
||||
|
||||
## 10. Deploy
|
||||
|
||||
TWO independent channels. Confusing them is the classic mistake: the client/
|
||||
engine/collector/bootstrap are pxe-images SHARE artifacts, NOT deployed by the
|
||||
git pipeline.
|
||||
|
||||
### Channel 1: backend + frontend (the git .cmd pipeline)
|
||||
|
||||
Prod is air-gapped from dev; code moves via a git bundle on the share
|
||||
(`\\172.16.9.9\pxe-images\github\shopdb-flask-pub.bundle`) and the .cmd
|
||||
scripts in the imaging share (`github/`), run on the work PC:
|
||||
|
||||
1. `pull-shopdb-bundle.cmd` - fetch the bundle into the local clone
|
||||
(ff-only).
|
||||
2. `update-shopdb-github.cmd` - push the clone to GitHub.
|
||||
3. `update-dev-server.cmd` - robocopy the clone to `X:` (dev `/ops` tree) +
|
||||
the `/ops`-base frontend dist. Validate on `/ops` FIRST.
|
||||
4. `update-prod-server.cmd` - robocopy to `Y:` (`C:\inetpub\wwwroot\shopdb`)
|
||||
+ the `/shopdb`-base dist (`frontend-dist-subpath-shopdb`). Both scripts
|
||||
carry instance guards (web.config MOUNT_PATH check) - do not bypass them.
|
||||
|
||||
Then RDP: `Restart-WebAppPool shopdbflask-prod`, and if migrations/deps
|
||||
changed: `flask db upgrade`, `flask plugin upgrade-all`, `flask seed
|
||||
permissions`, `flask seed settings`.
|
||||
|
||||
(The fast path used during the pilot - robocopy just the changed plugin files
|
||||
from `shopdb-migration\prod-patch-geenforce\` + pool restart, per
|
||||
`deploy-server-patch.txt` - works, but the same commits must ALSO go through
|
||||
the bundle pipeline or prod drifts from git.)
|
||||
|
||||
### Channel 2: client + engine + collector + bootstrap (the kiosk bundle)
|
||||
|
||||
These live at the imaging share (`shopdb-migration/kiosk-installer/`) and
|
||||
deploy by robocopy from the work PC (Z: = share, Y: = prod app dir):
|
||||
|
||||
```
|
||||
robocopy Z:\shopdb-migration\kiosk-installer Y:\installers\kiosk /E
|
||||
```
|
||||
|
||||
That directory (bundle contents: `Install-ShopdbKiosk.ps1`,
|
||||
`Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`,
|
||||
`lib\Install-FromManifest.ps1`, `Report-AssetToShopDB.ps1`, `web.config` with
|
||||
the MIME maps) IS the distribution point - PCs download from
|
||||
`{BaseUrl}/installers/kiosk/`. Reference copies of the client kit also live in
|
||||
the repo at `plugins/geenforce/client/` and the engine's source of truth is
|
||||
`the imaging share, common/lib/Install-FromManifest.ps1`; when the engine
|
||||
or client changes, update the kiosk bundle copy too (nothing syncs it
|
||||
automatically). PCs pick up new bytes by re-running the bootstrap one-liner.
|
||||
|
||||
### Server prerequisites (once per site, all three or token-less clients 401)
|
||||
|
||||
1. Publish the scope(s) - `seed_display_scope(publish=True)` or
|
||||
`flask geenforce publish <scope>`.
|
||||
2. Seed `geenforce_allowed_cidrs` = `10.134.48.0/23,10.48.249.0/26`
|
||||
(Settings rail > GE-Enforce Settings, or SQL upsert into `settings`).
|
||||
3. Mint tokens (Settings > API Tokens, Restrict permissions ON):
|
||||
`collector.ingest` (required, the kiosk `-CollectorKey`) and
|
||||
`geenforce.fetch` (fallback for non-allowlisted subnets; resource-bind it
|
||||
to the scope).
|
||||
4. Keep the IIS URL-Rewrite XFF-overwrite rule enabled (section 2).
|
||||
|
||||
---
|
||||
|
||||
## 11. PLAYBOOK: extending to a new pc-type
|
||||
|
||||
Checklist for cutting any of the remaining scopes (`gea-shopfloor-cmm`,
|
||||
`-collections`, `-keyence`, `-genspect`, `-heattreat`, `-partmarker`,
|
||||
`-nocollections`, `common`) over to the API.
|
||||
|
||||
1. **Decide the delivery model.** Does this pc-type keep SMB access? If yes,
|
||||
the cheap cutover is manifest-over-API + payloads-still-smb (entries stay
|
||||
`smb`, nothing to upload, the engine resolves share paths as today). Only
|
||||
a genuinely share-less PC needs http/inline payload conversion. Note the
|
||||
payload endpoint's 512 MB default ceiling
|
||||
(`GEENFORCE_PAYLOAD_MAX_BYTES`) before promising huge installers over
|
||||
HTTPS.
|
||||
2. **Get the scope into shopdb.** Existing share manifest:
|
||||
`flask geenforce parity` then `flask geenforce import-share --scope
|
||||
<name>`. New/reworked scope: author in code following
|
||||
`seed_display_scope.py` (registry heals with ValueMatches detection,
|
||||
idempotent Always PS1s, data-driven tables for anything per-subtype).
|
||||
3. **Convert payloads (share-less only).** Small scripts/configs ->
|
||||
`store_inline_payload` / the entry payload upload endpoint. Installers ->
|
||||
`flask geenforce add-payload <file>`, set
|
||||
`PayloadSource=http` + `PayloadSha256` + `PayloadRef` on the entry.
|
||||
Remember: `PayloadRef`'s extension decides the staged filename's
|
||||
extension.
|
||||
4. **Publish.** New version every publish; clients converge within one
|
||||
enforce cycle. Verify with
|
||||
`curl "{BaseUrl}/api/geenforce/manifest?pctype=<scope>"` from an
|
||||
allowlisted host.
|
||||
5. **Auth for the PCs.** Subnet already inside
|
||||
`10.134.48.0/23,10.48.249.0/26` -> token-less, nothing to do. New subnet
|
||||
-> add its CIDR to `geenforce_allowed_cidrs` (Settings rail validates).
|
||||
Not network-trustable -> mint a `geenforce.fetch` token resource-bound to
|
||||
the scope and deliver it to HKLM `ApiToken`.
|
||||
6. **Bootstrap the client.** Share-attached fleet: adapt the dispatcher /
|
||||
`Install-GEEnforce.ps1` path (the pilot flow in
|
||||
`shopdb-migration/kiosk-api-pilot.txt`: shadow first, then flip, then
|
||||
DISABLE the old share enforce task so the two do not fight). Share-less:
|
||||
the `Install-ShopdbKiosk.ps1` pattern - generalize `-Scope` and skip the
|
||||
display-only pieces. Decide `-IncludeCommon`: displays run without it;
|
||||
a non-display share-less PC that needs the fleet-wide common entries over
|
||||
HTTPS turns it on AND requires common's entries to be payload-converted
|
||||
first (an SMB-payload common entry will fail on a share-less PC).
|
||||
7. **Run SHADOW mode first** on one pilot PC
|
||||
(`-ShadowMode -ShareManifestPath <share manifest>`): fetch + compare +
|
||||
report with zero behavior change. Watch the shadow diff log lines and the
|
||||
Enforcement Reports row before flipping.
|
||||
8. **Re-read section 8 (gotchas).** Especially: LEAF filenames, StrictMode
|
||||
property access, SYSTEM/session-0, task trigger serialization, MIME maps
|
||||
if you host new downloadables.
|
||||
9. **Verify on the VM** (section 9) before the pilot PC: bootstrap +
|
||||
enforce cycle against a mock or the dev `/ops` instance, as SYSTEM via
|
||||
qga-run.py.
|
||||
10. **Pilot one PC, then the cohort.** Keep the rollback in your pocket:
|
||||
disable/remove the new task, re-enable the share task, remove HKLM
|
||||
`BaseUrl` - the share path is untouched by all of this.
|
||||
11. **pc-type mapping.** Make sure the collector's pctype mapping
|
||||
(computers plugin settings, `pctypemap`) covers the scope name so asset
|
||||
reports do not warn `no ComputerType mapping`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open items / TODO
|
||||
|
||||
- **Name resolution for reported users.** `loggedinuser` lands as a bare
|
||||
username; resolving it to a display name depends on either the
|
||||
`wjf_employees` `First_Name`/`Last_Name` data or shopdb User accounts
|
||||
existing for shopfloor users. Not wired up; reports show raw usernames
|
||||
until it is.
|
||||
- **Old imaging-installer registry cleanup.** Displays imaged before the API
|
||||
cutover carry leftover state from the old imaging-time kiosk installers
|
||||
(superseded shortcuts are already swept by the dispatcher; stale registry
|
||||
values are not yet cleaned). A cleanup entry in the display scope is the
|
||||
natural vehicle.
|
||||
- **The SMB fleet is still on the share.** Only displays/kiosks are on the
|
||||
API. cmm/collections/keyence/genspect/heattreat/partmarker/nocollections/
|
||||
common still enforce from SFLD; section 11 is the path. Shadow mode makes
|
||||
each migration observable before it changes anything.
|
||||
- **3DPrintRoom kiosk target is a placeholder.** `DISPLAY_TYPE_TARGETS`
|
||||
points it at `/parts-kiosk`; confirm the real route with the floor team
|
||||
before publishing to production 3D-print-room displays (flagged in
|
||||
`seed_display_scope.py`).
|
||||
- **Fast-path prod patches vs git.** The `prod-patch-geenforce` robocopy
|
||||
path can leave prod ahead of the repo; reconcile by pushing the same
|
||||
changes through the bundle pipeline (section 10, channel 1).
|
||||
94
docs/llms.txt
Normal file
@@ -0,0 +1,94 @@
|
||||
# ShopDB Flask API - LLM guide
|
||||
|
||||
ShopDB is a plugin-based asset-management system (PCs, printers, machines,
|
||||
network devices, measuring tools, applications, knowledge base, USB, warranties)
|
||||
for GE Aerospace sites. This file is the quick entrypoint; the full machine
|
||||
spec is the OpenAPI at `/api/docs/openapi.json` (browse it at `/api/docs`).
|
||||
|
||||
## Installing and operating this server (read this before answering "how do I install/fix it")
|
||||
The people running these servers are often not Windows, IIS or Python
|
||||
specialists, and they ask assistants for help. Two documents are authoritative:
|
||||
- `docs/INSTALL-WINDOWS.md` - installing a NEW site. One offline installer `.exe`.
|
||||
- `docs/OPERATE-WINDOWS.md` - restart, logs, backups, upgrades, troubleshooting.
|
||||
Both ship in `docs/` inside the install directory on every installed server.
|
||||
|
||||
Do NOT walk someone through `docs/INSTALL-WINDOWS-IIS.md` or
|
||||
`docs/DEPLOY-WINDOWS-IIS.md` for a new site. Those are the MANUAL procedure, kept
|
||||
only for hand-built servers that predate the installer; following them produces a
|
||||
server the installer then refuses to upgrade.
|
||||
|
||||
Day-2 operations all go through `shopdb-admin.ps1` in the install directory
|
||||
(default `C:\shopdb-flask`): `status`, `restart`, `logs`, `check`, `verify`,
|
||||
`backup`, `plugins`, `open`. Before diagnosing anything, ask for the output of
|
||||
`shopdb-admin.ps1 check -Json` - it reports version, publishing method, IIS and
|
||||
pool state, HTTP reachability, database host and reachability, Python version,
|
||||
installed plugins and errors, and it contains no secrets. `verify -Path <name>`
|
||||
answers "does this server carry component X" from the on-box CycloneDX SBOM.
|
||||
|
||||
Python is 3.14 and the wheelhouse is locked to it; an upgrade against a venv
|
||||
built by a different minor version is refused by design.
|
||||
|
||||
## Bulk-loading a site's data
|
||||
Two routes, and the right answer depends on what the site has:
|
||||
- SPREADSHEET, no developer (the common case): `flask csv templates --out <dir>`
|
||||
generates templates FROM THE LIVE SCHEMA, then `flask csv import --dir <dir>`
|
||||
checks and `--commit` applies. Foreign keys accept the NAME of the referenced
|
||||
row ('Bay 3'), not a numeric id, and resolve across files in one run. Dry run
|
||||
is the default; nothing is written unless every row passes; re-importing an
|
||||
edited file updates rather than duplicates. See `docs/CSV-IMPORT.md`.
|
||||
- A SOURCE DATABASE to script against: the HTTP import API, `docs/IMPORT-API.md`
|
||||
and `docs/IMPORT-ADOPTION.md`.
|
||||
Do NOT hand-write CSV templates - generate them. User accounts are deliberately
|
||||
not CSV-importable.
|
||||
|
||||
## Base URL
|
||||
Prod (West Jefferson): `https://tsgwp00525.wjs.geaerospace.net/shopdb`
|
||||
All API paths are under `/api` (e.g. `<base>/api/assets`). Dev: `http://localhost:5001`.
|
||||
|
||||
## Auth
|
||||
Three schemes:
|
||||
- **Bearer JWT** - most endpoints. Get one by logging in, or use a managed
|
||||
Personal Access Token (PAT). Send `Authorization: Bearer <token>`.
|
||||
- Login: `POST /api/auth/login` `{ "username": "...", "password": "..." }`
|
||||
-> `data.access_token`. Refresh: `POST /api/auth/refresh`.
|
||||
- PATs are minted in the UI (Settings > API Tokens); a *scoped* PAT is limited
|
||||
to named permissions and suspends the admin bypass.
|
||||
- **X-API-Key** - unattended/service endpoints (collector ingest, GE-Enforce
|
||||
fetch). Send `X-API-Key: <managed-token>`.
|
||||
- **Public** - some read endpoints (e.g. printer install-list, employee search,
|
||||
dashboards) need no auth.
|
||||
|
||||
Auth level per endpoint is in the OpenAPI `security` field: `bearerAuth`,
|
||||
`apiKeyAuth`, or none. Admin-only and permission-gated routes both use bearer.
|
||||
|
||||
## Response envelope
|
||||
JSON endpoints return `{ "status": "success", "data": <payload>, "meta": {...} }`.
|
||||
Errors: `{ "status": "error", "message": "...", "code": "..." }` with an HTTP 4xx/5xx.
|
||||
Lists include `meta.total` / pagination. A few feed endpoints (screensaver, some
|
||||
installer text formats) return raw text/JSON without the envelope - noted per route.
|
||||
|
||||
## Common recipes
|
||||
- Search everything: `GET /api/search?q=<term>` (multi-word = AND across words).
|
||||
- List assets on the map: `GET /api/assets/map`.
|
||||
- List a type: `GET /api/printers`, `/api/computers`, `/api/machines`,
|
||||
`/api/network`, `/api/measuringtools` (paginated: `?page=&perpage=`).
|
||||
- Get one: `GET /api/printers/<id>` etc.
|
||||
- Create (bearer): `POST /api/printers` `{assetnumber, windowsname, vendorid, ...}`.
|
||||
- Reports: `GET /api/reports` (list), `GET /api/reports/pc-relationships` (PC<->machine).
|
||||
- Printer installer data: `GET /api/printers/install-list` (public; add
|
||||
`?format=text` for a pipe-delimited variant); `GET /api/printers/pc-default?machine=<n>`.
|
||||
- Collector ingest (X-API-Key): `POST /api/collector/computers`.
|
||||
- GE-Enforce: `GET /api/geenforce/manifest?pctype=<scope>`,
|
||||
`GET /api/geenforce/payload/<sha256>`, `POST /api/geenforce/report`.
|
||||
- Import (admin PAT, preserves timestamps with `X-Import-Mode`): see docs/IMPORT-API.md.
|
||||
|
||||
## Conventions
|
||||
- DB-mirrored params/fields use lowercase concatenated names (no underscores):
|
||||
`locationid`, `vendorid`, `windowsname` - match them exactly.
|
||||
- IDs in paths are integers.
|
||||
- Plugin endpoints live under the plugin's prefix (`/api/<plugin>/...`).
|
||||
|
||||
## Full reference
|
||||
- Machine spec: `GET /api/docs/openapi.json` (OpenAPI 3.1, 362 operations).
|
||||
- Interactive: `GET /api/docs` (Redoc).
|
||||
- Human reference: `docs/API-REFERENCE.md`.
|
||||
6620
docs/openapi.json
Normal file
@@ -1,6 +1,6 @@
|
||||
# Proposal: GE-Enforce as a shopdb plugin
|
||||
|
||||
Status: DRAFT / planning only. Not accepted, not built.
|
||||
Status: ACCEPTED / built - see ADR-012 and plugins/geenforce/.
|
||||
Author: planning session 2026-07-12.
|
||||
|
||||
## 1. What this is
|
||||
|
||||
@@ -174,7 +174,7 @@ Labels (own print view, USBLabelBatch precedent):
|
||||
## 9. Manifest
|
||||
|
||||
name printedparts, version 0.1.0, api_prefix /api/printedparts,
|
||||
core_version ">=0.11.0,<1.0.0", dependencies ["employees"],
|
||||
core_version ">=0.12.0,<1.0.0", dependencies ["employees"],
|
||||
default_enabled false (site opts in - USB precedent).
|
||||
|
||||
## 10. Explicitly out of scope (v1)
|
||||
|
||||
4
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
# ADR-013 Phase 4 staged plugin frontends (generated by scripts/stage-frontend.mjs)
|
||||
src/.plugins-staged/
|
||||
src/router/routes.gen.js
|
||||
2
frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"axios": "^1.6.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -1756,7 +1757,6 @@
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"stage": "node ../scripts/stage-frontend.mjs",
|
||||
"predev": "npm run stage",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run stage",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"pretest": "npm run stage",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
@@ -16,6 +20,7 @@
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"axios": "^1.6.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
|
||||
@@ -120,6 +120,9 @@ export const computersApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/computers', { params })
|
||||
},
|
||||
displayKiosks() {
|
||||
return api.get('/computers/display-kiosks')
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/computers/${id}`)
|
||||
},
|
||||
@@ -315,6 +318,10 @@ export const printersApi = {
|
||||
dashboardSummary() {
|
||||
return api.get('/printers/dashboard/summary')
|
||||
},
|
||||
// Flat network-printer list (with mapx/mapy) for the installer map.
|
||||
installList() {
|
||||
return api.get('/printers/install-list')
|
||||
},
|
||||
drivers: {
|
||||
list(params = {}) {
|
||||
return api.get('/printers/drivers', { params })
|
||||
@@ -1126,3 +1133,61 @@ export const measuringtoolsApi = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3D printed parts (printedparts plugin)
|
||||
export const printedpartsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/printedparts/items', { params })
|
||||
},
|
||||
get(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/printedparts/items', data)
|
||||
},
|
||||
update(printeditemid, data) {
|
||||
return api.put(`/printedparts/items/${printeditemid}`, data)
|
||||
},
|
||||
remove(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}`)
|
||||
},
|
||||
restore(printeditemid) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/restore`)
|
||||
},
|
||||
uploadImage(printeditemid, file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post(`/printedparts/items/${printeditemid}/image`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
deleteImage(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}/image`)
|
||||
},
|
||||
restock(printeditemid, data) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/restock`, data)
|
||||
},
|
||||
adjust(printeditemid, data) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
|
||||
},
|
||||
kioskItem(itemcode) {
|
||||
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
|
||||
},
|
||||
kioskTake(data) {
|
||||
return api.post('/printedparts/kiosk/take', data)
|
||||
},
|
||||
listFiles(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}/files`)
|
||||
},
|
||||
uploadFile(printeditemid, file, note) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (note) formData.append('note', note)
|
||||
return api.post(`/printedparts/items/${printeditemid}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
removeFile(fileid) {
|
||||
return api.delete(`/printedparts/files/${fileid}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,18 +628,22 @@ input[type="radio"] {
|
||||
theme store always stamps it at startup) - a bare prefers-color-scheme
|
||||
query here leaks dark widget styles into light mode on dark-OS machines. */
|
||||
[data-theme="dark"] .form-control {
|
||||
background: var(--bg);
|
||||
/* background-COLOR, not the shorthand: the shorthand resets a select's
|
||||
background-repeat/position and the dropdown arrow tiles across the box. */
|
||||
background-color: var(--bg);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control:focus {
|
||||
background: var(--bg);
|
||||
background-color: var(--bg);
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] select.form-control {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-color: var(--bg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
}
|
||||
|
||||
[data-theme="dark"] select.form-control option {
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Add Relationship Modal -->
|
||||
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div v-if="showAddModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Add Relationship</h3>
|
||||
|
||||
@@ -1,145 +1,150 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="closeOnOverlay && close()">
|
||||
<div class="modal-container" :class="sizeClass">
|
||||
<div class="modal-header" v-if="title || $slots.header">
|
||||
<slot name="header">
|
||||
<h3>{{ title }}</h3>
|
||||
</slot>
|
||||
<button class="modal-close" @click="close" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" v-if="$slots.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
size: { type: String, default: 'medium' }, // small, medium, large, fullscreen
|
||||
closeOnOverlay: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
const sizeClass = computed(() => `modal-${props.size}`)
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Handle escape key
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleEscape(e) {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--bg-card-solid);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-medium {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
width: 900px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.modal-fullscreen {
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="closeOnOverlay && close()">
|
||||
<div class="modal-container" :class="sizeClass">
|
||||
<div class="modal-header" v-if="title || $slots.header">
|
||||
<slot name="header">
|
||||
<h3>{{ title }}</h3>
|
||||
</slot>
|
||||
<button class="modal-close" @click="close" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" v-if="$slots.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
size: { type: String, default: 'medium' }, // small, medium, large, fullscreen
|
||||
// Defaults to FALSE. Every current user of this component holds either a
|
||||
// form, a checkout, a stock adjustment or a map position being picked, and a
|
||||
// stray click on the backdrop threw all of it away without asking - which is
|
||||
// what operators complained about. A modal that genuinely wants dismissing
|
||||
// that way can still opt in with :close-on-overlay="true".
|
||||
closeOnOverlay: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
const sizeClass = computed(() => `modal-${props.size}`)
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Handle escape key
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleEscape(e) {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--bg-card-solid);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-medium {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
width: 900px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.modal-fullscreen {
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
131
frontend/src/components/PluginAssetPanels.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<!-- Generic renderer for ADR-010 get_asset_panels (Path A). A plugin declares
|
||||
a panel as JSON (title, endpoint, render mode, field map); this renders it
|
||||
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||
<template v-for="panel in panels" :key="panel.id">
|
||||
<div v-if="panelVisible(panel)" class="section-card">
|
||||
<h3 class="section-title">{{ panel.title }}</h3>
|
||||
|
||||
<!-- list: items each with a title, optional status badge, meta lines -->
|
||||
<div v-if="panel.render === 'list'" class="pap-list">
|
||||
<template v-if="rows(panel).length">
|
||||
<div v-for="(item, index) in rows(panel)" :key="index" class="pap-item">
|
||||
<div class="pap-item-top">
|
||||
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
|
||||
<span v-if="mapBadge(panel, item)" class="status-badge"
|
||||
:style="colorStyle(mapBadge(panel, item).color)">
|
||||
{{ mapBadge(panel, item).label }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="mapMeta(panel, item).length" class="pap-meta">
|
||||
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
|
||||
:class="{ mono: m.mono }">{{ m.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
{{ panel.manage.label || 'Manage' }}
|
||||
</router-link>
|
||||
</template>
|
||||
<div v-else class="pap-empty">
|
||||
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
{{ panel.manage.emptylabel || panel.manage.label || 'Add' }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- keyvalue: a label/value grid -->
|
||||
<div v-else-if="panel.render === 'keyvalue'" class="pap-kv">
|
||||
<div v-for="(field, fi) in keyvalueFields(panel)" :key="fi" class="pap-kv-row">
|
||||
<span class="pap-kv-label">{{ field.label }}</span>
|
||||
<span class="pap-kv-value" :class="{ mono: field.mono }">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- table: columns declared by the panel (or inferred from row keys) -->
|
||||
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
|
||||
<table class="pap-table">
|
||||
<thead>
|
||||
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, ri) in rows(panel)" :key="ri">
|
||||
<td v-for="col in tableColumns(panel)" :key="col.key">{{ cell(row, col) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- badge: a row of colored badges -->
|
||||
<div v-else-if="panel.render === 'badge'" class="pap-badges">
|
||||
<span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge"
|
||||
:style="colorStyle(b.color)">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import api from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
const props = defineProps({
|
||||
assetid: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const panels = ref([])
|
||||
|
||||
// Fetch the declared panels for this asset, then each panel's data endpoint.
|
||||
async function load() {
|
||||
panels.value = []
|
||||
if (!props.assetid) return
|
||||
let declared
|
||||
try {
|
||||
const response = await api.get('/pluginui/asset-panels', {
|
||||
params: { assetid: props.assetid },
|
||||
})
|
||||
declared = response.data.data || []
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
const withData = await Promise.all(
|
||||
declared.map(async (panel) => {
|
||||
try {
|
||||
const response = await api.get(toApiPath(panel.endpoint, props.assetid))
|
||||
return { ...panel, _data: response.data.data }
|
||||
} catch (err) {
|
||||
return { ...panel, _data: null }
|
||||
}
|
||||
})
|
||||
)
|
||||
panels.value = withData
|
||||
}
|
||||
|
||||
watch(() => props.assetid, load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pap-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.pap-item { padding: 0.6rem 0.75rem; background: var(--bg); border-radius: 6px; }
|
||||
.pap-item-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
||||
.pap-title { font-weight: 600; color: var(--text); }
|
||||
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
||||
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
|
||||
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.pap-manage { font-size: 0.82rem; }
|
||||
.pap-kv { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.pap-kv-row { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.pap-kv-label { color: var(--text-light); }
|
||||
.pap-kv-value { color: var(--text); font-weight: 500; }
|
||||
.pap-table-wrap { overflow-x: auto; }
|
||||
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
|
||||
.pap-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.mono { font-family: monospace; }
|
||||
.muted { color: var(--text-light); }
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@
|
||||
:key="t.machinetypeid"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-dot" :style="{ background: getTypeColor(t.machinetype) }"></span>
|
||||
<span class="legend-dot" :style="legendDotStyle(getTypeColor(t.machinetype))"></span>
|
||||
{{ t.machinetype }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@
|
||||
:key="subtypeId"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-dot" :style="{ background: color }"></span>
|
||||
<span class="legend-dot" :style="legendDotStyle(color)"></span>
|
||||
{{ subtypeNames[subtypeId] || `Type ${subtypeId}` }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -64,10 +64,19 @@
|
||||
:key="assetType"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-dot" :style="{ background: color }"></span>
|
||||
<span class="legend-dot" :style="legendDotStyle(color)"></span>
|
||||
{{ assetTypeLabels[assetType] || assetType }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- Plugin-contributed overlay legend entries (ADR-010 get_map_overlays) -->
|
||||
<span
|
||||
v-for="(entry, i) in overlayLegend"
|
||||
:key="`overlay-${i}`"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-dot legend-ring" :style="{ borderColor: entry.color }"></span>
|
||||
{{ entry.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="picker-controls" v-if="pickerMode">
|
||||
@@ -87,7 +96,9 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
||||
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
||||
import { assetTypeLabel } from '../utils/assetTypes'
|
||||
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
|
||||
import api from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
machines: { type: Array, default: () => [] },
|
||||
@@ -114,6 +125,13 @@ let pickerMarker = null
|
||||
let markerLayer = null
|
||||
let canvasRenderer = null
|
||||
|
||||
// ADR-010 map overlays (get_map_overlays): plugin-declared per-asset decoration
|
||||
// (ring/badge) drawn on top of markers, plus legend entries. overlayLayers are
|
||||
// the extra Leaflet layers, cleared and redrawn with the markers.
|
||||
const overlayDecorations = ref([]) // [{ style, byAsset: Map(assetid -> {color,label}) }]
|
||||
const overlayLegend = ref([]) // [{ label, color }] distinct entries
|
||||
let overlayLayers = []
|
||||
|
||||
const filters = ref({
|
||||
machinetype: '',
|
||||
businessunit: '',
|
||||
@@ -222,17 +240,11 @@ const visibleAssetTypes = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
// Get subtype ID from asset based on asset type
|
||||
function getSubtypeId(asset) {
|
||||
if (!asset.typedata) return null
|
||||
// Normalize network_device -> network device so the subtype id resolves.
|
||||
const typeLower = (asset.assettype || '').toLowerCase().replace(/_/g, ' ')
|
||||
if (typeLower === 'machine') return asset.typedata.machinetypeid
|
||||
if (typeLower === 'computer') return asset.typedata.computertypeid
|
||||
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
||||
if (typeLower === 'printer') return asset.typedata.printertypeid
|
||||
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
||||
return null
|
||||
// Legend swatches wear the same ring as the markers they stand for, so the key
|
||||
// reads as a key. Without this the dot took a surface-colored border and a light
|
||||
// color looked ringed in white next to its black-ringed marker.
|
||||
function legendDotStyle(color) {
|
||||
return { background: color, borderColor: markerRingColor(props.theme) }
|
||||
}
|
||||
|
||||
// Get visible subtypes when a type is selected
|
||||
@@ -344,26 +356,77 @@ function clearPosition() {
|
||||
emit('positionPicked', null)
|
||||
}
|
||||
|
||||
// Get the detail page route based on machine category
|
||||
// Extensible for future addon types (network, cameras, etc.)
|
||||
function getDetailRoute(machine) {
|
||||
const category = machine.category?.toLowerCase() || ''
|
||||
const routeMap = {
|
||||
'machine': '/machines',
|
||||
'pc': '/pcs',
|
||||
'printer': '/printers',
|
||||
// Future addon routes can be added here:
|
||||
// 'network': '/network',
|
||||
// 'camera': '/cameras',
|
||||
// Fetch the declared map overlays (ADR-010) and each overlay's per-asset
|
||||
// decoration data, keyed by assetid for a fast join during rendering.
|
||||
async function loadOverlays() {
|
||||
let declared
|
||||
try {
|
||||
const response = await api.get('/pluginui/map-overlays')
|
||||
declared = response.data.data || []
|
||||
} catch (err) {
|
||||
return
|
||||
}
|
||||
const basePath = routeMap[category] || '/machines'
|
||||
return `${basePath}/${machine.machineid}`
|
||||
const decorations = []
|
||||
const legend = []
|
||||
const seen = new Set()
|
||||
for (const overlay of declared) {
|
||||
let items = []
|
||||
try {
|
||||
const endpoint = overlay.endpoint.replace(/^\/api(?=\/)/, '')
|
||||
const response = await api.get(endpoint)
|
||||
items = response.data.data || []
|
||||
} catch (err) {
|
||||
continue
|
||||
}
|
||||
const byAsset = new Map()
|
||||
for (const item of items) {
|
||||
byAsset.set(item.assetid, { color: item.color, label: item.label })
|
||||
// distinct legend entries (label + color) for overlays that opt in
|
||||
if (overlay.legend) {
|
||||
const key = `${item.label}|${item.color}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
legend.push({ label: item.label, color: item.color })
|
||||
}
|
||||
}
|
||||
}
|
||||
decorations.push({ style: overlay.style || 'badge', byAsset })
|
||||
}
|
||||
overlayDecorations.value = decorations
|
||||
overlayLegend.value = legend
|
||||
if (map) renderMarkers()
|
||||
}
|
||||
|
||||
// Draw ring/badge decorations for one asset's marker.
|
||||
function applyOverlays(item, leafletY, leafletX) {
|
||||
if (item.assetid == null) return
|
||||
overlayDecorations.value.forEach((overlay) => {
|
||||
const dec = overlay.byAsset.get(item.assetid)
|
||||
if (!dec) return
|
||||
if (overlay.style === 'ring') {
|
||||
const ring = L.circleMarker([leafletY, leafletX], {
|
||||
radius: 9, fill: false, color: dec.color, weight: 2,
|
||||
opacity: 0.9, interactive: false, renderer: canvasRenderer,
|
||||
})
|
||||
ring.addTo(map)
|
||||
overlayLayers.push(ring)
|
||||
} else {
|
||||
const badge = L.circleMarker([leafletY + 4, leafletX + 4], {
|
||||
radius: 3.5, fillColor: dec.color, color: '#fff', weight: 1,
|
||||
fillOpacity: 1, interactive: false, renderer: canvasRenderer,
|
||||
})
|
||||
badge.addTo(map)
|
||||
overlayLayers.push(badge)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderMarkers() {
|
||||
// Clear existing markers
|
||||
// Clear existing markers + overlay decoration layers
|
||||
markers.value.forEach(m => m.marker.remove())
|
||||
markers.value = []
|
||||
overlayLayers.forEach(layer => layer.remove())
|
||||
overlayLayers = []
|
||||
|
||||
props.machines.forEach(item => {
|
||||
if (item.mapx == null || item.mapy == null) return
|
||||
@@ -373,13 +436,13 @@ function renderMarkers() {
|
||||
const leafletX = item.mapx
|
||||
|
||||
// Determine color based on mode
|
||||
let color, typeName, displayName, detailRoute
|
||||
let color, typeName, displayName
|
||||
|
||||
if (props.assetTypeMode) {
|
||||
// Unified asset mode - use subtype colors when a type is selected
|
||||
if (props.selectedAssetType) {
|
||||
const subtypeId = getSubtypeId(item)
|
||||
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
|
||||
color = (subtypeId && props.subtypeColors[subtypeId]) || UNSPECIFIED_COLOR
|
||||
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
|
||||
} else {
|
||||
// Prefer the stored AssetType.color; fall back to the built-in map.
|
||||
@@ -391,13 +454,11 @@ function renderMarkers() {
|
||||
if (item.dualpathpartner) {
|
||||
displayName = `${item.assetnumber} / ${item.dualpathpartner}`
|
||||
}
|
||||
detailRoute = getAssetDetailRoute(item)
|
||||
} else {
|
||||
// Legacy machine mode
|
||||
typeName = item.machinetype || ''
|
||||
color = getTypeColor(typeName)
|
||||
displayName = item.alias || item.machinenumber || 'Unknown'
|
||||
detailRoute = getDetailRoute(item)
|
||||
}
|
||||
|
||||
// Use circleMarker instead of divIcon marker - renders on canvas
|
||||
@@ -405,8 +466,10 @@ function renderMarkers() {
|
||||
const marker = L.circleMarker([leafletY, leafletX], {
|
||||
radius: 6,
|
||||
fillColor: color,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
weight: 2,
|
||||
// Ring keyed on the theme's surface: dark on the white blueprint, light on
|
||||
// the dark one. A fixed white ring vanished against the light blueprint.
|
||||
color: markerRingColor(props.theme),
|
||||
weight: 1.25,
|
||||
fillOpacity: 1,
|
||||
renderer: canvasRenderer
|
||||
})
|
||||
@@ -461,43 +524,15 @@ function renderMarkers() {
|
||||
className: 'marker-tooltip'
|
||||
})
|
||||
|
||||
// Click popup (detailed info)
|
||||
let popupContent
|
||||
if (props.assetTypeMode) {
|
||||
popupContent = `
|
||||
<div class="marker-popup">
|
||||
<strong>${displayName}</strong>
|
||||
<div class="popup-details">
|
||||
<div><span class="label">Asset #:</span> ${item.assetnumber || '-'}</div>
|
||||
<div><span class="label">Type:</span> ${item.assettype || '-'}</div>
|
||||
<div><span class="label">Status:</span> ${item.status || '-'}</div>
|
||||
<div><span class="label">Location:</span> ${item.location || '-'}</div>
|
||||
${item.primaryip ? `<div><span class="label">IP:</span> ${item.primaryip}</div>` : ''}
|
||||
</div>
|
||||
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||
</div>
|
||||
`
|
||||
} else {
|
||||
popupContent = `
|
||||
<div class="marker-popup">
|
||||
<strong>${displayName}</strong>
|
||||
<div class="popup-details">
|
||||
<div><span class="label">Number:</span> ${item.machinenumber || '-'}</div>
|
||||
<div><span class="label">Type:</span> ${typeName || '-'}</div>
|
||||
<div><span class="label">Category:</span> ${item.category || '-'}</div>
|
||||
<div><span class="label">Status:</span> ${item.status || '-'}</div>
|
||||
<div><span class="label">Vendor:</span> ${item.vendor || '-'}</div>
|
||||
<div><span class="label">Model:</span> ${item.model || '-'}</div>
|
||||
</div>
|
||||
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
marker.bindPopup(popupContent)
|
||||
// Hover is a glance; the click belongs to the consumer. No popup is bound:
|
||||
// MapView routes to the detail page on click, so a popup opened and was
|
||||
// discarded by the navigation in the same tick - it was never visible. In
|
||||
// the editor and the picker forms it DID render, where a 'View Details'
|
||||
// link only served to pull the user off an unsaved form.
|
||||
marker.on('click', () => emit('markerClick', item))
|
||||
|
||||
marker.addTo(map)
|
||||
applyOverlays(item, leafletY, leafletX)
|
||||
|
||||
// Build search data
|
||||
const searchData = props.assetTypeMode
|
||||
@@ -514,11 +549,6 @@ function renderMarkers() {
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
// Get detail route for unified asset format (shared util = single source).
|
||||
function getAssetDetailRoute(asset) {
|
||||
return assetDetailRoute(asset)
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const searchTerm = filters.value.search.toLowerCase()
|
||||
|
||||
@@ -555,6 +585,8 @@ watch(() => props.machines, (newVal, oldVal) => {
|
||||
watch(() => props.theme, (newTheme) => {
|
||||
if (imageOverlay && map) {
|
||||
imageOverlay.setUrl(blueprintUrlFor(newTheme))
|
||||
// Marker rings are keyed on the surface, so they have to be redrawn too.
|
||||
renderMarkers()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -565,6 +597,7 @@ onMounted(async () => {
|
||||
MAP_WIDTH = mapConfig.width
|
||||
MAP_HEIGHT = mapConfig.height
|
||||
initMap()
|
||||
loadOverlays()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -663,10 +696,18 @@ onUnmounted(() => {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-card);
|
||||
/* Ring color is bound inline (legendDotStyle) to match the markers. */
|
||||
border: 1.5px solid var(--bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Overlay legend entries: a hollow ring, distinct from the solid type dots. */
|
||||
.legend-dot.legend-ring {
|
||||
background: transparent;
|
||||
border-width: 3px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
min-height: 600px;
|
||||
@@ -703,39 +744,6 @@ onUnmounted(() => {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
:deep(.marker-popup) {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
:deep(.marker-popup strong) {
|
||||
display: block;
|
||||
margin-bottom: 0.625rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
:deep(.popup-details) {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
:deep(.popup-details .label) {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.popup-link) {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.popup-link:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:deep(.machine-marker) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
|
||||