From 3d43cb3d86995d42405ff553bf3c8656889ef322 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Mon, 3 Aug 2026 15:08:20 -0400 Subject: [PATCH] Windows installer: payload hash-locking, SBOM, client-IP handling, operator docs --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 50 +- Dockerfile | 2 +- README.md | 58 +- deploy/windows/installer/.gitignore | 6 + deploy/windows/installer/README.md | 245 ++ deploy/windows/installer/ShopDBFlask.iss | 1174 +++++++++ deploy/windows/installer/build-installer.ps1 | 301 +++ deploy/windows/installer/build-installer.sh | 140 ++ deploy/windows/installer/bundle-lock.json | 197 ++ deploy/windows/installer/bundle-lock.ps1 | 233 ++ deploy/windows/installer/make-branding.py | 159 ++ .../windows/installer/refresh-bundle-lock.ps1 | 110 + deploy/windows/installer/shopdb-admin.ps1 | 816 +++++++ deploy/windows/installer/shopdb-install.ps1 | 2104 +++++++++++++++++ deploy/windows/installer/shopdb-preflight.ps1 | 547 +++++ deploy/windows/installer/shopdb.ico | Bin 0 -> 52695 bytes deploy/windows/installer/swatch-bad.bmp | Bin 0 -> 822 bytes deploy/windows/installer/swatch-ok.bmp | Bin 0 -> 822 bytes deploy/windows/installer/swatch-warn.bmp | Bin 0 -> 822 bytes .../windows/installer/verify_bundle_lock.py | 170 ++ deploy/windows/installer/wizard-image.bmp | Bin 0 -> 154542 bytes deploy/windows/installer/wizard-image@125.bmp | Bin 0 -> 222390 bytes deploy/windows/installer/wizard-image@250.bmp | Bin 0 -> 889398 bytes deploy/windows/installer/wizard-small.bmp | Bin 0 -> 9294 bytes deploy/windows/installer/wizard-small@125.bmp | Bin 0 -> 12342 bytes deploy/windows/installer/wizard-small@250.bmp | Bin 0 -> 57462 bytes deploy/windows/web.config | 35 +- frontend/src/composables/setupState.js | 30 +- frontend/src/router/index.js | 21 +- frontend/src/views/Login.vue | 2 + frontend/src/views/SetupWizard.vue | 7 +- migrations/PRODUCTION_MIGRATION_GUIDE.md | 8 +- migrations/env.py | 25 +- plugins/employees/api/routes.py | 2 +- plugins/geenforce/api/routes.py | 23 +- requirements-dev.in | 12 + requirements-dev.txt | 886 +++++++ requirements.in | 14 +- requirements.txt | 762 +++++- scripts/build-site.sh | 110 +- scripts/check-naming-and-style.sh | 15 +- scripts/generate_sbom.py | 348 +++ scripts/resolve_plugin_closure.py | 61 + scripts/test-external-plugin.sh | 2 +- shopdb/cli/__init__.py | 25 + shopdb/plugins/alembic_template.py | 8 + shopdb/utils/mysql_charset.py | 40 + tests/test_bundle_lock.py | 231 ++ tests/test_installer_defaults.py | 55 + tests/test_sbom.py | 154 ++ 51 files changed, 9002 insertions(+), 190 deletions(-) create mode 100644 deploy/windows/installer/.gitignore create mode 100644 deploy/windows/installer/README.md create mode 100644 deploy/windows/installer/ShopDBFlask.iss create mode 100644 deploy/windows/installer/build-installer.ps1 create mode 100755 deploy/windows/installer/build-installer.sh create mode 100644 deploy/windows/installer/bundle-lock.json create mode 100644 deploy/windows/installer/bundle-lock.ps1 create mode 100644 deploy/windows/installer/make-branding.py create mode 100644 deploy/windows/installer/refresh-bundle-lock.ps1 create mode 100644 deploy/windows/installer/shopdb-admin.ps1 create mode 100644 deploy/windows/installer/shopdb-install.ps1 create mode 100644 deploy/windows/installer/shopdb-preflight.ps1 create mode 100644 deploy/windows/installer/shopdb.ico create mode 100644 deploy/windows/installer/swatch-bad.bmp create mode 100644 deploy/windows/installer/swatch-ok.bmp create mode 100644 deploy/windows/installer/swatch-warn.bmp create mode 100644 deploy/windows/installer/verify_bundle_lock.py create mode 100644 deploy/windows/installer/wizard-image.bmp create mode 100644 deploy/windows/installer/wizard-image@125.bmp create mode 100644 deploy/windows/installer/wizard-image@250.bmp create mode 100644 deploy/windows/installer/wizard-small.bmp create mode 100644 deploy/windows/installer/wizard-small@125.bmp create mode 100644 deploy/windows/installer/wizard-small@250.bmp create mode 100644 requirements-dev.in create mode 100644 requirements-dev.txt create mode 100644 scripts/generate_sbom.py create mode 100644 scripts/resolve_plugin_closure.py create mode 100644 shopdb/utils/mysql_charset.py create mode 100644 tests/test_bundle_lock.py create mode 100644 tests/test_installer_defaults.py create mode 100644 tests/test_sbom.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 239888a6..425fdc5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,9 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' cache: pip - - run: pip install -r requirements.txt + - run: pip install -r requirements-dev.txt - run: python -m pytest -q naming: diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0adbc5..721e9aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,54 @@ ADR-007 and ADR-002. ### Added +- 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 `. +- `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 @@ -53,7 +101,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) diff --git a/Dockerfile b/Dockerfile index 082620b8..d6c04a28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,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 \ diff --git a/README.md b/README.md index 8296fdfc..3663d2c0 100644 --- a/README.md +++ b/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 the DEPLOY wiki page) - 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 the DEPLOY wiki page) @@ -174,8 +174,15 @@ token plus the IMPORT-API page in the project wiki 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 the DEPLOY page in the project wiki; -for every environment variable and Setting key see the CONFIG page in the project wiki. +### Which deployment route + +| Target | Use | +|---|---| +| **Windows Server + IIS** (how sister sites run) | **the INSTALL-WINDOWS page in the project wiki** - one installer `.exe`, offline, no manual IIS work. Day 2: the OPERATE-WINDOWS page in the project wiki | +| Linux / Docker, air-gapped | the DEPLOY-AIRGAP page in the project wiki | +| Linux / Docker, connected | the DEPLOY page in the project wiki | + +For every environment variable and Setting key see the CONFIG page in the project wiki. ## Configuration @@ -217,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 @@ -227,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 diff --git a/deploy/windows/installer/.gitignore b/deploy/windows/installer/.gitignore new file mode 100644 index 00000000..5a3b04ac --- /dev/null +++ b/deploy/windows/installer/.gitignore @@ -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 diff --git a/deploy/windows/installer/README.md b/deploy/windows/installer/README.md new file mode 100644 index 00000000..7dbf084d --- /dev/null +++ b/deploy/windows/installer/README.md @@ -0,0 +1,245 @@ +# 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/mysql/ mysql-8.0.x-winx64.msi (bundled-database option only) +``` + +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:///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.0 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). diff --git a/deploy/windows/installer/ShopDBFlask.iss b/deploy/windows/installer/ShopDBFlask.iss new file mode 100644 index 00000000..7bbecd29 --- /dev/null +++ b/deploy/windows/installer/ShopDBFlask.iss @@ -0,0 +1,1174 @@ +; ShopDB-Flask air-gapped Windows installer +; Wraps the tested PowerShell stages in an operator-facing wizard. +; Version 1.0 +; +; DESIGN NOTE - why this is a thin wrapper and not a reimplementation: +; every install action lives in shopdb-install.ps1, which is verified end to end +; on Windows Server 2025 against both a bundled MySQL 8.0 and an existing MySQL +; 5.6. Reimplementing any of it in Pascal Script would create a second code path +; that nobody tests. This file only collects operator input, runs the stages in +; order, and reports which one failed. + +; Inno Setup 6.6.0 is the floor: WizardStyle below uses the built-in 'windows11' +; custom style, which 6.5 and earlier reject. Stated here so a build box on an +; older compiler fails with this sentence rather than with a bare +; "Value of [Setup] section directive WizardStyle is invalid". +#if VER < EncodeVer(6,6,0) + #error This script needs Inno Setup 6.6.0 or newer (WizardStyle=... windows11). Download it from https://jrsoftware.org/isdl.php +#endif + +#define AppName "ShopDB-Flask" +; The PRODUCT version, generated into version.iss by the builder from +; shopdb/__init__.py. It was hardcoded, and had drifted to 0.9.0 while the +; product, the frontend and the newest tag all said 0.7.0 - so the delivered exe, +; its Add/Remove Programs entry and the version stamp written onto the server all +; disagreed with the code inside it, and 0.9.0 collided with a retired contract +; version. Never hardcode it here again. +; +; Do NOT bump a version to work around a locked output file: the BUILD STAMP +; below makes every compile a unique filename, which is what that problem needed. +#include "version.iss" +#define AppPublisher "GE Aerospace" +#define BundleDir "bundle" +; Single source for the install directory - used by DefaultDirName and by the +; wizard's pre-fill fallback, so the two cannot disagree. +#define DefaultDir "C:\shopdb-flask" +; Generated by build-installer.sh from what is actually in the bundle, so the +; plugin page can never offer something the payload does not contain. +#include "plugins.iss" + +[Setup] +AppId={{7C4E1A93-2F86-4D5B-9E31-8A0C6B5D4F27} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher={#AppPublisher} +DefaultDirName={#DefaultDir} +DisableDirPage=no +CreateAppDir=yes +PrivilegesRequired=admin +OutputDir=.\Output +; Version identifies the RELEASE; the stamp identifies the BUILD. Separating them +; means recompiling never collides with an exe you happen to have open, and the +; version only moves when something meaningful changed. +#define BuildStamp GetDateTimeString('yyyymmdd-hhnn', '', '') +OutputBaseFilename=ShopDBFlask_Installer_{#AppVersion}_{#BuildStamp} +; The payload is ~200MB of wheels, Python and an MSI, all already compressed. +; lzma2/max on top of that costs minutes and saves almost nothing. +Compression=lzma2/normal +SolidCompression=yes + +; --- Appearance ------------------------------------------------------------- +; 'modern' larger layout, Segoe UI, no 1990s bevels +; 'windows11' built-in custom style (6.6.0+) - rounded controls, current palette +; 'dynamic' follows the machine's light/dark setting automatically +; 'hidebevels' removes the remaining sunken separator lines +; Custom styles switch themselves off under a high-contrast theme or /NOSTYLE, +; so accessibility is not broken by any of this. +WizardStyle=modern windows11 dynamic hidebevels +; 15% larger than default without scaling the font up - the preflight page is +; dense and benefits from the extra room. +WizardSizePercent=115 +DisableWelcomePage=no +; Name the Start Menu folder and skip the "choose a folder" page - a server tool +; does not need the operator to invent a location for its shortcuts. +DefaultGroupName=ShopDB-Flask +DisableProgramGroupPage=yes +; Always write a setup log. Inno writes one only when asked, and every streamed +; line from the install stages goes into it, so this is the difference between a +; diagnosable failure and "exit 1" with nothing to look at. +SetupLogging=yes + +; Artwork is generated by make-branding.py from the app's OWN brand assets, so +; the installer and the running site look like the same product. The @125/@250 +; variants let Inno pick a crisp image on high-DPI displays rather than upscaling. +WizardImageFile=wizard-image.bmp,wizard-image@125.bmp,wizard-image@250.bmp +WizardSmallImageFile=wizard-small.bmp,wizard-small@125.bmp,wizard-small@250.bmp + +; Installer executable icon, and the icon shown in Apps & Features. Without this +; Windows falls back to a generic setup icon and, for uninstall, to whatever exe +; is named - which was python.exe, i.e. a Python logo for a ShopDB entry. +SetupIconFile=shopdb.ico +UninstallDisplayIcon={app}\shopdb.ico +UninstallDisplayName=ShopDB-Flask + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Messages] +WelcomeLabel1=Set up ShopDB-Flask on this server +; Short, and says the two things an operator actually wants up front: it will not +; need the internet, and it will tell them before it changes anything. +; The installer supplies Python, the wheels, the IIS modules and optionally MySQL. +; It does NOT install the IIS Web Server role itself - that is a Windows feature, +; and the check on the next page confirms it is present rather than adding it. +WelcomeLabel2=Everything this application needs is included - Python, its packages, the IIS modules and optionally MySQL. No internet connection is used at any point.%n%nThis server must already have the IIS Web Server role installed. The next page checks that, and everything else this needs, before anything is changed.%n%nYou will then be asked a few short questions. Nothing on this server is changed until the final confirmation. +ClickNext=Click Next to check this server. +FinishedHeadingLabel=ShopDB-Flask is ready +SetupAppTitle=ShopDB-Flask Setup +SetupWindowTitle=ShopDB-Flask Setup + +[Files] +; The whole verified bundle, staged next to this script by build-installer.sh. +; Extracted during the install step, so it is available from ssPostInstall onward +; but NOT during the wizard pages. +Source: "{#BundleDir}\*"; DestDir: "{tmp}\shopdb-bundle"; \ + Flags: ignoreversion recursesubdirs createallsubdirs deleteafterinstall +; The preflight has to run on a WIZARD PAGE, which happens long before the [Files] +; section is processed. 'dontcopy' plus ExtractTemporaryFile is the only way to get +; a file on disk that early. Listed twice on purpose - once for each phase. +Source: "{#BundleDir}\shopdb-preflight.ps1"; Flags: dontcopy +; The operator's day-to-day tool. Installed into the app directory and given +; Start Menu shortcuts, so nobody has to open IIS Manager to restart the site. +; [UninstallRun] executes this from {app}. It was only ever staged into {tmp} +; with deleteafterinstall, so uninstall ran powershell against a path that no +; longer existed, exited non-zero unnoticed (runhidden, no result check), and +; Windows reported success while the site, app pool, firewall rule and .env with +; its plaintext password were all left in place. +Source: "{#BundleDir}\shopdb-install.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#BundleDir}\shopdb-preflight.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: "shopdb-admin.ps1"; DestDir: "{app}"; Flags: ignoreversion +; Kept on disk so the Start Menu shortcuts and the uninstall entry have an icon. +Source: "shopdb.ico"; DestDir: "{app}"; Flags: ignoreversion +; Status colours. These are BITMAPS, not control colours: a custom VCL style +; (WizardStyle=... windows11) repaints styled controls and ignores both +; Font.Color and TPanel.Color, so a bitmap is the only thing guaranteed to show +; the colour the operator is meant to see. +Source: "swatch-ok.bmp"; Flags: dontcopy +Source: "swatch-warn.bmp"; Flags: dontcopy +Source: "swatch-bad.bmp"; Flags: dontcopy + +[Icons] +; A folder rather than loose icons: this is a server tool, not a desktop app. +; +; EVERY shortcut passes -AppRoot and -SitePort. The console defaults to +; C:\shopdb-flask and port 8090, so on any install that chose a different +; directory or port it looked in the wrong place and reported a perfectly healthy +; site as broken - from a Start Menu shortcut the installer wrote itself. +Name: "{group}\ShopDB-Flask Console"; Filename: "powershell.exe"; \ + Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ + WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ + Comment: "Status, restart, logs and backups" +; Resolves the address from .env at click time rather than baking one in. The +; literal http://localhost:8090/login this used to carry was wrong for every +; subpath install and every non-default port, with no operator mistake involved. +Name: "{group}\Open ShopDB-Flask"; Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{app}\shopdb-admin.ps1"" open -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ + WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ + Comment: "Open the application in a browser" +Name: "{group}\Restart ShopDB-Flask"; Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" restart -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ + WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ + Comment: "Recycle the application pool" +Name: "{group}\Back up the database"; Filename: "powershell.exe"; \ + Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" backup -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ + WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ + Comment: "Write a .sql dump to ProgramData" + +[Run] +; Offer the console at the end, unticked - finishing the wizard should not +; surprise anyone with a shell. +; Setup is a 32-bit process, so a bare "powershell.exe" here resolves through +; WOW64 to the 32-bit build, whose Get-Website fails with REGDB_E_CLASSNOTREG +; and makes the console report "cannot read IIS". Sysnative reaches the real +; System32 from a 32-bit process. The script also self-corrects, but launching +; it correctly avoids a visible relaunch. +Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \ + Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \ + Description: "Open the ShopDB-Flask console"; \ + Flags: postinstall skipifsilent unchecked + +[Code] +var + DbChoicePage: TInputOptionWizardPage; + DbDetailsPage: TInputQueryWizardPage; + SitePage: TInputQueryWizardPage; + PreflightPage: TWizardPage; + BannerPanel: TPanel; + BannerBar: TBitmapImage; + BannerText: TNewStaticText; + BannerSub: TNewStaticText; + DetailPanel: TPanel; + FooterText: TNewStaticText; + RecheckButton: TNewButton; + // Set by the preflight render. The wizard REFUSES to leave the results page + // while this is True: the check used to be advisory, so a server missing IIS + // sailed through every page and failed in the middle of installing, having + // already put Python on the box. + PreflightBlocked: Boolean; + PreflightDone: Boolean; + // Shown while the preflight runs. Without it, clicking Next appears to hang: + // the check takes a few seconds, runs hidden, and gives no sign of life. + CheckingPage: TOutputProgressWizardPage; + PluginPage: TInputOptionWizardPage; + PluginNames: TArrayOfString; + PluginPageReady: Boolean; + DbPageReady: Boolean; + DeployPage: TInputOptionWizardPage; + DeployPageReady: Boolean; + ClientIpPage: TInputOptionWizardPage; + // Set from the streamed stage output so a failure can name its cause. Without + // this the wizard could only report "exit 1", which points at nothing. + FailCause: String; + FailDetail: String; + UseBundledDb: Boolean; + LogPath: String; + +const + DB_BUNDLED = 0; + DB_EXISTING = 1; + // Row geometry, in the page's own coordinate space. ScaleY/ScaleX keep these + // correct at 125%/150% DPI - hardcoded pixels would overlap on a 4K display. + ROW_GAP = 4; + + +// Plugin directory names are developer-facing. Show operators what the feature +// actually is; anything unlisted falls back to its raw name so a new plugin +// still appears rather than vanishing. +function PluginLabel(const Name: String): String; +begin + if Name = 'computers' then Result := 'Computers and workstations' + else if Name = 'machines' then Result := 'Machines (CNC, CMM, lathes)' + else if Name = 'printers' then Result := 'Printers and supplies' + else if Name = 'network' then Result := 'Network devices' + else if Name = 'measuringtools' then Result := 'Measuring tools and gauges' + else if Name = 'printedparts' then Result := '3D printed parts' + else if Name = 'knowledgebase' then Result := 'Knowledge base articles' + else if Name = 'slides' then Result := 'Shopfloor display slides' + else if Name = 'warranty' then Result := 'Warranty tracking' + else if Name = 'notifications' then Result := 'Notifications and announcements' + else if Name = 'usb' then Result := 'USB device checkout' + else if Name = 'employees' then Result := 'Employee directory' + else if Name = 'geenforce' then Result := 'GE-Enforce manifests' + else Result := Name; +end; + +// True when a fresh install should tick this by default. The five omitted here +// are specialised; a site that wants them can tick them. +// Pre-tick to match each plugin's own manifest. These five ship +// "default_enabled": false, so a site gets them only by asking. The list used to +// omit measuringtools and printedparts, which meant the wizard installed and +// enabled them against their manifests on every site that took the defaults - +// creating tables nobody asked for. +// +// Keep this in step with the manifests. tests/test_installer_defaults.py fails +// if they drift. +function PluginDefault(const Name: String): Boolean; +begin + Result := (Name <> 'usb') and (Name <> 'employees') and (Name <> 'geenforce') + and (Name <> 'measuringtools') and (Name <> 'printedparts'); +end; + +// Comma-separated list of what the operator ticked. +function SelectedPlugins: String; +var + I, Count: Integer; +begin + Result := ''; + if PluginPage = nil then Exit; + // Never index past what was actually ADDED to the page. PluginNames comes from + // a build-time define and CheckListBox.Items from the Add() calls; if those + // ever disagree, reading Values[] past the end is a runtime error rather than + // a graceful miss. + Count := GetArrayLength(PluginNames); + if PluginPage.CheckListBox.Items.Count < Count then + Count := PluginPage.CheckListBox.Items.Count; + for I := 0 to Count - 1 do + if PluginPage.Values[I] then + begin + if Result <> '' then Result := Result + ','; + Result := Result + PluginNames[I]; + end; +end; + +procedure SetSwatch(Img: TBitmapImage; const Swatch: String); +begin + ExtractTemporaryFile(Swatch); + Img.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch); +end; + +// A solid colour block, drawn as a stretched bitmap so no VCL style can repaint +// it. Used for the row markers and the banner's accent bar. +function AddSwatch(Parent: TWinControl; L, T, W, H: Integer; const Swatch: String): TBitmapImage; +begin + Result := TBitmapImage.Create(WizardForm); + Result.Parent := Parent; + Result.Left := L; + Result.Top := T; + Result.Width := W; + Result.Height := H; + Result.Stretch := True; + ExtractTemporaryFile(Swatch); + Result.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch); +end; + +// Defined further down, next to the preflight rendering they drive. Declared here +// because InitializeWizard wires RecheckClick to a button before that point. +procedure RunPreflight; forward; +procedure RecheckClick(Sender: TObject); forward; + +procedure InitializeWizard; +var + I: Integer; + Domain: String; +begin + LogPath := ExpandConstant('{tmp}\shopdb-installer-wizard.log'); + + // A custom page, not a memo. Status belongs in controls with colour, not in a + // wall of monospace text that has to be read line by line. + PreflightPage := CreateCustomPage(wpWelcome, + 'Server check', 'Confirming this server is ready'); + + // Verdict banner. Colour is the whole point: an operator should know within a + // second whether they can continue, without reading anything. + BannerPanel := TPanel.Create(WizardForm); + BannerPanel.Parent := PreflightPage.Surface; + BannerPanel.Left := 0; + BannerPanel.Top := 0; + BannerPanel.Width := PreflightPage.SurfaceWidth; + BannerPanel.Height := ScaleY(58); + BannerPanel.BevelOuter := bvNone; + // No Color here: the style would repaint it. The verdict colour is carried by + // BannerBar, a stretched bitmap down the left edge, which the style cannot touch. + + BannerBar := AddSwatch(BannerPanel, 0, 0, ScaleX(6), ScaleY(58), 'swatch-ok.bmp'); + + BannerText := TNewStaticText.Create(WizardForm); + BannerText.Parent := BannerPanel; + BannerText.Left := ScaleX(18); + BannerText.Top := ScaleY(10); + BannerText.Font.Size := 12; + BannerText.Font.Style := [fsBold]; + BannerText.Caption := 'Checking...'; + + BannerSub := TNewStaticText.Create(WizardForm); + BannerSub.Parent := BannerPanel; + BannerSub.Left := ScaleX(18); + BannerSub.Top := ScaleY(33); + BannerSub.Width := PreflightPage.SurfaceWidth - ScaleX(28); + BannerSub.AutoSize := False; + BannerSub.Caption := ''; + + // Rows are added here at run time, once the results are known. + DetailPanel := TPanel.Create(WizardForm); + DetailPanel.Parent := PreflightPage.Surface; + DetailPanel.Left := 0; + DetailPanel.Top := BannerPanel.Height + ScaleY(12); + DetailPanel.Width := PreflightPage.SurfaceWidth; + DetailPanel.Height := PreflightPage.SurfaceHeight - BannerPanel.Height - ScaleY(34); + DetailPanel.BevelOuter := bvNone; + DetailPanel.Color := clWindow; + + FooterText := TNewStaticText.Create(WizardForm); + FooterText.Parent := PreflightPage.Surface; + FooterText.Left := 0; + FooterText.Top := PreflightPage.SurfaceHeight - ScaleY(16); + FooterText.Width := PreflightPage.SurfaceWidth - ScaleX(84); + FooterText.AutoSize := False; + FooterText.Caption := ''; + + // A blocking page needs a way forward that is not "cancel the installer". The + // operator fixes what the page told them to fix - installs the IIS role, frees + // the port - and re-checks without starting over. + RecheckButton := TNewButton.Create(WizardForm); + RecheckButton.Parent := PreflightPage.Surface; + RecheckButton.Width := ScaleX(78); + RecheckButton.Height := ScaleY(23); + RecheckButton.Left := PreflightPage.SurfaceWidth - RecheckButton.Width; + RecheckButton.Top := PreflightPage.SurfaceHeight - ScaleY(23); + RecheckButton.Caption := 'Check again'; + RecheckButton.OnClick := @RecheckClick; + + CheckingPage := CreateOutputProgressPage('Checking this server', + 'Reading the current configuration. Nothing is being changed.'); + + // Which features this site uses. Multi-select, and pre-ticked from the + // EXISTING install when there is one - so an upgrade shows what you already + // have and ticking a new box adds it. + PluginPage := CreateInputOptionPage(PreflightPage.ID, + 'Features', 'Which parts of ShopDB-Flask does this site use?', + 'Unticked features are simply not set up, and their database tables are not ' + + 'created. On an upgrade this shows what the site already has - ticking a new ' + + 'box adds it, but UNTICKING one does NOT remove a feature that is already ' + + 'installed. Removing is a deliberate step, not a side effect of an upgrade.', + False, True); + PluginNames := StringSplit('{#AvailablePlugins}', [','], stAll); + for I := 0 to GetArrayLength(PluginNames) - 1 do + if Trim(PluginNames[I]) <> '' then + PluginPage.Add(PluginLabel(Trim(PluginNames[I]))); + + DbChoicePage := CreateInputOptionPage(PluginPage.ID, + 'Database', 'Where should ShopDB-Flask store its data?', + 'Most sites already run MySQL for the existing shopdb application. If so, ' + + 'choose the second option - installing a second server would collide on ' + + 'port 3306.', + True, False); + DbChoicePage.Add('Install the bundled MySQL 8.0 (new servers only)'); + DbChoicePage.Add('Use a MySQL server this site already runs'); + DbChoicePage.SelectedValueIndex := DB_EXISTING; + + DbDetailsPage := CreateInputQueryPage(DbChoicePage.ID, + 'Existing database', 'Connection details', + 'The database and application user must already exist. Your DBA creates ' + + 'them; the installer does not, so that it never needs administrative ' + + 'rights on your database server. If they need the exact statements:' + #13#10 + + ' CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;' + #13#10 + + ' CREATE USER ''shopdb''@''%'' IDENTIFIED BY '''';' + #13#10 + + ' GRANT ALL PRIVILEGES ON shopdb_flask.* TO ''shopdb''@''%'';' + #13#10 + + 'The utf8mb4 charset matters - the default on older servers is latin1.'); + DbDetailsPage.Add('Host:', False); + DbDetailsPage.Add('Port:', False); + DbDetailsPage.Add('Database:', False); + DbDetailsPage.Add('Username:', False); + DbDetailsPage.Add('Password:', True); + DbDetailsPage.Values[0] := '127.0.0.1'; + DbDetailsPage.Values[1] := '3306'; + DbDetailsPage.Values[2] := 'shopdb_flask'; + DbDetailsPage.Values[3] := 'shopdb'; + + // How the application is published. Offered only when the bundle actually + // carries a subpath SPA build - Vite compiles the base path in, so this can + // never be a pure runtime switch. + DeployPage := CreateInputOptionPage(DbDetailsPage.ID, + 'Address', 'How should people reach ShopDB-Flask?', + 'Both options serve the same application. The second needs no new DNS name ' + + 'and no port number, because it rides this server''s existing address.', + True, False); + DeployPage.Add('Its own address, on a port - http://:8090/'); + DeployPage.Add('Under this server''s existing address - http:///{#SubpathAlias}/'); + DeployPage.SelectedValueIndex := 0; + + SitePage := CreateInputQueryPage(DeployPage.ID, + 'Web site', 'How the site is published', + 'The host name is used for CORS. It must be the name operators actually ' + + 'type in the browser, or the page will load but its data requests will fail.'); + SitePage.Add('Host name:', False); + SitePage.Add('Port:', False); + // FQDN, not the NetBIOS name: this value becomes CORS_ORIGINS, and a browser + // arriving at the fully qualified address would be refused by a bare hostname. + Domain := ''; + RegQueryStringValue(HKEY_LOCAL_MACHINE, + 'SYSTEM\CurrentControlSet\Services\Tcpip\Parameters', 'Domain', Domain); + if Domain <> '' then + SitePage.Values[0] := GetComputerNameString + '.' + Domain + else + SitePage.Values[0] := GetComputerNameString; + SitePage.Values[1] := '8090'; + + // Where the real client IP comes from. Not cosmetic: IIS sends no + // X-Forwarded-For of its own, so with neither option applied every request + // reads as 127.0.0.1 and the GE-Enforce IP allowlist, the dashboard's + // visitor-location lookup and per-host login rate limiting all fail silently. + // + // The two answers are mutually exclusive, and picking the wrong one is worse + // than picking neither: the rule OVERWRITES the header with REMOTE_ADDR, which + // is exactly right when IIS faces clients (it defeats spoofing) and exactly + // wrong behind a proxy (REMOTE_ADDR is the proxy, so the real client IP is + // discarded). Hence a question rather than a default. + ClientIpPage := CreateInputOptionPage(SitePage.ID, + 'Client addresses', 'Does anything sit between your users and this server?', + 'ShopDB records the address of every request, and some features decide what ' + + 'to show based on it. If you are not sure, choose the first option - it is ' + + 'correct for a server users reach directly.', + True, False); + ClientIpPage.Add('No - users reach this server directly (installs URL Rewrite)'); + // Phrased as "already adds the visitor's address" rather than "is a proxy": + // the operator can check that with their network team, whereas "is there a + // proxy" invites a guess, and guessing wrong here silently discards the real + // client address on every request. + ClientIpPage.Add('Yes - a load balancer or gateway that already adds the visitor''s address'); + ClientIpPage.SelectedValueIndex := 0; +end; + +// 'direct' installs URL Rewrite from the bundle and enables the X-Forwarded-For +// rule; 'proxy' leaves both alone because the proxy already sets the header. +function ClientIpSourceArg: String; +begin + if ClientIpPage.SelectedValueIndex = 1 then + Result := 'proxy' + else + Result := 'direct'; +end; + +// Full path to the 64-bit PowerShell. +// +// Setup is a 32-bit process, so 'powershell.exe' resolves through WOW64 to the +// 32-bit SysWOW64 build, which CANNOT instantiate IIS's 64-bit COM objects: +// "Retrieving the COM class factory for component with CLSID ... 80040144" +// from Get-Website, while the identical script run from a normal shell is fine. +// 'Sysnative' is the alias that lets a 32-bit process reach the real System32; +// it exists ONLY for 32-bit processes, hence the IsWin64 guard. +// +// Inno has ExecAndCaptureOutputWithNativeSysDir for this, but there is no +// ExecAndLogOutput equivalent, so resolving the path ourselves keeps both call +// sites on one mechanism instead of two that can drift apart. +function PowerShellPath: String; +begin + if IsWin64 then + Result := ExpandConstant('{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe') + else + Result := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'); +end; + +// Streamed line by line from ExecAndLogOutput, so the operator sees the install +// happening instead of a frozen wizard. Stage 2 alone installs 47 wheels and can +// run for minutes with nothing on screen. +procedure OnStageLog(const S: String; const Error, FirstLine: Boolean); +var + Line: String; +begin + // Everything goes to the setup log verbatim - that is the diagnostic record. + Log('[shopdb] ' + S); + if Error then Exit; + Line := Trim(S); + if Line = '' then Exit; + + // Recognise the failures an operator can actually act on, and translate them + // into plain language. Matching on the SYMPTOM text (not an exit code) is what + // lets the wizard say "wrong password" instead of "exit 1". + if Pos('Access denied for user', Line) > 0 then + begin + FailCause := 'The database rejected the username or password.'; + FailDetail := 'Go back to the Database page and re-enter them. Check for ' + + 'capital letters - the password is case-sensitive.'; + end + else if Pos('Unknown database', Line) > 0 then + begin + FailCause := 'That database does not exist on the server.'; + FailDetail := 'Your DBA must create it before installing, along with the ' + + 'application user that owns it.'; + end + else if (Pos('Can''t connect to MySQL server', Line) > 0) or (Pos('timed out', Line) > 0) then + begin + FailCause := 'The database server did not answer.'; + FailDetail := 'Check the host name and port on the Database page, and that ' + + 'MySQL is running and reachable from this server.'; + end + else if Pos('index flags', Line) > 0 then + begin + FailCause := 'This MySQL 5.6 server is missing three required settings.'; + FailDetail := 'innodb_file_per_table, innodb_file_format=Barracuda and ' + + 'innodb_large_prefix must be set, then MySQL restarted.'; + end + else if Pos('does not represent a valid object', Line) > 0 then + begin + FailCause := 'IIS refused to create the site or application pool.'; + FailDetail := 'This usually means a previous ShopDB site or pool is in a ' + + 'half-removed state. Run IISRESET and try again.'; + end; + + // The status caption is a DIFFERENT job: it tells an operator what is happening + // right now. Raw log lines are useless for that - "exec python.exe (7 args)" + // says nothing, and the argument COUNT is only there because argument VALUES + // must never be logged (they can carry a password). + // So: translate the few lines that mark real progress, and ignore the rest. + if Pos('exec ', Line) > 0 then Exit; + + if Pos('STAGE 0', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Installing the bundled MySQL database...' + else if Pos('STAGE 2', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Installing Python and the application...' + else if Pos('wheelhouse', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Installing dependencies (offline)...' + else if Pos('STAGE 3', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Preparing the database...' + else if Pos('db upgrade', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Creating the database schema...' + else if Pos('seed ', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Adding reference data...' + else if Pos('apply-profile', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Installing the plugins this site uses...' + else if Pos('upgrade-all', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Applying plugin migrations...' + else if Pos('prune-schema', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Removing unused plugin tables...' + else if Pos('STAGE 4', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Configuring IIS...' + else if Pos('STAGE 5', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Checking the site responds...' + else if Pos('[FAIL]', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Failed - see the message that follows'; +end; + +function RunPowerShell(const ScriptArgs: String; var ResultCode: Integer): Boolean; +var + Cmd: String; +begin + Cmd := '-NoProfile -ExecutionPolicy Bypass -File "' + + ExpandConstant('{tmp}\shopdb-bundle\shopdb-install.ps1') + '" ' + ScriptArgs; + // ExecAndLogOutput streams output through OnStageLog as it is produced, and + // also writes it into the setup log - so a failed install leaves a full trace + // without the operator having to find the PowerShell log in TEMP. + // 64-bit PowerShell: stage 4 configures IIS, which the 32-bit build cannot do. + Result := ExecAndLogOutput(PowerShellPath, Cmd, '', SW_HIDE, + ewWaitUntilTerminated, ResultCode, @OnStageLog); +end; + +// One result row: coloured dot, bold title, wrapped detail. Returns the Y for +// the next row so the caller does not have to guess heights. +// +// Status marker. Four approaches failed before this one, all recorded so nobody +// re-treads them: +// - PNG into TBitmapImage.Bitmap -> "Bitmap image is not valid" (BMP only). +// - Chr(9679) for U+25CF -> renders 'I'-diaeresis; Chr takes a BYTE. +// - Wingdings glyph + Font.Color -> draws, but BLACK: a custom VCL style owns +// text painting and ignores Font.Color. +// - TPanel + Color -> also repainted by the style; no colour. +// A stretched BMP is painted verbatim, so the colour always shows. +function AddRow(Y: Integer; const Swatch, Title, Detail: String): Integer; +var + T, D: TNewStaticText; +begin + AddSwatch(DetailPanel, ScaleX(3), Y + ScaleY(4), ScaleX(10), ScaleY(10), Swatch); + + T := TNewStaticText.Create(WizardForm); + T.Parent := DetailPanel; + T.Left := ScaleX(22); + T.Top := Y; + T.Width := DetailPanel.Width - ScaleX(24); + T.Font.Style := [fsBold]; + T.Caption := Title; + + D := TNewStaticText.Create(WizardForm); + D.Parent := DetailPanel; + D.Left := ScaleX(22); + D.Top := Y + ScaleY(15); + D.Width := DetailPanel.Width - ScaleX(24); + D.AutoSize := False; + D.WordWrap := True; + D.Height := ScaleY(28); + D.Font.Color := clGrayText; + D.Caption := Detail; + + Result := Y + ScaleY(46) + ScaleY(ROW_GAP); +end; + +// Turn the delimited records into a verdict plus rows. Passing checks are NEVER +// listed: 10 green lines bury the one amber line that actually needs reading. +// If nothing is wrong the page collapses to a single reassuring statement. +procedure RenderPreflight(Lines: TArrayOfString; var HasBlockers: Boolean); +var + I, Y, Shown, Fails, Warns, Passes: Integer; + Parts: TArrayOfString; + Check, Detail, Fix: String; +begin + Fails := 0; Warns := 0; Passes := 0; + Y := ScaleY(4); + Shown := 0; + + // Blockers first, then warnings - a second pass rather than one, so severity + // ordering does not depend on the order the checks happen to run in. + for I := 0 to GetArrayLength(Lines) - 1 do + begin + Parts := StringSplit(Lines[I], ['|'], stAll); + if GetArrayLength(Parts) < 4 then Continue; + if Parts[0] = 'FAIL' then Fails := Fails + 1 + else if Parts[0] = 'WARN' then Warns := Warns + 1 + else if Parts[0] = 'PASS' then Passes := Passes + 1; + end; + + for I := 0 to GetArrayLength(Lines) - 1 do + begin + Parts := StringSplit(Lines[I], ['|'], stAll); + if GetArrayLength(Parts) < 4 then Continue; + if Parts[0] <> 'FAIL' then Continue; + // Qualify the check with its area: "Service" and "Port 3306" on their own + // say nothing about WHAT service or whose port. + Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3]; + if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := ''; + if Fix <> '' then Detail := Detail + ' - ' + Fix; + Y := AddRow(Y, 'swatch-bad.bmp', Check, Detail); + Shown := Shown + 1; + end; + + for I := 0 to GetArrayLength(Lines) - 1 do + begin + if Shown >= 6 then Break; + Parts := StringSplit(Lines[I], ['|'], stAll); + if GetArrayLength(Parts) < 4 then Continue; + if Parts[0] <> 'WARN' then Continue; + Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3]; + if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := ''; + if Fix <> '' then Detail := Detail + ' - ' + Fix; + Y := AddRow(Y, 'swatch-warn.bmp', Check, Detail); + Shown := Shown + 1; + end; + + HasBlockers := Fails > 0; + + if Fails > 0 then + begin + SetSwatch(BannerBar, 'swatch-bad.bmp'); + BannerText.Caption := 'This server is not ready'; + BannerSub.Caption := IntToStr(Fails) + ' problem(s) must be fixed before installing.'; + end + else if Warns > 0 then + begin + SetSwatch(BannerBar, 'swatch-warn.bmp'); + BannerText.Caption := 'Ready, with notes'; + BannerSub.Caption := 'Installation can continue. ' + IntToStr(Warns) + + ' item(s) below are worth reading first.'; + end + else + begin + SetSwatch(BannerBar, 'swatch-ok.bmp'); + BannerText.Caption := 'This server is ready'; + BannerSub.Caption := 'Everything needed for ShopDB-Flask is in place.'; + // Nothing to list, so say so rather than leaving an empty white box. + AddRow(ScaleY(4), 'swatch-ok.bmp', 'All checks passed', + 'IIS, HttpPlatformHandler, disk space, ports and the database were all ' + + 'verified. Nothing needs your attention.'); + end; + + if (Shown >= 6) and (Warns > 6 - Fails) then + FooterText.Caption := 'Some notes are not shown. The full check is in the install log.' + else + FooterText.Caption := IntToStr(Passes) + ' checks passed. Nothing has been changed on this server.'; +end; + +// Run the read-only check and paint its results. Called from Next on the welcome +// page, and again whenever the operator fixes something and presses Check again. +// +// Stage 1 changes nothing, so running it before the operator commits to anything +// is free, and it catches the blockers that are expensive later: IIS absent, +// locked config sections, the port in use, MySQL 5.6 missing its index flags. +procedure RunPreflight; +var + I, ResultCode: Integer; + Output: TExecOutput; + PreflightScript, Detail: String; + HasBlockers: Boolean; +begin + // The bundle is NOT extracted yet at wizard time - [Files] is processed during + // the install step. Pull just this one script out of the compressed payload. + ExtractTemporaryFile('shopdb-preflight.ps1'); + PreflightScript := ExpandConstant('{tmp}\shopdb-preflight.ps1'); + + // Visible feedback for the few seconds the check takes. SW_HIDE means the + // operator sees nothing at all otherwise, and a frozen wizard reads as a + // crash. ProgressBar.Style := npbstMarquee because the duration is unknown - + // a bar that sits at 0% is worse than no bar. + CheckingPage.SetText('Looking at IIS, disk, ports and the database...', ''); + CheckingPage.SetProgress(0, 0); + CheckingPage.ProgressBar.Style := npbstMarquee; + CheckingPage.Show; + try + // ExecAndCaptureOutput (6.4.0+) hands back stdout and stderr as string arrays, + // so output never touches disk. That deletes BOTH bugs this page shipped with: + // - Exec() has no shell, so "> file" went to PowerShell as a literal + // argument and no file was ever written (page came up empty); + // - PowerShell 5.1 writes UTF-16LE, and LoadStringFromFile reads bytes as + // ANSI, so the page rendered as garbage with a null between characters. + // Neither failure mode can recur now: there is no file and no encoding step. + // 64-bit PowerShell - see PowerShellPath. The IIS checks fail without it. + ExecAndCaptureOutput(PowerShellPath, + '-NoProfile -ExecutionPolicy Bypass -File "' + PreflightScript + '" -Delimited', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Output); + + // Every run repaints from scratch. The old code rendered ONCE and latched, + // so a re-check could not have shown a different answer even if the operator + // had fixed everything. + while DetailPanel.ControlCount > 0 do + DetailPanel.Controls[0].Free; + + PreflightDone := True; + if (not Output.Error) and (GetArrayLength(Output.StdOut) > 0) then + begin + RenderPreflight(Output.StdOut, HasBlockers); + PreflightBlocked := HasBlockers; + end + else + begin + // stderr is captured SEPARATELY, so a failure can report what actually + // went wrong instead of only an exit code. + Detail := ''; + for I := 0 to GetArrayLength(Output.StdErr) - 1 do + Detail := Detail + Output.StdErr[I] + ' '; + if Trim(Detail) = '' then Detail := 'No error output was produced.'; + SetSwatch(BannerBar, 'swatch-warn.bmp'); + BannerText.Caption := 'Could not check this server'; + BannerSub.Caption := 'Nothing has been verified. Continuing is a risk.'; + AddRow(ScaleY(4), 'swatch-warn.bmp', + 'Check did not run (exit ' + IntToStr(ResultCode) + ')', Detail); + FooterText.Caption := 'Nothing has been changed on this server.'; + // A check that could not RUN is not a check that PASSED. It does not block - + // there is no evidence of a problem - but it must not read like a green light. + PreflightBlocked := False; + end; + finally + CheckingPage.Hide; + end; +end; + +procedure RecheckClick(Sender: TObject); +begin + RunPreflight; +end; + +// The port the operator chose, for the [Icons]/[Run] entries. A subpath install +// is reached on the parent site's port, and the console works that out from +// MOUNT_PATH in .env, so the value only has to be right for the own-site case. +function SitePortValue(Param: String): String; +begin + Result := SitePage.Values[1]; + if Trim(Result) = '' then Result := '8090'; +end; + +// Where an EXISTING install would be, for the pre-fill reads. +// +// Not WizardDirValue() alone: these pages are inserted after wpWelcome and so +// run BEFORE the directory page, where that value can still be empty - and an +// empty base silently turned every pre-fill into "no existing install", which +// is why the Address page kept defaulting to 8090 on a /shopdb server. +// Falls back to the compile-time default, which is also the only directory the +// operator could have meant at that point in the wizard. +function InstalledDir: String; +begin + Result := WizardDirValue; + if Trim(Result) = '' then Result := ExpandConstant('{#DefaultDir}'); + Result := AddBackslash(Result); + Log('[shopdb] pre-fill base directory: ' + Result); +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + + if CurPageID = wpWelcome then + RunPreflight; + + // The results page BLOCKS while anything is failing. It used to be advisory: + // the operator read "IIS is not installed", pressed Next, answered five more + // pages, and the install then died partway through with Python already on the + // box. Refusing here costs them nothing - the server is untouched at this + // point - and the page already says what to do about each failure. + if CurPageID = PreflightPage.ID then + begin + if PreflightBlocked then + begin + MsgBox('This server is not ready yet.' + #13#10#13#10 + + 'Each problem above says what to do about it. Fix them, then choose ' + + '"Check again".' + #13#10#13#10 + + 'Nothing has been changed on this server.', mbError, MB_OK); + Result := False; + end; + end; + + if CurPageID = DbChoicePage.ID then + UseBundledDb := (DbChoicePage.SelectedValueIndex = DB_BUNDLED); + + if CurPageID = DbDetailsPage.ID then + begin + if not UseBundledDb then + begin + if DbDetailsPage.Values[0] = '' then + begin + MsgBox('Enter the database host.', mbError, MB_OK); + Result := False; + end + else if (DbDetailsPage.Values[4] = '') and + (not FileExists(InstalledDir + '.env')) then + begin + // Only required on a FRESH install. On an upgrade, blank means "keep the + // password already in .env", so the operator never has to know it. + MsgBox('Enter the password for the application database user.', mbError, MB_OK); + Result := False; + end; + end; + end; +end; + +// Pre-tick the boxes the first time the page appears. On an upgrade the existing +// site-profile.json is already on disk, so the page opens showing exactly what +// this server has today - and ticking another box adds it. +procedure CurPageChanged(CurPageID: Integer); +var + I, Count: Integer; + Existing, Creds, Rest: String; + Lines: TArrayOfString; +begin + // Pre-fill the database page from the EXISTING .env on an upgrade. Its + // defaults are 127.0.0.1 / shopdb_flask, so a site whose database lives on + // another server would otherwise have its real connection string overwritten + // by defaults the operator never looked at - and .env was the only record. + // Pre-select the method this server already uses. Without this, an upgrade of + // a /shopdb install would sit on the default and create a SECOND deployment - + // a new site on 8090 beside the existing Application, same directory, wrong + // SPA build, MOUNT_PATH still set. Read it from .env, which is the same value + // wsgi.py mounts on. + if (CurPageID = DeployPage.ID) and (not DeployPageReady) then + begin + DeployPageReady := True; + if LoadStringsFromFile(InstalledDir + '.env', Lines) then + for I := 0 to GetArrayLength(Lines) - 1 do + if Pos('MOUNT_PATH=', Lines[I]) = 1 then + begin + Log('[shopdb] existing install is a subpath deployment: ' + Lines[I]); + DeployPage.SelectedValueIndex := 1; + DeployPage.SubCaptionLabel.Caption := + 'This server is currently published under its existing address. ' + + 'Changing this will move where people reach ShopDB-Flask.'; + end; + end; + + if (CurPageID = DbDetailsPage.ID) and (not DbPageReady) then + begin + DbPageReady := True; + if LoadStringsFromFile(InstalledDir + '.env', Lines) then + for I := 0 to GetArrayLength(Lines) - 1 do + if Pos('DATABASE_URL=', Lines[I]) = 1 then + begin + // mysql+pymysql://USER:PASS@HOST:PORT/NAME?charset=... + Existing := Copy(Lines[I], Pos('//', Lines[I]) + 2, Length(Lines[I])); + if Pos('@', Existing) > 0 then + begin + Creds := Copy(Existing, 1, Pos('@', Existing) - 1); + Rest := Copy(Existing, Pos('@', Existing) + 1, Length(Existing)); + if Pos(':', Creds) > 0 then + DbDetailsPage.Values[3] := Copy(Creds, 1, Pos(':', Creds) - 1); + if Pos(':', Rest) > 0 then + begin + DbDetailsPage.Values[0] := Copy(Rest, 1, Pos(':', Rest) - 1); + Rest := Copy(Rest, Pos(':', Rest) + 1, Length(Rest)); + if Pos('/', Rest) > 0 then + begin + DbDetailsPage.Values[1] := Copy(Rest, 1, Pos('/', Rest) - 1); + Rest := Copy(Rest, Pos('/', Rest) + 1, Length(Rest)); + if Pos('?', Rest) > 0 then Rest := Copy(Rest, 1, Pos('?', Rest) - 1); + DbDetailsPage.Values[2] := Rest; + end; + end; + end; + // Password intentionally left blank: blank means "keep the current + // one", so an upgrade never needs the operator to know it. + DbDetailsPage.Values[4] := ''; + DbDetailsPage.SubCaptionLabel.Caption := + 'These are the settings this server is using now. Leave the password ' + + 'blank to keep the current one.'; + end; + end; + + if (CurPageID = PluginPage.ID) and (not PluginPageReady) then + begin + PluginPageReady := True; + Existing := ''; + // Read instance\plugins.json - the PLUGIN REGISTRY, which is what is actually + // installed. site-profile.json only records the last SELECTION, and the two + // drift: unticking a plugin shrinks the profile but does NOT uninstall it + // (apply-profile never removes), so the profile would show a plugin as absent + // while its tables and data are still on the server. + // + // NOT ExpandConstant('{app}') here: this page runs BEFORE the directory page + // and {app} is not initialised yet - expanding it raises "attempt was made to + // expand the app constant before it was initialized". WizardDirValue() is the + // chosen directory and is safe at any point. + if LoadStringsFromFile(InstalledDir + 'instance\plugins.json', Lines) then + for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I] + else if LoadStringsFromFile(InstalledDir + 'site-profile.json', Lines) then + // Fallback for an install predating the registry, or a partial install. + for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I]; + + Count := GetArrayLength(PluginNames); + if PluginPage.CheckListBox.Items.Count < Count then + Count := PluginPage.CheckListBox.Items.Count; + for I := 0 to Count - 1 do + if Existing <> '' then + // Crude but sufficient: the profile lists plugins as quoted strings. + PluginPage.Values[I] := (Pos('"' + PluginNames[I] + '"', Existing) > 0) + else + PluginPage.Values[I] := PluginDefault(PluginNames[I]); + end; +end; + +function UsingSubpath: Boolean; +begin + Result := ('{#SubpathAlias}' <> '') and (DeployPage.SelectedValueIndex = 1); +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + // A bundle without a subpath build cannot offer the choice at all. + if (PageID = DeployPage.ID) and ('{#SubpathAlias}' = '') then + begin + Result := True; + Exit; + end; + // The bundled path generates its own credentials, so asking for them would be + // meaningless - and any value typed here would be silently ignored. + Result := (PageID = DbDetailsPage.ID) and UseBundledDb; +end; + +// Runs from ssPostInstall, NOT PrepareToInstall. +// +// PrepareToInstall fires BEFORE the [Files] section is processed, so +// {tmp}\shopdb-bundle does not exist yet and powershell.exe fails on a missing +// -File path (exit -196608 / 0xFFFD0000). Everything here depends on the +// extracted bundle, so it has to run after the install step. + +// Set the "Run as administrator" bit on a .lnk (byte 21, flag 0x20). +// +// The console needs Administrator to read IIS state. The script self-elevates, +// but that spawns a SECOND window after a UAC prompt, which is easy to miss and +// looks broken. Marking the shortcut makes Windows elevate up front: one window, +// one prompt. +// +// Inno cannot set this flag, and its file helpers are string-based rather than +// binary safe, so use PowerShell - already a dependency here. +procedure MarkShortcutRunAs(const LnkPath: String); +var + ResultCode: Integer; + Cmd: String; +begin + if not FileExists(LnkPath) then Exit; + Cmd := '-NoProfile -ExecutionPolicy Bypass -Command "' + + '$p='''' + LnkPath + ''''; ' + + '$b=[IO.File]::ReadAllBytes($p); ' + + '$b[21]=$b[21] -bor 0x20; ' + + '[IO.File]::WriteAllBytes($p,$b)"'; + Exec(PowerShellPath, Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +function RunInstallStages: String; +var + ResultCode: Integer; + PwFile, Args, Common: String; +begin + Result := ''; + Common := '-BundleRoot "' + ExpandConstant('{tmp}\shopdb-bundle') + '"' + + ' -AppRoot "' + ExpandConstant('{app}') + '"' + + ' -SiteHost "' + SitePage.Values[0] + '"' + + ' -SitePort ' + SitePage.Values[1] + + ' -OnFailure never' + + ' -ClientIpSource ' + ClientIpSourceArg + + ' -SitePlugins "' + SelectedPlugins + '"'; + + // Subpath deployment: an IIS Application under the existing site instead of a + // site of its own. Appended here because Pascal has no conditional expression. + if UsingSubpath then + Common := Common + ' -MountAlias "{#SubpathAlias}"'; + + if UseBundledDb then + begin + if not RunPowerShell('-Stage 0 ' + Common, ResultCode) or (ResultCode <> 0) then + begin + if FailCause <> '' then + Result := FailCause + #13#10#13#10 + FailDetail + else + Result := 'The bundled MySQL database could not be installed (exit ' + + IntToStr(ResultCode) + ').'; + Result := Result + #13#10#13#10 + 'Full details are in:' + #13#10 + + ExpandConstant('{log}'); + Exit; + end; + // Stage 0 leaves an ACL'd handoff file that stage 2 picks up, so no password + // is passed here or anywhere else. + Args := Common; + end + else + begin + // The password goes via an ACL'd FILE, never on the command line: command + // lines are readable by any user through Win32_Process and are captured in + // PowerShell transcripts. The installer shreds the file after reading it. + // No password typed on an upgrade means "leave .env alone" - so send no + // password file, and the installer keeps the existing DATABASE_URL. + if DbDetailsPage.Values[4] <> '' then + begin + PwFile := ExpandConstant('{tmp}\dbpw.txt'); + SaveStringToFile(PwFile, DbDetailsPage.Values[4] + #13#10, False); + end + else + PwFile := ''; + Args := Common + + ' -DbHost "' + DbDetailsPage.Values[0] + '"' + + ' -DbPort ' + DbDetailsPage.Values[1] + + ' -DbName "' + DbDetailsPage.Values[2] + '"' + + ' -DbUser "' + DbDetailsPage.Values[3] + '"'; + if PwFile <> '' then Args := Args + ' -DbPasswordFile "' + PwFile + '"'; + end; + + if not RunPowerShell('-Stage all ' + Args, ResultCode) or (ResultCode <> 0) then + begin + if FailCause <> '' then + Result := FailCause + #13#10#13#10 + FailDetail + else + Result := 'The installation could not be completed (exit ' + + IntToStr(ResultCode) + ').'; + // NOT "nothing was left running". The stages run with -OnFailure never, so + // nothing is rolled back: whatever had been created by the failing point is + // still there. Claiming otherwise sent operators away believing the server + // was clean when it was half-configured, and the next thing they did was + // install again on top of it. + Result := Result + #13#10#13#10 + + 'This server has been part-configured. Whatever had been done ' + + 'before the failure is still in place.' + #13#10#13#10 + + 'What to do:' + #13#10 + + ' - Fix the cause above, then run this installer again. Re-running ' + + 'is safe and picks up where it left off.' + #13#10 + + ' - Or remove it entirely from Settings > Apps.' + #13#10#13#10 + + 'Full details, including everything that was created, are in:' + #13#10 + + ExpandConstant('{log}'); + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + Failure, FinalUrl: String; +begin + if CurStep = ssPostInstall then + begin + if UsingSubpath then + FinalUrl := 'http://' + SitePage.Values[0] + '/{#SubpathAlias}/login' + else + FinalUrl := 'http://' + SitePage.Values[0] + ':' + SitePage.Values[1] + '/login'; + + Failure := RunInstallStages; + + // After the stages: the shortcuts exist by now, and this is cosmetic enough + // that it must never be able to fail the install. + MarkShortcutRunAs(ExpandConstant('{group}\ShopDB-Flask Console.lnk')); + MarkShortcutRunAs(ExpandConstant('{group}\Restart ShopDB-Flask.lnk')); + MarkShortcutRunAs(ExpandConstant('{group}\Back up the database.lnk')); + // NOTE: never start a line with #13#10. The Inno PREPROCESSOR treats any line + // whose first non-blank character is '#' as a directive and fails with + // "Unknown preprocessor directive" before Pascal parsing happens. Keep the + // concatenation operator at the start of continuation lines instead. + if Failure <> '' then + begin + MsgBox(Failure, mbCriticalError, MB_OK); + // The final page is headed "ShopDB-Flask is ready" from [Messages]. After a + // failed install that is the last thing the operator reads, and it + // contradicts the error box they just dismissed. Setup cannot be made to + // fail from here, so at least stop it claiming success. + WizardForm.FinishedHeadingLabel.Caption := 'ShopDB-Flask is NOT installed'; + WizardForm.FinishedLabel.Caption := + 'The installation did not complete. This server has been part-configured.' + + #13#10#13#10 + + 'Fix the problem reported above and run this installer again - re-running ' + + 'is safe. Or remove it from Settings > Apps.' + + #13#10#13#10 + + 'The log is at ' + ExpandConstant('{log}'); + end + else + begin + // Recorded so the Start Menu shortcut and the console open the address this + // install actually serves, rather than a compile-time guess. + SaveStringToFile(ExpandConstant('{app}\.installed-url'), FinalUrl, False); + MsgBox('ShopDB-Flask is installed.' + #13#10#13#10 + + 'Open ' + FinalUrl + + #13#10#13#10 + + 'With no user in the database that page offers to create the first ' + + 'administrator and then runs the setup wizard.' + + #13#10#13#10 + + 'Day-to-day: use the ShopDB-Flask Console in the Start Menu, or read ' + + ExpandConstant('{app}\docs\OPERATE-WINDOWS.md'), + mbInformation, MB_OK); + end; + end; +end; + +[UninstallRun] +; Delegate to the same tested script rather than duplicating removal logic here. +; It removes the site, app pool, firewall rule and application directory, and +; deliberately does NOT drop the database or uninstall MySQL. +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-install.ps1"" -Stage uninstall -BundleRoot ""{app}"" -AppRoot ""{app}"" -OnFailure never"; \ + RunOnceId: "ShopDBFlaskUninstall"; Flags: waituntilterminated runhidden diff --git a/deploy/windows/installer/build-installer.ps1 b/deploy/windows/installer/build-installer.ps1 new file mode 100644 index 00000000..080632fe --- /dev/null +++ b/deploy/windows/installer/build-installer.ps1 @@ -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 / (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', '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 '' diff --git a/deploy/windows/installer/build-installer.sh b/deploy/windows/installer/build-installer.sh new file mode 100755 index 00000000..9062dc2e --- /dev/null +++ b/deploy/windows/installer/build-installer.sh @@ -0,0 +1,140 @@ +#!/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 [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 [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" < 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" < 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 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 " mysql/ mysql-8.0.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.)" diff --git a/deploy/windows/installer/bundle-lock.json b/deploy/windows/installer/bundle-lock.json new file mode 100644 index 00000000..d56fef7e --- /dev/null +++ b/deploy/windows/installer/bundle-lock.json @@ -0,0 +1,197 @@ +{ + "schema": 1, + "generated": "2026-08-03T15:45:11Z", + "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": { + "wheels": { + "required": true, + "files": { + "jinja2-3.1.6-py3-none-any.whl": { + "size": 134899, + "sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" + }, + "itsdangerous-2.2.0-py3-none-any.whl": { + "size": 16234, + "sha256": "c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef" + }, + "cffi-2.1.0-cp314-cp314-win_amd64.whl": { + "size": 187937, + "sha256": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb" + }, + "email_validator-2.3.0-py3-none-any.whl": { + "size": 35604, + "sha256": "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" + }, + "urllib3-2.7.0-py3-none-any.whl": { + "size": 131087, + "sha256": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" + }, + "flask_jwt_extended-4.7.3-py2.py3-none-any.whl": { + "size": 22698, + "sha256": "905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb" + }, + "flask_sqlalchemy-3.1.1-py3-none-any.whl": { + "size": 25125, + "sha256": "4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0" + }, + "pycparser-3.0-py3-none-any.whl": { + "size": 48172, + "sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" + }, + "requests-2.33.1-py3-none-any.whl": { + "size": 64947, + "sha256": "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" + }, + "blinker-1.9.0-py3-none-any.whl": { + "size": 8458, + "sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc" + }, + "greenlet-3.5.0-cp314-cp314-win_amd64.whl": { + "size": 239835, + "sha256": "3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033" + }, + "marshmallow_sqlalchemy-1.5.0-py3-none-any.whl": { + "size": 16582, + "sha256": "3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258" + }, + "alembic-1.18.4-py3-none-any.whl": { + "size": 263893, + "sha256": "a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a" + }, + "pyjwt-2.12.1-py3-none-any.whl": { + "size": 29726, + "sha256": "28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c" + }, + "mako-1.3.12-py3-none-any.whl": { + "size": 78521, + "sha256": "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9" + }, + "waitress-3.0.2-py3-none-any.whl": { + "size": 56232, + "sha256": "c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e" + }, + "colorama-0.4.6-py2.py3-none-any.whl": { + "size": 25335, + "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + }, + "certifi-2026.4.22-py3-none-any.whl": { + "size": 135707, + "sha256": "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a" + }, + "cachelib-0.13.0-py3-none-any.whl": { + "size": 20914, + "sha256": "8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516" + }, + "pymysql-1.1.3-py3-none-any.whl": { + "size": 45356, + "sha256": "8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289" + }, + "werkzeug-3.1.8-py3-none-any.whl": { + "size": 226459, + "sha256": "63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50" + }, + "idna-3.13-py3-none-any.whl": { + "size": 68629, + "sha256": "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3" + }, + "python_dotenv-1.2.2-py3-none-any.whl": { + "size": 22101, + "sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a" + }, + "flask_marshmallow-1.5.0-py3-none-any.whl": { + "size": 12161, + "sha256": "99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6" + }, + "markupsafe-3.0.3-cp314-cp314-win_amd64.whl": { + "size": 15341, + "sha256": "bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581" + }, + "flask_cors-6.0.2-py3-none-any.whl": { + "size": 13257, + "sha256": "e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a" + }, + "flask_caching-2.4.0-py3-none-any.whl": { + "size": 28727, + "sha256": "d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486" + }, + "tzdata-2026.3-py2.py3-none-any.whl": { + "size": 348168, + "sha256": "dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931" + }, + "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl": { + "size": 159634, + "sha256": "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f" + }, + "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl": { + "size": 2144204, + "sha256": "77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5" + }, + "marshmallow-4.3.0-py3-none-any.whl": { + "size": 49148, + "sha256": "46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46" + }, + "dnspython-2.8.0-py3-none-any.whl": { + "size": 331094, + "sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af" + }, + "typing_extensions-4.15.0-py3-none-any.whl": { + "size": 44614, + "sha256": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + }, + "flask-3.1.3-py3-none-any.whl": { + "size": 103424, + "sha256": "f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c" + }, + "tabulate-0.10.0-py3-none-any.whl": { + "size": 39814, + "sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3" + }, + "mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl": { + "size": 18197576, + "sha256": "5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49" + }, + "Flask_Migrate-4.1.0-py3-none-any.whl": { + "size": 21237, + "sha256": "24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d" + }, + "cryptography-50.0.0-cp311-abi3-win_amd64.whl": { + "size": 3840395, + "sha256": "bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30" + }, + "click-8.3.3-py3-none-any.whl": { + "size": 110502, + "sha256": "a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613" + } + } + }, + "python": { + "required": true, + "files": { + "python-3.14.6-amd64.exe": { + "size": 30774112, + "sha256": "14b3e9a710a3fcf0bd9b55ab6b60412bd91227563f813fc49040cabc0209e0bd" + } + } + }, + "urlrewrite": { + "required": false, + "files": { + "rewrite_amd64_en-US.msi": { + "size": 6078464, + "sha256": "37342ff2f585f263f34f48e9de59eb1051d61015a8e967dbde4075716230a32a" + } + } + }, + "httpplatformhandler": { + "required": true, + "files": { + "httpPlatformHandler_amd64.msi": { + "size": 557056, + "sha256": "90f8d4905a0ab4f2c95223b3c79e2807a0b74507747d240e43c4302e8db4b5ef" + } + } + } + } +} diff --git a/deploy/windows/installer/bundle-lock.ps1 b/deploy/windows/installer/bundle-lock.ps1 new file mode 100644 index 00000000..7cb317ec --- /dev/null +++ b/deploy/windows/installer/bundle-lock.ps1 @@ -0,0 +1,233 @@ +<# +.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 = '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. + param([string] $Dir) + $out = @{} + if (-not (Test-Path $Dir)) { return $out } + $root = (Resolve-Path $Dir).Path.TrimEnd('\', '/') + foreach ($f in (Get-ChildItem $Dir -Recurse -File)) { + $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' + 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 + 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) +} diff --git a/deploy/windows/installer/make-branding.py b/deploy/windows/installer/make-branding.py new file mode 100644 index 00000000..e7637a2f --- /dev/null +++ b/deploy/windows/installer/make-branding.py @@ -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() diff --git a/deploy/windows/installer/refresh-bundle-lock.ps1 b/deploy/windows/installer/refresh-bundle-lock.ps1 new file mode 100644 index 00000000..95849c8a --- /dev/null +++ b/deploy/windows/installer/refresh-bundle-lock.ps1 @@ -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' diff --git a/deploy/windows/installer/shopdb-admin.ps1 b/deploy/windows/installer/shopdb-admin.ps1 new file mode 100644 index 00000000..4307aee3 --- /dev/null +++ b/deploy/windows/installer/shopdb-admin.ps1 @@ -0,0 +1,816 @@ +<# +.SYNOPSIS + Day-to-day control of a ShopDB-Flask installation, on the server itself. + +.DESCRIPTION + Installed alongside the application so an operator never has to open IIS + Manager, hunt for a log, or remember an appcmd incantation. + + Run it with no arguments for a menu, or pass a command directly: + + .\shopdb-admin.ps1 status + .\shopdb-admin.ps1 restart + .\shopdb-admin.ps1 backup D:\backups + + Everything here is safe to run at any time EXCEPT backup/restore, which are + called out explicitly. + +.NOTES + Written for stock Windows PowerShell 5.1. No modules to install. +#> + +[CmdletBinding()] +param( + [ValidateSet('menu','status','start','stop','restart','logs','open','backup', + 'check','sessions','plugins','add-plugin','verify','uninstall')] + [string] $Command = 'menu', + [string] $Path = '', + # Machine-readable output for 'check'. The people running this are expected to + # ask an AI assistant for help, and pasting a screenshot of a console into a + # chat window loses most of what matters. One structured blob they can paste + # gives the assistant real state to reason about instead of guesses. + [switch] $Json, + [string] $AppRoot = 'C:\shopdb-flask', + [string] $SiteName = 'shopdb-flask', + [string] $AppPool = 'shopdbflask', + [int] $SitePort = 8090 +) + +$ErrorActionPreference = 'Continue' +$AppCmd = Join-Path $env:windir 'System32\inetsrv\appcmd.exe' + +# --- bitness --------------------------------------------------------------- +# IIS's management COM objects are 64-BIT ONLY. Under the 32-bit PowerShell, +# Import-Module WebAdministration SUCCEEDS but Get-Website then fails with +# Retrieving the COM class factory ... REGDB_E_CLASSNOTREG +# which this script caught and reported as "cannot read IIS" - indistinguishable +# from IIS being absent. +# +# A 32-bit launcher is easy to end up with: Inno Setup is a 32-bit process, so +# anything it starts gets SysWOW64 powershell through WOW64 redirection. Rather +# than fix every launcher, relaunch under the native PowerShell. 'Sysnative' is +# the alias that lets a 32-bit process reach the real System32, and it exists +# ONLY for 32-bit processes - hence the guard. + +# Every relaunch below must carry the ORIGINAL arguments through. They used to be +# dropped, so a console started from the Start Menu with -AppRoot D:\shopdb +# relaunched itself with the C:\shopdb-flask default and reported a healthy +# install as missing - and the operator had done nothing wrong. +function Get-ForwardedArgs { + $forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass', + '-File', ('"' + $PSCommandPath + '"'), $Command) + if ($Path) { $forward += @('-Path', ('"' + $Path + '"')) } + if ($AppRoot) { $forward += @('-AppRoot', ('"' + $AppRoot + '"')) } + if ($SiteName) { $forward += @('-SiteName', ('"' + $SiteName + '"')) } + if ($AppPool) { $forward += @('-AppPool', ('"' + $AppPool + '"')) } + if ($SitePort) { $forward += @('-SitePort', $SitePort) } + if ($Json) { $forward += '-Json' } + return $forward +} + +if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) { + $native = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe' + if (Test-Path $native) { + Start-Process -FilePath $native -ArgumentList (Get-ForwardedArgs) -Wait -NoNewWindow + return + } +} + +# --- elevation ------------------------------------------------------------- +# Reading IIS state needs Administrator: the WebAdministration module and the +# IIS: drive both fail without it. Unelevated, this tool used to report +# "IIS not available / application pool: not installed", which reads as "your +# install is broken" when it actually means "I cannot see it". Relaunch elevated +# instead of reporting a false state. +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +# 'open' just launches a browser. Prompting for administrator to do that trains +# people to click through UAC, and the Start Menu shortcut uses this command. +if ($Command -ne 'open' -and + -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Host '' + Write-Host ' Administrator rights are needed to read IIS state.' -ForegroundColor Yellow + Write-Host ' Re-launching elevated - approve the prompt.' -ForegroundColor Yellow + $argList = @('-NoExit') + (Get-ForwardedArgs) + try { + Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs | Out-Null + } catch { + Write-Host '' + Write-Host ' Elevation was declined.' -ForegroundColor Red + Write-Host ' Right-click the shortcut and choose "Run as administrator".' -ForegroundColor Red + Read-Host ' Press Enter to close' | Out-Null + } + return +} + + +# --- brand header ---------------------------------------------------------- +# Deliberately typographic, NOT ASCII art. The GE monogram is fine cursive +# linework; rendered as block characters at console resolution it reads as noise, +# which looks worse than no mark at all. A clean rule and correct wordmark says +# "considered"; mushy art says the opposite. +# +# Box-drawing characters used here are all in code page 437 (the Windows console +# default), and this file is saved UTF-8 WITH BOM so PowerShell 5.1 reads them +# correctly rather than assuming the ANSI code page. +function Show-Banner { + $rule = ([string][char]0x2500) * 58 + Write-Host '' + Write-Host ' GE AEROSPACE' -ForegroundColor Blue + Write-Host (' ' + $rule) -ForegroundColor DarkGray + Write-Host ' ShopDB-Flask' -ForegroundColor White + Write-Host ' Asset management for the shop floor' -ForegroundColor DarkGray + Write-Host '' +} + +function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c } +function Head { + param($m) + Write-Host '' + Write-Host (' ' + $m) -ForegroundColor Cyan + Write-Host (' ' + (([string][char]0x2500) * $m.Length)) -ForegroundColor DarkGray +} + +function Get-EnvValue { + param([string] $Key) + $envFile = Join-Path $AppRoot '.env' + if (-not (Test-Path $envFile)) { return '' } + $line = Get-Content $envFile | Where-Object { $_ -like "$Key=*" } | Select-Object -First 1 + if ($line) { return $line.Substring($Key.Length + 1) } + return '' +} + +function Get-Deployment { + <# + Which way was this installed? + + Method A: its own IIS site on $SitePort. + Method B: an IIS Application under an existing site, at /, reached + on that site's port. MOUNT_PATH in .env is what distinguishes + them - it is the same value wsgi.py uses to mount the app. + + Without this the console looked for a SITE that method B never creates and + reported "web site: not installed" on a perfectly healthy server, then + probed the wrong port and said it was not responding. + #> + $mount = (Get-EnvValue 'MOUNT_PATH').Trim() + if (-not $mount) { + return @{ Subpath = $false; Alias = ''; BaseUrl = "http://localhost:$SitePort"; Port = $SitePort } + } + $alias = $mount.Trim('/') + $port = 80 + $parent = 'Default Web Site' + try { + Import-Module WebAdministration -ErrorAction Stop + foreach ($site in (Get-Website)) { + $app = Get-WebApplication -Site $site.Name -Name $alias -ErrorAction SilentlyContinue + if ($app) { + $parent = $site.Name + $b = $site.bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 + if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $port = [int]$Matches[1] } + break + } + } + } catch { } + $base = "http://localhost:$port/$alias" + if ($port -eq 80) { $base = "http://localhost/$alias" } + return @{ Subpath = $true; Alias = $alias; Parent = $parent; BaseUrl = $base; Port = $port } +} + +function Get-DbParts { + # Pull host/port/name/user out of DATABASE_URL without printing the password. + $url = Get-EnvValue 'DATABASE_URL' + if ($url -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') { + # The password is PERCENT-ENCODED in DATABASE_URL (the installer applies + # [uri]::EscapeDataString). SQLAlchemy unescapes it; so must we. Without + # this, every command here fails to authenticate whenever the password + # contains a space, @, %, ! or /, and the console reports the database as + # unreachable on a server where the application is running perfectly. + return @{ User = [uri]::UnescapeDataString($Matches[1]) + Pass = [uri]::UnescapeDataString($Matches[2]) + Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] } + } + return $null +} + +function Find-MysqlClient { + $candidates = @( + 'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe', + 'C:\mysql56\bin\mysql.exe' + ) + @(Get-ChildItem 'C:\Program Files\MySQL' -Filter mysql.exe -Recurse -EA SilentlyContinue | + Select-Object -ExpandProperty FullName) + foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } } + return '' +} + +# -------------------------------------------------------------------------- +function Show-Status { + Head 'Status' + + $deploy = Get-Deployment + $siteState = 'not installed' + $poolState = 'not installed' + try { + Import-Module WebAdministration -ErrorAction Stop + if ($deploy.Subpath) { + # Method B: there is no site of our own - we are an Application. + $app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue + if ($app) { + $parentSite = Get-Website -Name $deploy.Parent -ErrorAction SilentlyContinue + $siteState = ("/{0} under '{1}' ({2})" -f $deploy.Alias, $deploy.Parent, + $(if ($parentSite) { $parentSite.State } else { 'unknown' })) + } + } else { + $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue + if ($site) { $siteState = $site.State } + } + if (Test-Path "IIS:\AppPools\$AppPool") { $poolState = (Get-Item "IIS:\AppPools\$AppPool").State } + } catch { + # Should be unreachable now that the script self-elevates, but if IIS is + # genuinely absent say THAT, rather than implying ShopDB is missing. + # Name the real cause instead of implying ShopDB is missing. + if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) { + $siteState = 'IIS is not installed on this server' + } else { + $siteState = 'could not read IIS - ' + $_.Exception.Message + } + $poolState = $siteState + } + + $colour = if (($siteState -eq 'Started') -or ($siteState -like '*Started*')) { 'Green' } else { 'Yellow' } + Say (" published as : {0}" -f $siteState) $colour + Say (" application pool: {0}" -f $poolState) $colour + Say (" installed at : {0}" -f $(if (Test-Path $AppRoot) { $AppRoot } else { 'not found' })) + + # Does it actually answer? A "Started" pool proves nothing. Under method B + # this must include the mount path, or it tests a URL that never existed. + $url = $deploy.BaseUrl + '/' + try { + $r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 20 + Say (" responding : yes (HTTP {0})" -f $r.StatusCode) 'Green' + } catch { + Say ' responding : NO' 'Red' + Say (" {0}" -f $_.Exception.Message) 'DarkGray' + } + + $db = Get-DbParts + if ($db) { + Say (" database : {0} on {1}:{2} as {3}" -f $db.Name, $db.Host, $db.Port, $db.User) + $mysql = Find-MysqlClient + if ($mysql) { + $q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';" + $n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null + if ($LASTEXITCODE -eq 0) { Say (" tables : {0}" -f $n) 'Green' } + else { Say ' tables : could not connect' 'Red' } + } + } else { Say ' database : no .env found' 'Yellow' } + + # Is anyone set up yet? + try { + $na = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/api/setup/needs-admin') -UseBasicParsing -TimeoutSec 15 + if ($na.Content -match '"needsadmin"\s*:\s*true') { + Say ' first run : NOT SET UP - open the site to create the first administrator' 'Yellow' + } else { Say ' first run : complete' 'Green' } + } catch { } + + Say '' + if ($deploy.Subpath) { + $shown = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias + if ($deploy.Port -ne 80) { $shown = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias } + } else { + $shown = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort + } + Say (" open with : {0}" -f $shown) 'White' +} + +function Start-App { + Head 'Starting' + $deploy = Get-Deployment + & $AppCmd start apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" } + if (-not $deploy.Subpath) { + & $AppCmd start site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" } + } else { + # Method B shares the parent site - starting or stopping THAT would take + # every other application on this server with it. + Say (" (published under '{0}' - only the application pool is ours to control)" -f $deploy.Parent) 'DarkGray' + } + Say ' started' 'Green' +} + +function Stop-App { + Head 'Stopping' + $deploy = Get-Deployment + Say ' ShopDB-Flask will be unavailable until you start it again.' 'Yellow' + if (-not $deploy.Subpath) { + & $AppCmd stop site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" } + } else { + Say (" (leaving site '{0}' running - stopping it would take down everything else on it)" -f $deploy.Parent) 'DarkGray' + } + & $AppCmd stop apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" } + Say ' stopped' 'Yellow' +} + +function Restart-App { + Head 'Restarting' + # Recycle rather than stop/start: it drains existing requests instead of + # cutting them off, and it is what a config change actually needs. + & $AppCmd recycle apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" } + Start-Sleep -Seconds 2 + try { + $r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30 + Say (" back up (HTTP {0})" -f $r.StatusCode) 'Green' + } catch { Say ' did not respond after restart - run: shopdb-admin.ps1 logs' 'Red' } +} + +function Show-Logs { + Head 'Recent logs' + $appLog = Join-Path $AppRoot 'logs' + if (Test-Path $appLog) { + $newest = Get-ChildItem "$appLog\*.log" -EA SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($newest) { + Say (" application: {0}" -f $newest.FullName) 'White' + Get-Content $newest.FullName -Tail 20 | ForEach-Object { Say " $_" } + } else { Say ' no application log yet' } + } + $inst = 'C:\ProgramData\ShopDB-Flask\logs' + if (Test-Path $inst) { + $newest = Get-ChildItem "$inst\*.log" -EA SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($newest) { + Say '' + Say (" install: {0}" -f $newest.FullName) 'White' + Get-Content $newest.FullName -Tail 12 | ForEach-Object { Say " $_" } + } + } +} + +function Open-Site { + # The installer records the address it actually published at. Prefer it: this + # command runs unelevated, and .env is ACL'd, so working the address out from + # MOUNT_PATH and IIS may not be possible from here. + $recorded = Join-Path $AppRoot '.installed-url' + if (Test-Path $recorded) { + $u = (Get-Content $recorded -TotalCount 1).Trim() + if ($u) { Start-Process $u; Say " opened $u" 'Green'; return } + } + $deploy = Get-Deployment + if ($deploy.Subpath) { + if ($deploy.Port -eq 80) { $u = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias } + else { $u = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias } + } else { + $u = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort + } + Start-Process $u + Say " opened $u" 'Green' +} + +function Backup-Db { + param([string] $Dest) + Head 'Database backup' + $db = Get-DbParts + if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return } + if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' } + if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null } + + $mysql = Find-MysqlClient + if (-not $mysql) { Say ' mysql client not found' 'Red'; return } + $dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe' + if (-not (Test-Path $dump)) { Say ' mysqldump not found' 'Red'; return } + + $file = Join-Path $Dest ("shopdb_flask-{0}.sql" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) + Say (" writing {0}" -f $file) + # Redirect rather than --result-file: keeps it working on 5.6 and 8.0 alike. + # Do NOT pipe mysqldump through the PowerShell pipeline into Out-File. + # PowerShell 5.1 writes a UTF-8 BOM and re-encodes the stream through the + # console code page, producing a dump MySQL refuses to load and mangling any + # non-ASCII data - discovered only when the backup is finally needed. + # Redirect the process's stdout straight to the file instead. + $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", + '--single-transaction', '--routines', '--triggers', $db.Name) + $quoted = $args | ForEach-Object { + if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ } + } + $err = [System.IO.Path]::GetTempFileName() + try { + $proc = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow ` + -RedirectStandardOutput $file -RedirectStandardError $err + if ($proc.ExitCode -ne 0) { + Say (" mysqldump failed (exit {0})" -f $proc.ExitCode) 'Red' + Get-Content $err -Tail 3 -EA SilentlyContinue | ForEach-Object { Say (" " + $_) 'DarkGray' } + return + } + } finally { Remove-Item $err -Force -ErrorAction SilentlyContinue } + + # Verify it is complete rather than merely present. + $tail = @(Get-Content $file -Tail 5 -ErrorAction SilentlyContinue) + if ((Test-Path $file) -and ((Get-Item $file).Length -gt 1024) -and ($tail -match 'Dump completed')) { + Say (" done - {0:N1} MB, verified complete" -f ((Get-Item $file).Length / 1MB)) 'Green' + + # The dump is NOT everything. Uploaded branding, map blueprints and + # generated files live in instance\ on disk, not in the database, so a + # restore from the .sql alone comes back with no floor map. Saying "all of + # your asset data" was true and misleading at the same time. + $instance = Join-Path $AppRoot 'instance' + if (Test-Path $instance) { + $zip = [System.IO.Path]::ChangeExtension($file, $null) + 'instance.zip' + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + if (Test-Path $zip) { Remove-Item $zip -Force } + [System.IO.Compression.ZipFile]::CreateFromDirectory($instance, $zip) + Say (" uploaded files: {0:N1} MB -> {1}" -f ((Get-Item $zip).Length / 1MB), (Split-Path $zip -Leaf)) 'Green' + } catch { + Say (" could not archive instance\: {0}" -f $_.Exception.Message) 'Yellow' + Say ' copy it by hand - it holds branding and floor-map images' 'Yellow' + } + } + + Say '' + Say ' Store BOTH files off this server. The .sql holds the records; the' 'Yellow' + Say ' .zip holds uploaded branding and floor-map images, which the' 'Yellow' + Say ' database does not. A restore needs both.' 'Yellow' + Say '' + Say ' Contains user password hashes - treat it as sensitive.' 'Yellow' + } else { + Say ' backup is empty or truncated - do NOT rely on it' 'Red' + Remove-Item $file -Force -ErrorAction SilentlyContinue + } +} + +function Get-CheckState { + <# + Everything an outside reader needs to reason about this server, gathered + without changing anything. Secrets are NEVER included: the database password + lives in DATABASE_URL and this reports the host, port, name and user only. + #> + $deploy = Get-Deployment + $db = Get-DbParts + $state = [ordered]@{ + collected = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + computername = $env:COMPUTERNAME + approot = $AppRoot + installed = (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py')) + version = '' + publishedas = if ($deploy.Subpath) { "subpath /$($deploy.Alias) under '$($deploy.Parent)'" } else { "own site '$SiteName' on port $SitePort" } + baseurl = $deploy.BaseUrl + apppool = 'unknown' + poolstate = 'unknown' + sitestate = 'unknown' + responding = $false + httpstatus = 0 + database = $null + pythonversion = '' + plugins = @() + sbomcomponents = 0 + errors = @() + } + foreach ($pair in @(@('version', '.installed-version'))) { + $f = Join-Path $AppRoot $pair[1] + if (Test-Path $f) { $state[$pair[0]] = (Get-Content $f -TotalCount 1).Trim() } + } + $state.apppool = $AppPool + try { + Import-Module WebAdministration -ErrorAction Stop + if (Test-Path "IIS:\AppPools\$AppPool") { $state.poolstate = (Get-Item "IIS:\AppPools\$AppPool").State.ToString() } + if ($deploy.Subpath) { + $app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue + $state.sitestate = if ($app) { 'application present' } else { 'application MISSING' } + } else { + $s = Get-Website -Name $SiteName -ErrorAction SilentlyContinue + $state.sitestate = if ($s) { $s.State.ToString() } else { 'site MISSING' } + } + } catch { $state.errors += "IIS: $($_.Exception.Message)" } + + try { + $r = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/') -UseBasicParsing -TimeoutSec 20 + $state.responding = $true + $state.httpstatus = [int] $r.StatusCode + } catch { $state.errors += "HTTP: $($_.Exception.Message)" } + + if ($db) { + $state.database = [ordered]@{ host = $db.Host; port = $db.Port; name = $db.Name; user = $db.User; reachable = $false } + $mysql = Find-MysqlClient + if ($mysql) { + $q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';" + $n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null + if ($LASTEXITCODE -eq 0) { $state.database.reachable = $true; $state.database.tables = [int] $n } + } else { $state.errors += 'no mysql client found, database not probed' } + } else { $state.errors += 'no .env found' } + + $py = Join-Path $AppRoot 'venv\Scripts\python.exe' + if (Test-Path $py) { $state.pythonversion = (& $py -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null | Select-Object -First 1) } + + $dir = Join-Path $AppRoot 'plugins' + if (Test-Path $dir) { + $state.plugins = @(Get-ChildItem $dir -Directory -EA SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } | + Select-Object -ExpandProperty Name) + } + $sbom = Join-Path $AppRoot 'sbom.cdx.json' + if (Test-Path $sbom) { + try { $state.sbomcomponents = (Get-Content $sbom -Raw | ConvertFrom-Json).components.Count } catch { } + } + return $state +} + +function Invoke-Check { + if ($Json) { + # ONLY the JSON goes to stdout, so it can be redirected to a file or piped + # without a banner in the middle of the document. + Get-CheckState | ConvertTo-Json -Depth 6 + return + } + + Head 'Health check' + $flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' + if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return } + Push-Location $AppRoot + $env:FLASK_APP = 'shopdb' + try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } } + finally { Pop-Location } + Say '' + Say ' For help from an AI assistant, paste the output of:' 'DarkGray' + Say ' shopdb-admin.ps1 check -Json' 'White' +} + +function Show-Sessions { + Head 'Worker processes' + $w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue + if (-not $w) { Say ' no IIS worker running (the site starts one on first request)' } + else { + foreach ($p in $w) { + Say (" pid {0} {1:N0} MB started {2}" -f $p.ProcessId, + ($p.WorkingSetSize/1MB), $p.CreationDate) + } + } + $py = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -EA SilentlyContinue | + Where-Object { $_.CommandLine -like "*$AppRoot*" } + if ($py) { foreach ($p in $py) { Say (" python pid {0} {1:N0} MB" -f $p.ProcessId, ($p.WorkingSetSize/1MB)) } } +} + + +# Set by every Invoke-Flask call. Callers MUST test this rather than +# $LASTEXITCODE: when flask.exe is missing no native command runs at all, so +# $LASTEXITCODE keeps whatever value it had from something earlier - which reads +# as success and made a command that did nothing report that it had worked. +$script:LastFlaskExit = 0 + +function Invoke-Flask { + param([string[]] $Arguments) + $flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' + if (-not (Test-Path $flask)) { + Say ' application not installed' 'Red' + $script:LastFlaskExit = 127 + return $null + } + Push-Location $AppRoot + $env:FLASK_APP = 'shopdb' + # The app logs plugin startup to STDERR even on success; with EAP=Stop that + # becomes a terminating error and a healthy command looks like a failure. + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + & $flask @Arguments 2>&1 + $script:LastFlaskExit = $LASTEXITCODE + } + finally { $ErrorActionPreference = $prev; Pop-Location } +} + +function Show-Plugins { + Head 'Plugins' + Invoke-Flask @('plugin','list') | ForEach-Object { Say " $_" } + + # What is on disk but NOT installed can still be added here. What is absent + # from disk cannot - see the note below. + $dir = Join-Path $AppRoot 'plugins' + if (Test-Path $dir) { + $onDisk = (Get-ChildItem $dir -Directory -EA SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } | + Select-Object -ExpandProperty Name) -join ', ' + Say '' + Say (" shipped in this build : {0}" -f $onDisk) 'White' + } + Say '' + Say ' Add one that is shipped : shopdb-admin.ps1 add-plugin -Path ' 'White' + Say '' + Say ' A plugin NOT listed above is not on this server at all. This build was' 'DarkGray' + Say ' made for your site''s chosen plugin set, so its code was never shipped.' 'DarkGray' + Say ' Adding one means a new installer built from an updated site profile.' 'DarkGray' +} + +function Add-Plugin { + param([string] $Name) + Head 'Add a plugin' + if (-not $Name) { Say ' usage: shopdb-admin.ps1 add-plugin -Path ' 'Yellow'; return } + + $dir = Join-Path $AppRoot ('plugins\' + $Name) + if (-not (Test-Path (Join-Path $dir 'manifest.json'))) { + Say (" '{0}' is not present on this server." -f $Name) 'Red' + Say '' + Say ' This build ships only the plugins your site chose. Adding a new one' 'Yellow' + Say ' requires a new installer built from an updated site profile - the' 'Yellow' + Say ' code is not here to install.' 'Yellow' + Say '' + Say ' Run "shopdb-admin.ps1 plugins" to see what IS available.' 'White' + return + } + + # apply-profile, not plugin install. Five plugins ship default_enabled=false, + # so `install` alone left them installed-but-disabled: the command printed a + # green success line and the feature did not appear anywhere in the UI. + # apply-profile installs AND enables, and pulls in the dependency closure. + Say (" adding {0}..." -f $Name) + $profilePath = Join-Path $AppRoot 'site-profile.json' + $applied = $false + if (Test-Path $profilePath) { + try { + $profile = Get-Content $profilePath -Raw | ConvertFrom-Json + $wanted = @($profile.plugins) + if ($wanted -notcontains $Name) { $wanted += $Name } + $profile.plugins = $wanted + $profile | ConvertTo-Json -Depth 6 | Set-Content -Path $profilePath -Encoding UTF8 + Invoke-Flask @('plugin','apply-profile',$profilePath) | ForEach-Object { Say " $_" } + $applied = ($script:LastFlaskExit -eq 0) + } catch { Say (" could not update site-profile.json: {0}" -f $_.Exception.Message) 'Yellow' } + } + if (-not $applied) { + # No profile on disk, or apply-profile failed: fall back, but enable + # explicitly so the outcome is the same either way. + Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say " $_" } + $installed = ($script:LastFlaskExit -eq 0) + Invoke-Flask @('plugin','enable',$Name) | ForEach-Object { Say " $_" } + $applied = $installed -and ($script:LastFlaskExit -eq 0) + } + if (-not $applied) { + # Do NOT print the green line on a failure. It used to be unconditional, + # so a failed add reported success and the operator went looking for a + # feature that was never enabled. + Say (" {0} was NOT added - see the output above" -f $Name) 'Red' + return + } + + Say ' applying its database migrations...' + Invoke-Flask @('plugin','upgrade-all') | ForEach-Object { Say " $_" } + if ($script:LastFlaskExit -ne 0) { + Say ' migrations FAILED - the feature is installed but its tables are not' 'Red' + Say ' do not use it until this is resolved; restore a backup if needed' 'Red' + return + } + Say ' restarting so its routes register...' + Restart-App + Say (" {0} added" -f $Name) 'Green' +} + +function Invoke-Verify { + <# + Prove the installed dependencies are still the ones that shipped. + + Two independent records, both written at install time: + requirements.txt a sha256 for every wheel, checked by pip on install + bundle-lock.json the exact third-party payload of the bundle + + This re-checks what can still be checked on a live server. It reports; it + changes nothing. + #> + Head 'Verify' + + $lock = Join-Path $AppRoot 'bundle-lock.json' + if (Test-Path $lock) { + try { + $l = Get-Content $lock -Raw | ConvertFrom-Json + Say (" installed from : {0} bundle ({1}, {2})" -f $l.generated, $l.pythontag, $l.platform) + } catch { Say ' bundle-lock.json is present but unreadable' 'Yellow' } + } else { + Say ' no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow' + } + + # The SBOM travels with the application, because a server on a vaulted + # network cannot be scanned from anywhere else. When a CVE lands, this is + # what answers "is that component here, and at what version" without needing + # the build box, the internet, or anyone's memory. + $sbom = Join-Path $AppRoot 'sbom.cdx.json' + if (Test-Path $sbom) { + try { + $b = Get-Content $sbom -Raw | ConvertFrom-Json + $shipped = @($b.components | Where-Object { $_.scope -eq 'required' }).Count + Say (" components : {0} ({1} shipped), CycloneDX {2}" -f ` + $b.components.Count, $shipped, $b.specVersion) + Say (" bill of materials: {0}" -f $sbom) 'DarkGray' + Say ' search it with : shopdb-admin.ps1 verify -Path ' 'DarkGray' + } catch { Say ' sbom.cdx.json is present but unreadable' 'Yellow' } + + # A named component turns this into the actual question being asked. + # Guarded on $b: an unreadable SBOM leaves it unset, and querying it then + # would report 'none', which reads as "you are not affected". + if ($Path -and $b) { + Say '' + Say (" matches for '{0}':" -f $Path) 'Cyan' + $hits = @($b.components | Where-Object { $_.name -like ('*' + $Path + '*') }) + if (-not $hits) { Say ' none - this server does not carry it' 'Green' } + foreach ($h in $hits) { + $tag = if ($h.scope -eq 'required') { 'SHIPPED' } else { 'build only' } + Say (" {0,-40} {1,-14} {2}" -f $h.name, $h.version, $tag) ` + $(if ($h.scope -eq 'required') { 'Yellow' } else { 'DarkGray' }) + } + } + } else { + Say ' no SBOM recorded (installed before SBOMs shipped, or by hand)' 'Yellow' + } + + # pip's own audit. It re-reads the metadata of what is actually installed and + # reports anything missing or version-inconsistent, which is the part that + # can still drift after install - a hand-run `pip install` on the server. + $py = Join-Path $AppRoot 'venv\Scripts\python.exe' + if (-not (Test-Path $py)) { Say ' no venv - application not installed' 'Red'; return } + + Say '' + Say ' checking installed packages against requirements.txt...' + $req = Join-Path $AppRoot 'requirements.txt' + if (-not (Test-Path $req)) { Say ' requirements.txt is missing from the install' 'Red'; return } + + $wanted = @{} + foreach ($line in (Get-Content $req)) { + if ($line -match '^([A-Za-z0-9._-]+)==([^ \\]+)') { $wanted[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] } + } + $frozen = @{} + foreach ($line in (& $py -m pip freeze 2>$null)) { + if ($line -match '^([A-Za-z0-9._-]+)==(.+)$') { $frozen[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] } + } + + $bad = 0 + foreach ($name in ($wanted.Keys | Sort-Object)) { + if (-not $frozen.ContainsKey($name)) { + Say (" MISSING {0} {1}" -f $name, $wanted[$name]) 'Red'; $bad++ + } elseif ($frozen[$name] -ne $wanted[$name]) { + Say (" DIFFERS {0} {1} installed, {2} expected" -f $name, $frozen[$name], $wanted[$name]) 'Red'; $bad++ + } + } + if ($bad -eq 0) { + Say (" all {0} packages match" -f $wanted.Count) 'Green' + } else { + Say '' + Say (" {0} package(s) do not match what shipped." -f $bad) 'Red' + Say ' Something was installed or upgraded on this server by hand. Re-run the' 'Yellow' + Say ' installer to put the shipped set back.' 'Yellow' + } + + & $py -m pip check 2>&1 | ForEach-Object { Say " $_" 'DarkGray' } +} + +function Show-Uninstall { + Head 'Uninstall' + Say ' Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.' + Say '' + Say ' That removes the web site, application pool, firewall rule and files.' + Say ' It does NOT drop the database and does NOT uninstall MySQL.' 'Yellow' + Say ' Take a backup first: shopdb-admin.ps1 backup' 'Yellow' +} + +function Show-Menu { + $first = $true + while ($true) { + if ($first) { Show-Banner; $first = $false } + Show-Status + Write-Host '' + Write-Host ' 1 Restart the application 6 Back up the database' -ForegroundColor White + Write-Host ' 2 Stop the application 7 Worker processes' -ForegroundColor White + Write-Host ' 3 Start the application 8 Open in browser' -ForegroundColor White + Write-Host ' 4 View recent logs 9 Plugins' -ForegroundColor White + Write-Host ' 5 Health check V Verify this install' -ForegroundColor White + Write-Host ' 0 Exit' -ForegroundColor White + Write-Host '' + $c = Read-Host ' Choose' + switch ($c) { + '1' { Restart-App } '2' { Stop-App } '3' { Start-App } + '4' { Show-Logs } '5' { Invoke-Check } '6' { Backup-Db $Path } + '7' { Show-Sessions } '8' { Open-Site } + 'v' { Invoke-Verify } 'V' { Invoke-Verify } + '9' { Show-Plugins + $add = Read-Host ' Name of a shipped plugin to add (Enter to skip)' + if ($add) { Add-Plugin $add } } + '0' { return } + default { Say ' not a choice' 'Yellow' } + } + Write-Host '' + Read-Host ' Press Enter to continue' | Out-Null + Clear-Host + Show-Banner + } +} + +switch ($Command) { + 'status' { Show-Banner; Show-Status } + 'start' { Start-App } + 'stop' { Stop-App } + 'restart' { Restart-App } + 'logs' { Show-Logs } + 'open' { Open-Site } + 'backup' { Backup-Db $Path } + 'check' { Invoke-Check } + 'sessions' { Show-Sessions } + 'plugins' { Show-Plugins } + 'add-plugin'{ Add-Plugin $Path } + 'verify' { Invoke-Verify } + 'uninstall' { Show-Uninstall } + default { Show-Menu } +} diff --git a/deploy/windows/installer/shopdb-install.ps1 b/deploy/windows/installer/shopdb-install.ps1 new file mode 100644 index 00000000..feae3a81 --- /dev/null +++ b/deploy/windows/installer/shopdb-install.ps1 @@ -0,0 +1,2104 @@ +<# +.SYNOPSIS + ShopDB-Flask offline installer for Windows (IIS + HttpPlatformHandler + waitress). + +.DESCRIPTION + Automates docs/INSTALL-WINDOWS-IIS.md. Fully offline: every prerequisite comes + from the bundle directory. Never contacts the network. + + Stages (each can be run alone with -Stage): + 1 preflight - read-only discovery (shopdb-preflight.ps1) + 2 runtime - Python, venv, wheels, .env + 3 data - schema, seeds, plugins + 4 iis - app pool, ACLs, site, firewall + 5 verify - smoke test + handoff + all - 2,3,4,5 in order (default) + + Written for stock Windows PowerShell 5.1. No pwsh-only syntax, no modules, + no network. + +.PARAMETER BundleRoot + Directory holding the offline bundle. Expected layout: + python\python-3.14.x-amd64.exe + wheels\*.whl + app\ (release tree: wsgi.py, shopdb\, plugins\, frontend\dist\, ...) + httpplatformhandler\httpPlatformHandler_amd64.msi + urlrewrite\ (optional, -ClientIpSource direct) + mysql\ (optional, bundled-database option) + bundle-lock.json + bundle-lock.ps1 + + The payload is checked against bundle-lock.json before anything runs. A + bundle whose wheels or MSIs do not match the lock exactly - missing, extra or + altered - is refused, because every one of them executes as SYSTEM here. + +.PARAMETER DbHost + Existing-MySQL option: the server hostname. The PASSWORD IS NEVER A PARAMETER - + it is prompted for as a SecureString, because Windows command lines are + readable by any user (Win32_Process) and are captured in PowerShell + transcripts and history. If a valid .env already exists it is reused and + nothing is prompted. + +.EXAMPLE + .\shopdb-install.ps1 -BundleRoot D:\shopdb-bundle -SiteHost shopdb.plant.local + .\shopdb-install.ps1 -Stage 4 -BundleRoot D:\shopdb-bundle + +.NOTES + Idempotent. Re-running preserves an existing .env (regenerating JWT_SECRET_KEY + would invalidate every issued session). +#> + +[CmdletBinding()] +param( + # Stage 0 installs the bundled MySQL 8.0 (greenfield sites only). It is NOT in + # 'all': a site with an existing MySQL must never have a second server dropped + # on top of it, and that is the common case (docs/DEPLOY-WINDOWS-IIS.md: the + # target box already runs the classic ASP shopdb against MySQL 5.6). Run it + # explicitly, and only when preflight reports no MySQL on 3306. + [ValidateSet('0','1','2','3','4','5','all','uninstall')] [string] $Stage = 'all', + # Bundled-MySQL (stage 0) settings. The root and app passwords are GENERATED, + # never supplied: see New-Secret. Root is shown once and not persisted. + [string] $MysqlRoot = 'C:\Program Files\MySQL\MySQL Server 8.0', + [string] $MysqlDataDir = 'C:\ProgramData\MySQL\data', + [string] $MysqlIni = 'C:\ProgramData\MySQL\my.ini', + [string] $MysqlService = 'MySQL80', + [Parameter(Mandatory=$true)] [string] $BundleRoot, + [string] $AppRoot = 'C:\shopdb-flask', + [int] $SitePort = 8090, + [string] $SiteName = 'shopdb-flask', + [string] $AppPool = 'shopdbflask', + [string] $SiteHost = '', # for CORS_ORIGINS; defaults to this machine + # Deliberately NOT a password parameter. Command lines are readable by any + # user via Win32_Process and are captured in PowerShell transcripts. + # Supply host/user/db here; the password is prompted for. + [string] $DbHost = '', + [int] $DbPort = 3306, + [string] $DbName = 'shopdb_flask', + [string] $DbUser = 'shopdb', + # Unattended installs only. Path to a file whose FIRST LINE is the DB password. + # The installer reads it, overwrites it and deletes it. ACL the file to + # SYSTEM + Administrators before writing it. Omit this for an interactive run + # and the password is prompted for instead. It is never a parameter value: + # command lines are world-readable via Win32_Process and are captured in + # PowerShell transcripts and ConsoleHost_history.txt. + [string] $DbPasswordFile = '', + # The site's plugin set is DECLARED in a profile (ADR-013), not listed here. + # The bundle is built lean for that same profile by scripts/build-site.sh, so + # a plugin the site did not choose is absent from the backend tree entirely. + # That absence is what makes core's `try: from plugins.X.models import ... + # except ImportError` guards correct: the import genuinely fails, no + # relationship backref is created, and no query joins the missing table. + # + # Do NOT go back to installing a fixed list against a full tree. That was the + # cause of the 500s on /api/assets and /api/dashboard/summary + # ("Table 'shopdb_flask.measuringtools' doesn't exist"): the plugin's CODE + # shipped regardless, so the import succeeded, SQLAlchemy created the + # Asset.measuringtool backref and LEFT OUTER JOINed a table that only exists + # if the plugin was installed. + [string] $SiteProfile = '', + # Comma-separated plugin names chosen at INSTALL time (the wizard's plugin + # page). When supplied this REWRITES site-profile.json before apply-profile + # runs, so the operator's choice wins over whatever the bundle was built with. + # Omit it and the profile shipped in the bundle is used unchanged. + [string] $SitePlugins = '', + # Subpath deployment (docs/INSTALL-WINDOWS-IIS.md method B). Empty = the app + # gets its own IIS site on -SitePort (method A, the default). Set to an alias + # such as 'shopdb' and it becomes an IIS Application under -ParentSite, i.e. + # http:///shopdb/ - no new DNS record, no port in the URL. + # The alias MUST match the one the bundle's subpath SPA was built with; the + # installer checks that and refuses if they disagree. + [string] $MountAlias = '', + [string] $ParentSite = 'Default Web Site', + # How this server learns a request's real client IP. + # + # direct IIS is exposed to clients. Install URL Rewrite from the bundle + # and set X-Forwarded-For from REMOTE_ADDR. Overwriting the header + # is what stops a client spoofing it. + # proxy A reverse proxy (ARR, load balancer) sits in front and already + # sets X-Forwarded-For. Leave the rule off - REMOTE_ADDR would be + # the proxy, so applying it would DESTROY the real client IP. + # + # Without one or the other, IIS sends no X-Forwarded-For at all and every + # client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard + # visitor-location lookup and per-host login rate limiting all break quietly. + [ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct', + # Required before this installer will alter an installation it did not + # create. Everything here is built for a greenfield server: it makes its own + # Python, its own venv and its own IIS objects, and its upgrade path assumes + # the thing it is upgrading came out of a previous run. + # + # Pointed at a server that was deployed by hand, the IIS reconciliation would + # DELETE the existing application or site as part of switching deployment + # method - silently, because removing the artifact of the method you are not + # using is correct behaviour when the installer owns both. It is not correct + # when someone else built it. So it asks first. + [switch] $AdoptExisting, + [switch] $WhatIfOnly, + # Unattended runs cannot answer a prompt. Choose the failure behaviour up front. + [ValidateSet('ask','always','never')] [string] $OnFailure = 'ask' +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +# NOT $env:TEMP. When this runs under the Inno wrapper, TEMP points at Setup's +# own extraction directory, which is deleted when Setup exits - so the log the +# operator is told to send to support disappears with it, and a failed install +# leaves nothing to diagnose. ProgramData persists and is admin-writable. +$script:LogDir = Join-Path $env:ProgramData 'ShopDB-Flask\logs' +try { if (-not (Test-Path $script:LogDir)) { New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null } } +catch { $script:LogDir = $env:TEMP } +$script:LogPath = Join-Path $script:LogDir ("shopdb-install-{0}.log" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) +$script:Created = New-Object System.Collections.ArrayList # for rollback +# Was this server already stamped by a previous run of THIS installer, as observed +# BEFORE stage 2 writes its own stamp? Stage 4 needs the answer to know whether the +# IIS objects it is about to reconcile are its own. $null means nothing has looked +# yet, which is the case when stage 4 is run on its own. +$script:PreexistingInstallStamp = $null + +function Write-Log { + param([string] $Message, [string] $Level = 'INFO') + $line = "{0} [{1}] {2}" -f (Get-Date -Format 'HH:mm:ss'), $Level, $Message + Add-Content -Path $script:LogPath -Value $line + $colour = 'Gray' + if ($Level -eq 'OK') { $colour = 'Green' } + if ($Level -eq 'WARN') { $colour = 'Yellow' } + if ($Level -eq 'FAIL') { $colour = 'Red' } + if ($Level -eq 'STEP') { $colour = 'Cyan' } + Write-Host $line -ForegroundColor $colour +} + +function Fail { + param([string] $Message, [string] $Fix = '') + Write-Log $Message 'FAIL' + if ($Fix) { Write-Log " fix: $Fix" 'FAIL' } + Write-Log "log: $script:LogPath" + throw $Message +} + +function Track { param([string] $Kind, [string] $Id) $null = $script:Created.Add(@{Kind=$Kind; Id=$Id}) } + +function Invoke-Native { + # Runs an external command and fails loudly. + # + # Three PowerShell 5.1 traps are handled here, each found the hard way on a + # real Server 2025 box: + # 1. `& $exe @array` can collapse the array into ONE argument. Start-Process + # -ArgumentList binds arrays correctly. + # 2. Native tools write progress to stderr on SUCCESS, and + # $ErrorActionPreference='Stop' turns that into a terminating error. + # 3. Argument VALUES must never be logged: a DATABASE_URL or password would + # land in the log we invite operators to send to support. + param([string] $Exe, [string[]] $Arguments, [string] $What, [string] $StdinFile = '', + [int] $TimeoutSec = 0) + Write-Log (" exec {0} ({1} args)" -f (Split-Path $Exe -Leaf), $Arguments.Count) + # Start-Process -ArgumentList joins the array with spaces WITHOUT quoting, so + # any argument containing whitespace gets split into several. That is how + # "IIS AppPool\name:(OI)(CI)RX" became the parameter "IIS" (icacls exit 87), + # and how TargetDir=C:\Program Files\... installed Python into C:\Program\. + $quoted = $Arguments | ForEach-Object { + if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ } + } + $so = [System.IO.Path]::GetTempFileName() + $se = [System.IO.Path]::GetTempFileName() + try { + if ($StdinFile) { + # Feeding SQL as a file on stdin, not as -e "source ": the mysql + # client reads that path as a DATABASE NAME and fails with + # ERROR 1049 Unknown database ''. + $p = Start-Process -FilePath $Exe -ArgumentList $quoted -Wait -PassThru ` + -NoNewWindow -RedirectStandardInput $StdinFile ` + -RedirectStandardOutput $so -RedirectStandardError $se + } else { + $p = Start-Process -FilePath $Exe -ArgumentList $quoted -Wait -PassThru ` + -NoNewWindow -RedirectStandardOutput $so -RedirectStandardError $se + } + # A hung child must not hang the install. msiexec in particular deadlocks + # when a second Windows Installer transaction holds the global mutex: two + # msiexec processes sat there for 40 minutes with no output and no error, + # and -Wait alone gives you no way out. + if ($TimeoutSec -gt 0 -and -not $p.HasExited) { + if (-not $p.WaitForExit($TimeoutSec * 1000)) { + try { $p.Kill() } catch { } + Fail "$What timed out after $TimeoutSec seconds" @' +The command was killed. For an MSI this usually means another Windows Installer +transaction held the global mutex: check for stray msiexec processes +(Get-Process msiexec), end them, then re-run this stage. +'@ + } + } + $out = @() + if (Test-Path $so) { $out += Get-Content $so -ErrorAction SilentlyContinue } + if (Test-Path $se) { $out += Get-Content $se -ErrorAction SilentlyContinue } + if ($p.ExitCode -ne 0) { + $out | Select-Object -Last 8 | ForEach-Object { Write-Log " $_" 'FAIL' } + Fail "$What failed (exit $($p.ExitCode))" + } + return $out + } + finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue } +} + +function Protect-File { + # Owner-only ACL: Administrators + SYSTEM, inheritance broken. + param([string] $Path) + Invoke-Native 'icacls.exe' @($Path,'/inheritance:r', + '/grant','BUILTIN\Administrators:(F)','/grant','NT AUTHORITY\SYSTEM:(F)') 'ACL' +} + +function New-Secret { + param([int] $Bytes = 48) + $b = New-Object byte[] $Bytes + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b) + # URL-safe, no padding: avoids characters that need escaping in .env or URLs. + return [Convert]::ToBase64String($b).Replace('+','-').Replace('/','_').TrimEnd('=') +} + +# Paths derived once. +$Py = Join-Path $AppRoot 'venv\Scripts\python.exe' +$Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' +$Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe' +$EnvFile = Join-Path $AppRoot '.env' +$WheelDir = Join-Path $BundleRoot 'wheels' +$AppSource = Join-Path $BundleRoot 'app' + + +# ============================================================================= +# Version awareness and database safety (upgrades) +# ============================================================================= +# An upgrade is just this installer run against an existing install. That is +# only safe if three things are true, and none of them were before: +# - it knows whether it is going forwards or backwards; +# - the database is backed up BEFORE migrations touch it; +# - a failed migration puts the backup back rather than leaving a half-state. + +function Assert-BundleIntegrity { + <# + Refuse to run against a payload that is not exactly what was built and + reviewed. + + This runs BEFORE anything is installed, because everything downstream is an + executable that runs as SYSTEM on this server: the Python installer, the + HttpPlatformHandler and URL Rewrite MSIs, and ~40 wheels. requirements.txt + hashes cover the wheels once pip gets to them; nothing covered the rest, + and nothing noticed a stale extra wheel sitting in the wheelhouse. + + There is deliberately no override. A bundle that fails here was altered + after it was built, and the fix is to get a correct bundle rather than to + wave this one through on a server nobody can see. + #> + $checker = Join-Path $BundleRoot 'bundle-lock.ps1' + $lockFile = Join-Path $BundleRoot 'bundle-lock.json' + if (-not (Test-Path $checker)) { + Fail 'bundle-lock.ps1 is missing from the bundle' ` + 'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.' + } + if (-not (Test-Path $lockFile)) { + Fail 'bundle-lock.json is missing from the bundle' ` + 'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.' + } + . $checker + $lock = Read-BundleLock $lockFile + $problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock + if ($problems.Count -gt 0) { + foreach ($p in $problems) { Write-Log " $p" 'FAIL' } + Fail ("the bundle does not match bundle-lock.json ({0} problem(s))" -f $problems.Count) @' +The payload was changed after this installer was built. Do not install from it. +Obtain a bundle whose payload matches its lock, or rebuild one and re-compile. +'@ + } + Write-Log ("bundle payload verified against bundle-lock.json ({0}, {1})" -f ` + (Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'OK' +} + +function Get-WheelhousePythonTag { + # Which Python the wheelhouse was built for. The lock is authoritative; a + # bundle predating it is read from the wheel filenames instead. + $lockFile = Join-Path $BundleRoot 'bundle-lock.json' + if (Test-Path $lockFile) { + $tag = Get-JsonProperty (Get-Content $lockFile -Raw | ConvertFrom-Json) 'pythontag' + if ($tag -and ($tag -match '^cp(\d)(\d+)$')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) } + } + $wheel = Get-ChildItem $WheelDir -Filter '*.whl' -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '-cp(\d)(\d+)-' } | Select-Object -First 1 + if ($wheel -and ($wheel.Name -match '-cp(\d)(\d+)-')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) } + return '' +} + +function Assert-VenvMatchesWheelhouse { + <# + An existing venv is REUSED rather than rebuilt, which is right for a repair + or an upgrade of an install this made. It is wrong when the venv belongs to + a different Python: the wheelhouse is tagged for one minor version, so pip + finds no candidate for cffi, cryptography, greenlet or mysql-connector and + dies partway through - after Python has already been installed and the app + tree already replaced. + + Fail before any of that, and say which two versions disagree. + #> + if (-not (Test-Path $Py)) { return } # greenfield: nothing to disagree with + $wanted = Get-WheelhousePythonTag + if (-not $wanted) { return } # unknowable; the pip failure will have to do + + $found = '' + try { + $found = (& $Py -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>$null | Select-Object -First 1) + if ($found) { $found = $found.Trim() } + } catch { return } + if (-not $found -or ($found -eq $wanted)) { return } + + Fail ("the existing venv is Python {0}, but this bundle's wheelhouse is for {1}" -f $found, $wanted) @' +The wheelhouse is locked to one Python minor version. Installing it into a venv +built by a different one fails at the first compiled package, halfway through. + +On a server this installer built: delete APP_ROOT\venv and re-run. The venv is +rebuilt from the bundle and holds nothing of yours. + +On a server someone else built: this is not an upgrade, it is a runtime change. +Take a database backup first, and expect to reconcile web.config by hand. +'@ +} + +function Get-BundleVersion { + # The version being installed, read from the payload itself so it can never + # disagree with the code that is about to be copied. + $init = Join-Path $AppSource 'shopdb\__init__.py' + if (-not (Test-Path $init)) { return '' } + $m = Select-String -Path $init -Pattern "^__version__\s*=\s*'([^']+)'" | Select-Object -First 1 + if ($m) { return $m.Matches[0].Groups[1].Value } + return '' +} + +function Get-InstalledVersion { + $f = Join-Path $AppRoot '.installed-version' + if (Test-Path $f) { return (Get-Content $f -TotalCount 1).Trim() } + # An install predating version stamping still has an app tree. + if (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py')) { return 'unknown' } + return '' +} + +function Compare-Version { + # -1 / 0 / 1, comparing dotted numeric versions. 'unknown' sorts as older so + # a stamped bundle over an unstamped install is treated as an upgrade. + param([string] $A, [string] $B) + if ($A -eq $B) { return 0 } + if ($A -eq 'unknown' -or $A -eq '') { return -1 } + if ($B -eq 'unknown' -or $B -eq '') { return 1 } + $x = @($A -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') }) + $y = @($B -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') }) + for ($i = 0; $i -lt [Math]::Max($x.Count, $y.Count); $i++) { + $xi = if ($i -lt $x.Count) { $x[$i] } else { 0 } + $yi = if ($i -lt $y.Count) { $y[$i] } else { 0 } + if ($xi -gt $yi) { return 1 } + if ($xi -lt $yi) { return -1 } + } + return 0 +} + +function Find-MysqlTool { + param([string] $Name) # mysql.exe or mysqldump.exe + # The BUNDLE first, then the install directory, then the local server. + # + # A site whose MySQL lives on another host has no client installed here at + # all, so mysqldump was never found - which meant every upgrade skipped the + # pre-upgrade backup, and it did so AFTER stage 2 had stopped the pool and + # replaced the application tree. Shipping the client makes the backup work + # on a remote-database site, which is the case that needs it most. + $bundled = @( + (Join-Path $BundleRoot ('mysqlclient\' + $Name)), + (Join-Path $AppRoot ('mysqlclient\' + $Name)) + ) + foreach ($b in $bundled) { if (Test-Path $b) { return $b } } + + $roots = @('C:\Program Files\MySQL', 'C:\mysql56\bin', 'C:\Program Files (x86)\MySQL') + foreach ($r in $roots) { + if (Test-Path $r) { + $hit = Get-ChildItem $r -Filter $Name -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($hit) { return $hit.FullName } + } + } + return '' +} + +function Get-DbFromEnv { + $line = Get-Content $EnvFile -ErrorAction SilentlyContinue | + Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1 + if ($line -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') { + return @{ User = $Matches[1]; Pass = [uri]::UnescapeDataString($Matches[2]) + Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] } + } + return $null +} + + +function Test-DatabaseEmpty { + <# + Is the TARGET DATABASE empty right now? + + This - not the presence of C:\shopdb-flask - is what decides whether it is + safe to force-drop tables. A rebuilt server pointed at a site's existing, + populated database has no app directory, so filesystem-based detection + called it a "fresh install" and ran prune-schema --yes --force, destroying + the rows of every unselected plugin with no backup taken. + + Returns $true ONLY when we positively confirm zero tables. Anything we + cannot determine returns $false, because the failure mode of guessing + "empty" is irreversible data loss and the failure mode of guessing + "populated" is merely a redundant backup. + #> + $probe = Join-Path $env:TEMP ("shopdb-dbprobe-{0}.py" -f (Get-Date -Format 'HHmmssfff')) + @' +import os, sys +try: + from sqlalchemy import create_engine, inspect + url = None + for line in open(".env", encoding="utf-8", errors="replace"): + if line.startswith("DATABASE_URL="): + url = line.split("=", 1)[1].strip() + if not url: + print("UNKNOWN"); sys.exit(0) + print("EMPTY" if len(inspect(create_engine(url)).get_table_names()) == 0 else "POPULATED") +except Exception: + print("UNKNOWN") +'@ | Set-Content -Path $probe -Encoding ASCII + try { + Push-Location $AppRoot + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { $out = & $Py $probe 2>&1 } finally { $ErrorActionPreference = $prev; Pop-Location } + $verdict = ($out | Where-Object { $_ -match '^(EMPTY|POPULATED|UNKNOWN)$' } | Select-Object -Last 1) + Write-Log "database probe: $verdict" + return ($verdict -eq 'EMPTY') + } + finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue } +} + +function Backup-Database { + # Returns the backup path, or '' if it could not be taken. + param([string] $Reason = 'pre-upgrade') + $db = Get-DbFromEnv + if (-not $db) { Write-Log 'cannot read DATABASE_URL; skipping backup' 'WARN'; return '' } + $dump = Find-MysqlTool 'mysqldump.exe' + if (-not $dump) { Write-Log 'mysqldump not found; skipping backup' 'WARN'; return '' } + + # A dump contains every row, including the users table and its password + # hashes. ProgramData is readable by every user on the box by default, so the + # directory is locked to Administrators and SYSTEM the moment it is created. + $dir = Join-Path $env:ProgramData 'ShopDB-Flask\backups' + if (-not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + Protect-File $dir + } + $file = Join-Path $dir ("{0}-{1}-{2}.sql" -f $db.Name, $Reason, (Get-Date -Format 'yyyyMMdd-HHmmss')) + + Write-Log "backing up $($db.Name) before migrating" + # --single-transaction keeps it consistent without locking the whole server. + # Password goes in the argument array, never interpolated into a log line. + $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", + '--single-transaction', '--routines', '--triggers', $db.Name) + $so = [System.IO.Path]::GetTempFileName() + $se = [System.IO.Path]::GetTempFileName() + try { + # Start-Process -ArgumentList does NOT quote, so a password containing a + # space splits into several arguments and the dump fails. Quote anything + # with whitespace, exactly as Invoke-Native does. + $quoted = $args | ForEach-Object { + if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ } + } + Write-Log (" exec mysqldump.exe ({0} args)" -f $args.Count) + $p = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow ` + -RedirectStandardOutput $so -RedirectStandardError $se + if ($p.ExitCode -ne 0) { + Write-Log "mysqldump exited $($p.ExitCode); no usable backup" 'WARN' + return '' + } + + # VALIDATE before anything relies on this file. mysqldump can exit 0 and + # still leave a truncated dump - a full disk, or a table it could not + # read part-way through. A backup that is trusted but unusable is worse + # than no backup at all, because migrations then proceed on its strength. + if (-not (Test-Path $so)) { Write-Log 'mysqldump produced no file' 'WARN'; return '' } + $len = (Get-Item $so).Length + if ($len -lt 512) { + Write-Log "backup is only $len bytes; refusing to treat it as valid" 'WARN'; return '' + } + $tail = @(Get-Content $so -Tail 5 -ErrorAction SilentlyContinue) + if (-not ($tail -match 'Dump completed')) { + Write-Log 'backup lacks the mysqldump completion marker - it is truncated' 'WARN' + return '' + } + + Move-Item $so $file -Force + $mb = [math]::Round((Get-Item $file).Length / 1MB, 1) + Write-Log "backup written and verified complete: $file ($mb MB)" 'OK' + return $file + } + finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue } +} + +function Restore-Database { + param([string] $BackupFile) + if (-not $BackupFile -or -not (Test-Path $BackupFile)) { + Write-Log 'no backup available to restore from' 'FAIL'; return $false + } + $db = Get-DbFromEnv + $mysql = Find-MysqlTool 'mysql.exe' + if (-not $db -or -not $mysql) { Write-Log 'cannot restore: client or .env missing' 'FAIL'; return $false } + Write-Log "restoring $($db.Name) from $BackupFile" 'WARN' + $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", $db.Name) + try { + # mysqldump emits DROP TABLE IF EXISTS before each CREATE, so replaying + # genuinely restores every table the dump contains. + Invoke-Native $mysql $args 'database restore' -StdinFile $BackupFile | Out-Null + } catch { Write-Log "restore failed: $($_.Exception.Message)" 'FAIL'; return $false } + + # What the replay CANNOT undo: tables the failed migration created that the + # dump knows nothing about. MySQL DDL is not transactional, so those CREATEs + # are already committed and no dump-replay removes them. + # + # We report them rather than dropping them. Dropping would mean deciding, on + # a machine holding a site's only copy of its data, that a table is safe to + # destroy - and the installer deliberately does not claim that authority. + # (Restoring into a side database instead is not available either: the app + # user has rights on this database only, not CREATE DATABASE.) + $dumped = @() + foreach ($line in (Get-Content $BackupFile -ErrorAction SilentlyContinue)) { + if ($line -match '^CREATE TABLE `([^`]+)`') { $dumped += $Matches[1] } + } + + $probe = Join-Path $env:TEMP ("shopdb-tables-{0}.py" -f (Get-Date -Format 'HHmmssfff')) + @' +import sys +try: + from sqlalchemy import create_engine, inspect + url = None + for line in open(".env", encoding="utf-8", errors="replace"): + if line.startswith("DATABASE_URL="): + url = line.split("=", 1)[1].strip() + for t in sorted(inspect(create_engine(url)).get_table_names()): + print("TABLE " + t) +except Exception: + pass +'@ | Set-Content -Path $probe -Encoding ASCII + $current = @() + try { + Push-Location $AppRoot + $prev = $ErrorActionPreference; $ErrorActionPreference = 'Continue' + try { $current = @(& $Py $probe 2>&1 | Where-Object { $_ -match '^TABLE ' } | + ForEach-Object { $_.Substring(6).Trim() }) } + finally { $ErrorActionPreference = $prev; Pop-Location } + } finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue } + + $residue = @($current | Where-Object { $dumped -notcontains $_ }) + + if ($residue.Count -eq 0) { + Write-Log 'data restored from backup; no leftover tables from the failed migration' 'OK' + } else { + # Say exactly what is true. The previous wording claimed the database was + # "restored to its previous state", which is a false assurance when the + # schema still carries changes the migration committed before it failed. + Write-Log 'data restored from backup, but the schema is NOT identical to before' 'WARN' + Write-Log (" {0} table(s) created by the failed migration remain:" -f $residue.Count) 'WARN' + $residue | ForEach-Object { Write-Log " $_" 'WARN' } + Write-Log ' they hold no data and are harmless to the running application,' 'WARN' + Write-Log ' but a DBA should drop them before the next upgrade attempt.' 'WARN' + } + Write-Log " backup used: $BackupFile" + return $true +} + +# ============================================================================= +# STAGE 0 - bundled MySQL 8.0 (greenfield sites only) +# ============================================================================= +# The MSI installs BINARIES ONLY: no service, no data directory, no config. The +# full sequence is msiexec -> write my.ini -> --initialize-insecure -> --install +# -> Start-Service -> bootstrap SQL. Every step below has bitten us once. +function Invoke-Stage0 { + Write-Log 'STAGE 0: bundled MySQL 8.0' 'STEP' + + $msi = Get-ChildItem (Join-Path $BundleRoot 'mysql') -Filter '*.msi' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $msi) { + Fail "no MySQL MSI in $BundleRoot\mysql" @' +Stage 0 installs the BUNDLED database. Add mysql\mysql-8.0.x-winx64.msi to the +bundle, or skip stage 0 and use the site's existing server (the common case). +'@ + } + + # Refuse to stack a second server on an existing one. Two servers fighting + # over 3306, or a silent takeover of the classic ASP app's database, is far + # worse than stopping here. + $existing = Get-Service -Name 'MySQL*' -ErrorAction SilentlyContinue + if ($existing) { + Fail ("MySQL service already present: {0}" -f (($existing | ForEach-Object Name) -join ', ')) @' +This box already runs MySQL. Do NOT install the bundled server on top of it: +it would collide on port 3306 and could disrupt the existing application. +Skip stage 0 and point the installer at the existing server with -DbHost. +'@ + } + $inUse = Get-NetTCPConnection -LocalPort $DbPort -State Listen -ErrorAction SilentlyContinue + if ($inUse) { Fail "port $DbPort is already in use" 'Free the port, or use the existing server.' } + + if ($WhatIfOnly) { Write-Log 'WhatIf: skipping MySQL install' 'WARN'; return } + + # --- binaries ----------------------------------------------------------- + $mysqld = Join-Path $MysqlRoot 'bin\mysqld.exe' + if (Test-Path $mysqld) { + # Re-running stage 0 after a partial failure must not re-run the MSI. + # Reinstalling an already-registered product is a no-op that STILL needs + # the Windows Installer service, and if msiserver is stopped the client + # blocks forever at 0% CPU with no transaction ever opening and no error. + # The binaries are what this step exists to produce, so if they are here, + # skip straight to configuration. Also makes the stage idempotent (T-005). + Write-Log "MySQL binaries already present at $MysqlRoot; skipping MSI" 'OK' + } + else { + # The Windows Installer service is Manual-start and is supposed to start + # on demand. When it does not, msiexec waits on it indefinitely - so start + # it explicitly rather than trusting on-demand activation. + $msiSvc = Get-Service msiserver -ErrorAction SilentlyContinue + if ($msiSvc -and $msiSvc.Status -ne 'Running') { + Write-Log 'starting Windows Installer service (msiserver)' + try { Start-Service msiserver -ErrorAction Stop } catch { + Write-Log "could not start msiserver: $($_.Exception.Message)" 'WARN' + } + } + # Quiet MSI. INSTALLDIR must have no trailing backslash or the MSI mis-parses it. + Write-Log "installing $($msi.Name) (125MB, takes a minute)" + Invoke-Native 'msiexec.exe' @('/i', $msi.FullName, '/quiet', '/norestart', + "INSTALLDIR=$MysqlRoot") 'MySQL MSI' -TimeoutSec 900 + } + # An installer exit code of 0 does NOT mean it did what you asked - the Python + # bootstrapper returned 0 while installing to the wrong directory. Verify. + if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" } + Track 'mysql-files' $MysqlRoot + Write-Log 'MySQL binaries installed' 'OK' + + # --- config ------------------------------------------------------------- + # Space-free paths on purpose: every quoting bug in this script came from a + # path containing a space being split into two arguments. + New-Item -ItemType Directory -Force -Path (Split-Path $MysqlIni -Parent) | Out-Null + $iniText = @" +[mysqld] +basedir=$MysqlRoot +datadir=$MysqlDataDir +port=$DbPort +bind-address=127.0.0.1 +character-set-server=utf8mb4 +collation-server=utf8mb4_unicode_ci +default-storage-engine=INNODB +innodb_file_per_table=1 +max_connections=200 +log-error=$MysqlDataDir\mysql-error.log +"@ + Set-Content -Path $MysqlIni -Value $iniText -Encoding ASCII + Write-Log "wrote $MysqlIni" + Track 'mysql-files' $MysqlIni + + # --- data directory ----------------------------------------------------- + # --initialize-insecure REFUSES a non-empty datadir, so a previous failed + # attempt must be cleared before retrying. + if (Test-Path $MysqlDataDir) { + $items = Get-ChildItem $MysqlDataDir -Force -ErrorAction SilentlyContinue + if ($items) { + Fail "$MysqlDataDir already exists and is not empty" @' +mysqld --initialize-insecure refuses a non-empty data directory. If this is a +failed previous run, remove the directory and re-run stage 0. If it holds real +data, STOP: you are about to destroy a database. +'@ + } + } + Write-Log 'initializing data directory' + Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize' + Track 'mysql-datadir' $MysqlDataDir + + # --- service ------------------------------------------------------------ + # Argument ORDER MATTERS: --install BEFORE --defaults-file. Verified + # empirically - reversed, mysqld exits 1 and the service is not registered. + Write-Log "registering service $MysqlService" + Invoke-Native $mysqld @('--install', $MysqlService, "--defaults-file=$MysqlIni") 'MySQL service register' + Track 'service' $MysqlService + Start-Service -Name $MysqlService + $svc = Get-Service -Name $MysqlService + if ($svc.Status -ne 'Running') { Fail "service $MysqlService did not start" "Check $MysqlDataDir\mysql-error.log" } + Set-Service -Name $MysqlService -StartupType Automatic + Write-Log "service $MysqlService running" 'OK' + + # --- bootstrap ---------------------------------------------------------- + # --initialize-insecure leaves root with an EMPTY password, so this must run + # immediately. Generated, never supplied; root is shown once and not stored. + $rootPass = New-Secret -Bytes 24 + $appPass = New-Secret -Bytes 24 + # Written OUTSIDE %TEMP% and ACL'd BEFORE the secrets go in - a file created + # with default ACLs and populated afterwards is readable in the gap. + $sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql' + New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null + New-Item -ItemType File -Force -Path $sqlPath | Out-Null + Protect-File $sqlPath + $sql = @" +CREATE DATABASE IF NOT EXISTS $DbName CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER IF NOT EXISTS '$DbUser'@'localhost' IDENTIFIED BY '$appPass'; +CREATE USER IF NOT EXISTS '$DbUser'@'127.0.0.1' IDENTIFIED BY '$appPass'; +GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'localhost'; +GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'127.0.0.1'; +ALTER USER 'root'@'localhost' IDENTIFIED BY '$rootPass'; +FLUSH PRIVILEGES; +"@ + Set-Content -Path $sqlPath -Value $sql -Encoding ASCII + try { + $mysqlExe = Join-Path $MysqlRoot 'bin\mysql.exe' + # -e "source " fails with ERROR 1049 Unknown database '': the + # client reads the path as a database name. Redirect stdin instead. That + # also stops PowerShell eating the backticks MySQL uses to quote identifiers. + Invoke-Native $mysqlExe @('-uroot', '--skip-password', '--protocol=TCP', + "--port=$DbPort") 'MySQL bootstrap' -StdinFile $sqlPath + } + finally { + # Unconditional: this file holds both passwords. + Set-Content -Path $sqlPath -Value ('0' * 512) -Encoding ASCII -ErrorAction SilentlyContinue + Remove-Item $sqlPath -Force -ErrorAction SilentlyContinue + } + + # Hand the app password to stage 2 through an ACL'd FILE, never stdout - + # stdout lands in the log that FR-161 invites operators to send to support. + $handoff = Join-Path $AppRoot '.dbpass' + New-Item -ItemType File -Force -Path $handoff | Out-Null + Protect-File $handoff + Set-Content -Path $handoff -Value $appPass -Encoding UTF8 + Write-Log "database $DbName and user $DbUser created" 'OK' + Write-Log "app password written to $handoff (stage 2 consumes and deletes it)" + + # NOT Write-Host. Under the wizard this process runs hidden, so nobody ever + # saw this - and every line of stdout is forwarded into the setup log that + # operators are told to send to support. The generated root password was + # therefore invisible to the person who needed it and permanently recorded + # for everyone who did not. + # + # Write it to a file only Administrators and SYSTEM can read, and let the + # wizard tell the operator where it is. + $rootFile = Join-Path $env:ProgramData 'ShopDB-Flask\mysql-root-password.txt' + $rootDir = Split-Path $rootFile -Parent + if (-not (Test-Path $rootDir)) { New-Item -ItemType Directory -Path $rootDir -Force | Out-Null } + New-Item -ItemType File -Force -Path $rootFile | Out-Null + Protect-File $rootFile + Set-Content -Path $rootFile -Value @( + 'MySQL root password for this server, generated during installation.', + 'It is not recorded anywhere else and cannot be recovered.', + 'Move it into your password manager, then delete this file.', + '', + $rootPass + ) -Encoding UTF8 + Write-Log "MySQL root password written to $rootFile (Administrators and SYSTEM only)" 'OK' + Write-Log 'MYSQLROOTFILE:' + $rootFile + Write-Log 'stage 0 complete' 'OK' +} + +# ============================================================================= +# STAGE 2 - runtime: Python, venv, wheels, .env +# ============================================================================= +function Invoke-Stage2 { + Write-Log 'STAGE 2: runtime and configuration' 'STEP' + + if (-not (Test-Path $BundleRoot)) { Fail "bundle not found: $BundleRoot" } + if (-not (Test-Path $WheelDir)) { Fail "wheelhouse not found: $WheelDir" 'Rebuild the bundle with deploy\windows\installer\build-installer.ps1 (or .sh); it refuses to produce a bundle without one.' } + if (-not (Test-Path $AppSource)) { Fail "application payload not found: $AppSource" } + Assert-BundleIntegrity + Assert-VenvMatchesWheelhouse + # Captured HERE, before the stamp below is written. Stage 4's guard against + # touching an installation this did not create tested the stamp directly, and + # stage 2 always runs first in a '-Stage all' install - so the stamp it had + # just written made every server look like one of ours and the guard never + # fired on the case it exists for. + $script:PreexistingInstallStamp = Test-Path (Join-Path $AppRoot '.installed-version') + + # --- Python, all users -------------------------------------------------- + # A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity + # cannot read. That produces a 500 with an empty HttpPlatform log. + $pyInstaller = Get-ChildItem (Join-Path $BundleRoot 'python') -Filter '*.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 + # SPACE-FREE deliberately. `TargetDir=C:\Program Files\Python314` splits at + # the space when passed to the bootstrapper and installs into C:\Program\. + # Same class of failure as MySQL's --defaults-file. Never put a space in a + # path this installer controls. + $pyTarget = 'C:\Python314' + if (Test-Path (Join-Path $pyTarget 'python.exe')) { + Write-Log "Python already present at $pyTarget" 'OK' + } elseif ($null -eq $pyInstaller) { + Fail "no Python installer in $BundleRoot\python" + } else { + Write-Log "installing Python (all users) from $($pyInstaller.Name)" + if (-not $WhatIfOnly) { + Invoke-Native $pyInstaller.FullName @( + '/quiet','InstallAllUsers=1','PrependPath=0','Include_launcher=1', + 'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget" + ) 'Python install' + Track 'python' $pyTarget + } + } + $sysPy = Join-Path $pyTarget 'python.exe' + if (-not $WhatIfOnly -and -not (Test-Path $sysPy)) { Fail "Python not at $sysPy after install" } + + # --- application payload ------------------------------------------------ + if (-not (Test-Path $AppRoot)) { + New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null + Track 'dir' $AppRoot + } + # --- upgrade detection --------------------------------------------------- + $script:BundleVersion = Get-BundleVersion + $script:InstalledVersion = Get-InstalledVersion + $script:IsUpgrade = ($script:InstalledVersion -ne '') + $script:DbWasEmpty = $false # established for real in stage 3 + + if ($script:IsUpgrade) { + Write-Log ("upgrading: installed {0} -> bundle {1}" -f $script:InstalledVersion, $script:BundleVersion) 'STEP' + $cmp = Compare-Version $script:BundleVersion $script:InstalledVersion + if ($cmp -lt 0) { + # Migrations only go forwards. Running older code against a newer + # schema fails in ways that are hard to unpick, so refuse outright. + Fail ("this bundle ({0}) is OLDER than what is installed ({1})" -f $script:BundleVersion, $script:InstalledVersion) @' +Downgrading is not supported: the database schema has already been migrated +forwards, and older code cannot read it. Install a build at least as new as +what is on this server, or restore a backup taken before the upgrade. +'@ + } + if ($cmp -eq 0) { + Write-Log 'same version already installed; re-running is harmless (repair)' 'WARN' + } + } else { + Write-Log ("installing version {0}" -f $script:BundleVersion) + } + + # UPGRADE PATH: on an existing install the app pool holds python.exe and the + # site files open, so copying over them fails with "file in use" partway + # through - leaving a half-replaced application. Stop the pool first and note + # that we did, so stage 4 knows to start it again. + $script:PoolWasStopped = $false + if (-not $WhatIfOnly) { + try { + Import-Module WebAdministration -ErrorAction Stop + if ((Test-Path "IIS:\AppPools\$AppPool") -and + ((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started')) { + Write-Log "stopping app pool $AppPool so its files can be replaced" 'WARN' + Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + # The worker does not exit instantly; copying too soon still fails. + for ($i = 0; $i -lt 15; $i++) { + Start-Sleep -Seconds 1 + if (-not (Get-Process w3wp -ErrorAction SilentlyContinue)) { break } + } + $script:PoolWasStopped = $true + } + } catch { } + } + + # Plugin directories present on the server but NOT in the new bundle are + # removed BEFORE the copy. The copy merges rather than replaces, so a plugin + # dropped from a site's profile kept its code on disk forever - which defeats + # the point of a lean build (ADR-013) and, worse, leaves the core's + # `try: from plugins.X.models import ...` guards succeeding for a plugin this + # site no longer has, so SQLAlchemy still builds the relationship and queries + # still join a table that may have been pruned. + if (-not $WhatIfOnly) { + $newPlugins = @() + $srcPlugins = Join-Path $AppSource 'plugins' + if (Test-Path $srcPlugins) { + $newPlugins = @(Get-ChildItem $srcPlugins -Directory | Select-Object -ExpandProperty Name) + } + $livePlugins = Join-Path $AppRoot 'plugins' + if ($newPlugins.Count -gt 0 -and (Test-Path $livePlugins)) { + foreach ($dir in (Get-ChildItem $livePlugins -Directory)) { + if ($newPlugins -notcontains $dir.Name) { + Write-Log "removing '$($dir.Name)': not in this build" 'WARN' + # Uninstall FIRST, while the code is still importable - the + # registry entry outlives the directory otherwise, and the app + # then fails to load a plugin it still believes is installed. + try { + if (Test-Path $Flask) { + Push-Location $AppRoot + $env:FLASK_APP = 'shopdb' + & $Flask plugin uninstall $dir.Name 2>&1 | + ForEach-Object { Write-Log " $_" } + Pop-Location + } + } catch { Write-Log " could not deregister $($dir.Name): $($_.Exception.Message)" 'WARN' } + Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue + } + } + } + } + + Write-Log "copying application payload to $AppRoot" + if (-not $WhatIfOnly) { + Copy-Item (Join-Path $AppSource '*') -Destination $AppRoot -Recurse -Force + } + # Pick the SPA build that matches the deployment method. Vite compiles the + # base path in, so the wrong one loads a page that then fetches its assets + # from the wrong prefix and renders nothing. + if (-not $WhatIfOnly) { + $rootDist = Join-Path $AppRoot 'frontend\dist' + $subDist = Join-Path $AppRoot 'frontend\dist-subpath' + if ($MountAlias) { + if (-not (Test-Path $subDist)) { + Fail 'this bundle has no subpath frontend build' @' +The bundle was built without the / SPA, so a subpath install would serve +a page that cannot load its own assets. Rebuild with scripts/build-site.sh +(which produces both), or install without -MountAlias. +'@ + } + # The alias is fixed at BUILD time; refuse rather than mis-serve. + $built = '' + $aliasFile = Join-Path $subDist '.alias' + if (Test-Path $aliasFile) { $built = (Get-Content $aliasFile -TotalCount 1).Trim() } + if ($built -and ($built -ne $MountAlias.Trim('/'))) { + Fail ("this bundle's subpath build is for '/{0}', not '/{1}'" -f $built, $MountAlias.Trim('/')) ` + 'Rebuild the bundle with SUBPATH_ALIAS set to the alias you want.' + } + Remove-Item $rootDist -Recurse -Force -ErrorAction SilentlyContinue + Move-Item $subDist $rootDist -Force + Write-Log ("using the /{0} frontend build" -f $MountAlias.Trim('/')) 'OK' + } else { + # Not needed for a root install; leave nothing confusing behind. + Remove-Item $subDist -Recurse -Force -ErrorAction SilentlyContinue + } + } + + if (-not $WhatIfOnly -and $script:BundleVersion) { + # Stamped AFTER the copy: if the copy dies, the stamp still reflects what + # is actually on disk rather than what we hoped to put there. + Set-Content -Path (Join-Path $AppRoot '.installed-version') ` + -Value $script:BundleVersion -Encoding ASCII + } + # Keep the lock with the install so `shopdb-admin.ps1 verify` can say WHICH + # bundle this server was built from, months later and offline. + if (-not $WhatIfOnly) { + $bundledLock = Join-Path $BundleRoot 'bundle-lock.json' + if (Test-Path $bundledLock) { Copy-Item $bundledLock $AppRoot -Force } + # The MySQL client stays on the server. The bundle is extracted to a temp + # directory and deleted when Setup exits, so a copy that lived only there + # would leave `shopdb-admin backup` with nothing to run on a site whose + # database is on another host. + $bundledClient = Join-Path $BundleRoot 'mysqlclient' + if (Test-Path $bundledClient) { + $target = Join-Path $AppRoot 'mysqlclient' + if (-not (Test-Path $target)) { New-Item -ItemType Directory -Path $target -Force | Out-Null } + Copy-Item (Join-Path $bundledClient '*') $target -Recurse -Force + Write-Log 'MySQL client staged for backups' 'OK' + } + } + foreach ($sub in @('logs','instance')) { + $p = Join-Path $AppRoot $sub + if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } + } + + # --- venv --------------------------------------------------------------- + if (Test-Path $Py) { + Write-Log 'venv already exists' 'OK' + } else { + Write-Log 'creating venv' + if (-not $WhatIfOnly) { + Invoke-Native $sysPy @('-m','venv',(Join-Path $AppRoot 'venv')) 'venv creation' + Track 'dir' (Join-Path $AppRoot 'venv') + } + } + + # --- offline dependency install ---------------------------------------- + # PIP_NO_INDEX makes a network attempt impossible rather than merely + # unnecessary. On an air-gapped box pip otherwise hangs on DNS timeouts. + # + # --require-hashes puts pip in hash-checking mode: every wheel must match a + # sha256 listed in requirements.txt or the install ABORTS. Without it pip + # took whatever file in the wheelhouse satisfied the version pin, so a + # swapped or hand-dropped wheel installed silently. The flag is passed + # explicitly rather than relying on pip inferring it from the presence of + # hashes, so shipping an unhashed requirements.txt fails loudly here instead + # of quietly dropping the check. + # + # --only-binary=:all: refuses to fall back to building from an sdist. On a + # server with no compiler and no network that fallback cannot succeed; it + # just turns a clear "no wheel for this platform" into a confusing build + # error deep in someone's setup.py. + Write-Log 'installing dependencies from the wheelhouse (offline, hash-checked)' + if (-not $WhatIfOnly) { + $env:PIP_NO_INDEX = '1' + $env:PIP_FIND_LINKS = $WheelDir + $env:PIP_DISABLE_PIP_VERSION_CHECK = '1' + try { + Invoke-Native $Py @('-m','pip','install','--no-index',"--find-links=$WheelDir", + '--require-hashes','--only-binary=:all:', + '-r',(Join-Path $AppRoot 'requirements.txt')) 'dependency install' + } finally { + Remove-Item Env:\PIP_NO_INDEX, Env:\PIP_FIND_LINKS -ErrorAction SilentlyContinue + } + # waitress and tzdata are load-bearing on Windows; verify rather than assume. + foreach ($mod in @('waitress','tzdata')) { + & $Py -c "import $mod" 2>$null + if ($LASTEXITCODE -ne 0) { + Fail "$mod did not install" "The wheelhouse is incomplete. $mod is required (waitress is the WSGI server; tzdata provides the IANA timezone database Windows lacks)." + } + } + Write-Log 'waitress and tzdata present' 'OK' + } + + # --- .env --------------------------------------------------------------- + if (Test-Path $EnvFile) { + # Preserving .env is right for a plain re-run: regenerating JWT_SECRET_KEY + # would invalidate every issued session. + # + # But if the caller EXPLICITLY supplied connection details - a wizard where + # someone just typed a password, or -DbHost/-DbPasswordFile on the command + # line - then silently keeping the old DATABASE_URL ignores what they asked + # for and fails later with a bare "Access denied", pointing at the database + # instead of at the stale file that actually caused it. + # So: keep the secrets, refresh the connection string. + # Rewrite the connection string ONLY when a password was actually + # supplied. Previously any -DbHost (which the wizard always passes, from + # a page whose defaults were 127.0.0.1/shopdb_flask) rewrote DATABASE_URL + # - so a site whose database lives on another server had its real + # connection string silently replaced with defaults during an upgrade, + # and .env was the only record of it. + if ($DbPasswordFile) { + Write-Log '.env exists and new database details were supplied; updating DATABASE_URL' 'WARN' + $dbPass = '' + if ($DbPasswordFile) { + if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" } + $dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8) + if ($dbPass) { $dbPass = $dbPass.Trim() } + if ($DbPasswordFile -ne (Join-Path $AppRoot '.dbpass')) { + Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force -ErrorAction SilentlyContinue + Remove-Item $DbPasswordFile -Force -ErrorAction SilentlyContinue + } + } + if (-not $dbPass) { Fail 'no database password supplied to update .env' } + if (-not $DbHost) { $DbHost = '127.0.0.1' } + $escUser = [uri]::EscapeDataString($DbUser) + $escPass = [uri]::EscapeDataString($dbPass) + $newUrl = "DATABASE_URL=mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4" + # Keep a copy before touching it. .env holds the DB password in + # plaintext by design, so the backup gets the same owner-only ACL and + # is the operator's way back if the new details turn out wrong. + $envBak = "$EnvFile.bak-{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss') + Copy-Item $EnvFile $envBak -Force -ErrorAction SilentlyContinue + if (Test-Path $envBak) { + Protect-File $envBak + Write-Log "previous .env kept at $envBak" + } + $kept = Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*DATABASE_URL=' } + Set-Content -Path $EnvFile -Value ($kept + $newUrl) -Encoding ASCII + Protect-File $EnvFile + Write-Log 'DATABASE_URL updated; SECRET_KEY and JWT_SECRET_KEY kept' 'OK' + } else { + Write-Log '.env exists and no new password was supplied; keeping it unchanged' 'OK' + Write-Log ' (its database settings and secret keys are left exactly as they are)' + } + + # MOUNT_PATH must ALWAYS track the chosen deployment method, whether or + # not the connection string changed. Switching from a subpath install + # back to its own site otherwise leaves MOUNT_PATH=/shopdb behind, and + # wsgi.py keeps mounting the app at /shopdb - so the new site answers + # 404 on every path and the smoke test fails on a correct install. + # CORS_ORIGINS is tied to the deployment method too: switching between + # methods changes the port people arrive on, and a stale value fails every + # browser request while curl (which does not send Origin) still works. + $wantOrigin = '' + if ($MountAlias) { + $parentPort = 80 + try { + Import-Module WebAdministration -ErrorAction SilentlyContinue + $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection | + Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 + if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } + } catch { } + if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME } + if ($parentPort -eq 80) { $wantOrigin = "http://{0}" -f $SiteHost } + else { $wantOrigin = "http://{0}:{1}" -f $SiteHost, $parentPort } + } elseif ($SiteHost) { + $wantOrigin = "http://{0}:{1}" -f $SiteHost, $SitePort + } + + $envLines = @(Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*MOUNT_PATH=' }) + if ($wantOrigin) { + $envLines = @($envLines | Where-Object { $_ -notmatch '^\s*CORS_ORIGINS=' }) + $envLines += ("CORS_ORIGINS={0}" -f $wantOrigin) + Write-Log "CORS_ORIGINS set to $wantOrigin" 'OK' + } + if ($MountAlias) { + $envLines += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/')) + Write-Log ("MOUNT_PATH set to /{0}" -f $MountAlias.Trim('/')) 'OK' + } else { + Write-Log 'MOUNT_PATH removed - the app serves from the site root' 'OK' + } + Set-Content -Path $EnvFile -Value ($envLines -join "`r`n") -Encoding ASCII + Protect-File $EnvFile + } else { + if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME } + + # Stage 0 leaves an ACL'd handoff file when it created the database itself. + # Prefer it: a greenfield install then needs no password from anyone, and + # the generated password never passes through a human or a command line. + # This MUST come before the -DbHost check below - stage 0 always installs + # to the local box, so the handoff implies the host as well as the password. + $handoff = Join-Path $AppRoot '.dbpass' + if (-not $DbPasswordFile -and (Test-Path $handoff)) { + $DbPasswordFile = $handoff + if (-not $DbHost) { $DbHost = '127.0.0.1' } + Write-Log 'using the bundled-MySQL password handoff from stage 0 (host 127.0.0.1)' + } + + if (-not $DbHost) { + Fail 'no database host supplied and no existing .env' @' +Pass -DbHost . The password is prompted for, never passed on the +command line. For a bundled-database install run stage 0 first: it creates the +database and leaves an ACL'd handoff file, after which no password is needed. +'@ + } + # Two ways in, both keeping the password off the command line (which any + # user can read via Win32_Process, and which lands in transcripts and + # ConsoleHost_history.txt). + if ($DbPasswordFile) { + # Unattended path. The caller writes an ACL'd file; we consume and + # shred it so it does not outlive the install. + if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" } + $dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8) + if ($dbPass) { $dbPass = $dbPass.Trim() } + Write-Log "read DB password from $DbPasswordFile" + if ($DbPasswordFile -eq $handoff) { + # Do NOT shred the stage-0 handoff here. It holds the ONLY copy of a + # GENERATED password: .env is the sole other copy, and rollback + # deletes .env. Shredding both on a failed install leaves an + # unrecoverable box - the database exists, the app user exists, and + # nobody alive knows the password. Stage 5 shreds it once the + # install is proven working. + Write-Log 'keeping the stage-0 handoff until stage 5 confirms the install' + } else { + try { + # Operator-supplied file: they know the password, so consume it + # immediately. Overwrite before delete - a plain Remove-Item + # leaves the bytes on disk. + Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force + Remove-Item $DbPasswordFile -Force + Write-Log 'password file overwritten and removed' + } catch { Write-Log "could not remove $DbPasswordFile - delete it manually" 'WARN' } + } + } + else { + # Interactive path. Read-Host -AsSecureString reads the CONSOLE, not + # stdin: piping into it does not work, it just blocks forever with no + # error and no timeout. So refuse up front rather than hang a remote or + # scheduled run. + if (-not [Environment]::UserInteractive) { + Fail 'no interactive console for the password prompt' @' +This session has no console, so the password prompt would hang indefinitely +(Read-Host -AsSecureString reads the console directly and ignores piped stdin). +Re-run with -DbPasswordFile pointing at a file whose first line is the +password. The installer reads it, overwrites it and deletes it. +'@ + } + $sec = Read-Host -Prompt ("Password for MySQL user '{0}' on {1}" -f $DbUser, $DbHost) -AsSecureString + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) + try { $dbPass = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } + finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } + } + if (-not $dbPass) { Fail 'no password entered' } + # Percent-encode: passwords routinely contain characters that break a URL. + $escUser = [uri]::EscapeDataString($DbUser) + $escPass = [uri]::EscapeDataString($dbPass) + $DatabaseUrl = "mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4" + # CORS_ORIGINS must be the ORIGIN PEOPLE ACTUALLY USE, and an explicit one - + # a wildcard makes the app refuse to boot. Under a subpath install the app + # is reached on the PARENT site's port (usually 80), so pinning the + # stand-alone port here would make every browser request fail CORS on a + # correctly installed server. Origin is scheme+host+port only - never the path. + if ($MountAlias) { + $parentPort = 80 + try { + Import-Module WebAdministration -ErrorAction SilentlyContinue + $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection | + Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 + if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } + } catch { } + if ($parentPort -eq 80) { $origin = "http://{0}" -f $SiteHost } + else { $origin = "http://{0}:{1}" -f $SiteHost, $parentPort } + } else { + $origin = "http://{0}:{1}" -f $SiteHost, $SitePort + } + $content = @( + 'FLASK_ENV=production' + ("SECRET_KEY={0}" -f (New-Secret)) + ("JWT_SECRET_KEY={0}" -f (New-Secret)) + ("DATABASE_URL={0}" -f $DatabaseUrl) + ("CORS_ORIGINS={0}" -f $origin) + 'LOG_LEVEL=INFO' + ) + if ($MountAlias) { + # wsgi.py reads MOUNT_PATH and moves the prefix from PATH_INFO to + # SCRIPT_NAME, so Flask both routes and GENERATES urls under it. + # Added to the ARRAY, before the join - appending to the joined string + # glues it onto the previous line ("LOG_LEVEL=INFOMOUNT_PATH=/shopdb"). + $content += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/')) + } + $content = $content -join "`r`n" + Write-Log "writing .env (CORS_ORIGINS=$origin)" + if (-not $WhatIfOnly) { + Set-Content -Path $EnvFile -Value $content -Encoding ASCII + Track 'file' $EnvFile + # .env holds the database password in plaintext BY DESIGN - the app + # reads it at boot. Filesystem permissions are the only control. + # Administrators and SYSTEM only; the app-pool identity gets Read in stage 4. + Protect-File $EnvFile + # Clear the plaintext copy from this session's memory. + $dbPass = $null; $content = $null + [GC]::Collect() + } + } + Write-Log 'stage 2 complete' 'OK' +} + +# ============================================================================= +# STAGE 3 - data: schema, seeds, plugins +# ============================================================================= +function Invoke-Stage3 { + Write-Log 'STAGE 3: schema, seed data, plugins' 'STEP' + if (-not (Test-Path $Flask)) { Fail 'venv not found; run stage 2 first' } + + # Resolve the site profile. An explicit -SiteProfile wins; otherwise use the + # one the bundle was built from, which scripts/build-site.sh stages alongside + # the app tree so the profile and the lean tree can never drift apart. + $ProfilePath = $SiteProfile + if (-not $ProfilePath) { $ProfilePath = Join-Path $AppRoot 'site-profile.json' } + if (-not (Test-Path $ProfilePath)) { + Fail "site profile not found: $ProfilePath" @' +The installer no longer carries a hardcoded plugin list - the site's set is +declared in a profile and the bundle is built lean for it. +Either pass -SiteProfile , or rebuild the bundle with +scripts/build-site.sh , which stages the profile into the app tree. +See deploy/site-profile.example.json for the format. +'@ + } + # Report the chosen set up front: it decides both what gets installed and what + # prune-schema drops, so it belongs in the log FR-161 sends to support. + try { + $declared = (Get-Content $ProfilePath -Raw | ConvertFrom-Json).plugins -join ', ' + Write-Log "site profile: $ProfilePath" + Write-Log "declared plugins: $declared" + } catch { Fail "site profile is not valid JSON: $ProfilePath" } + + $env:FLASK_APP = 'shopdb' + Push-Location $AppRoot + try { + # The app ships its own preflight. Use it rather than reimplementing: + # it checks Python, required env, DB connectivity and the MySQL 5.6 flags. + Write-Log 'running flask db-utils preflight' + # TRAP (documented in Invoke-Native, and hit again here): the app logs + # plugin initialisation to STDERR even on success. With 2>&1 and + # $ErrorActionPreference='Stop' PowerShell turns that into a terminating + # NativeCommandError, so a healthy preflight aborts the install with a + # plugin INFO line as the "error". Judge by the OUTPUT, not by stderr. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { $pf = & $Flask db-utils preflight 2>&1 } + finally { $ErrorActionPreference = $prevEAP } + $pf | ForEach-Object { Write-Log " $_" } + # Match the preflight's own status column (" FAIL "), not any line + # that merely contains the word - plugin log lines can too. + $pfFailed = @($pf | Where-Object { $_ -match '^\s*FAIL\s' }).Count -gt 0 + if ($LASTEXITCODE -ne 0 -or $pfFailed) { + Fail 'application preflight reported failures' 'Fix the FAIL items above, then re-run stage 3.' + } + + if ($WhatIfOnly) { Write-Log 'WhatIf: skipping migrations and seeds' 'WARN'; return } + + # Decide fresh-vs-existing from the DATABASE, and do it HERE rather than + # relying on a variable set in stage 2 - stage 3 is documented as + # separately runnable ("re-run stage 3"), and under Set-StrictMode + # reading an unset $script:IsUpgrade is a terminating error. + $script:DbWasEmpty = Test-DatabaseEmpty + if (-not $script:DbWasEmpty) { + $script:IsUpgrade = $true + Write-Log 'existing data found in the target database - treating this as an upgrade' 'WARN' + } + + # Back up BEFORE Alembic touches anything. Skipped only when the database + # is provably empty; on anything else it is the only thing standing + # between a failed migration and a half-migrated database. + $script:PreUpgradeBackup = '' + if (-not $script:DbWasEmpty) { + $script:PreUpgradeBackup = Backup-Database 'pre-upgrade' + if (-not $script:PreUpgradeBackup) { + Fail 'could not back up the database before upgrading' @' +Refusing to migrate without a backup. Fix mysqldump access (or take a backup +manually with shopdb-admin.ps1 backup) and run this again. +'@ + } + } + + # Alembic owns the schema. Nothing else may create tables. + Write-Log 'flask db upgrade' + try { + Invoke-Native $Flask @('db','upgrade') 'schema upgrade' + } catch { + if ($script:PreUpgradeBackup) { + Write-Log 'migration failed - putting the database back' 'FAIL' + if (Restore-Database $script:PreUpgradeBackup) { + Fail 'the upgrade failed; your data was restored from the pre-upgrade backup' @' +Your DATA is intact. The schema may still carry tables the failed migration +created before it stopped - the log lists any of them, and they are empty. +The application files on disk are the NEW version, so re-install the PREVIOUS +build to get a matching pair, then send the install log to support. +'@ + } + } + throw + } + + foreach ($seed in @('reference-data','permissions','settings')) { + Write-Log "flask seed $seed" + Invoke-Native $Flask @('seed',$seed) "seed $seed" + } + + # Plugin registry starts empty on a fresh box. apply-profile resolves the + # declared set's hard-dependency closure and installs + enables it in + # dependency order, idempotently (ADR-013). It fails loudly if the profile + # names a plugin that is not on disk - which on a lean bundle correctly + # catches a profile/bundle mismatch. + # An install-time selection overrides the bundled profile. Written here, + # after the payload copy, so it survives stage 2 replacing the app tree. + if ($SitePlugins) { + $chosen = @($SitePlugins -split ',' | ForEach-Object { $_.Trim() } | + Where-Object { $_ }) + $onDisk = @(Get-ChildItem (Join-Path $AppRoot 'plugins') -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } | + Select-Object -ExpandProperty Name) + $missing = @($chosen | Where-Object { $onDisk -notcontains $_ }) + if ($missing.Count -gt 0) { + Fail ("these plugins are not in this bundle: {0}" -f ($missing -join ', ')) ` + 'Rebuild the installer from a profile that includes them.' + } + $profileJson = @{ site = $SiteHost; plugins = $chosen; locked = @() } | + ConvertTo-Json -Depth 3 + Set-Content -Path $ProfilePath -Value $profileJson -Encoding ASCII + Write-Log ("site profile rewritten from the installer selection: {0}" -f ($chosen -join ', ')) 'OK' + } + + Write-Log "flask plugin apply-profile $ProfilePath" + Invoke-Native $Flask @('plugin','apply-profile',$ProfilePath) 'apply site profile' + + # Per-plugin Alembic chains (ADR-008) run after the plugins are registered. + Write-Log 'flask plugin upgrade-all' + Invoke-Native $Flask @('plugin','upgrade-all') 'plugin migrations' + + # ADR-014 Phase 2. The shared core Alembic baseline creates EVERY plugin's + # tables regardless of this site's choice, so a lean site still carries the + # omitted plugins' (empty) tables. Drop them, leaving core + chosen. + # + # Both flags are required and they mean different things: --yes executes + # (the command is a dry-run preview otherwise), --force permits dropping a + # table that holds rows. --force is needed even on a fresh install because + # core migrations seed a few plugin reference tables (e.g. 7d05 inserts + # default access protocols). Safe here and ONLY here: this runs during + # initial provisioning, before any site data exists. + # NOTE: ADR-014's prose says lean provisioning "uses --force"; that alone + # would silently do nothing but print a preview. It needs both. + # --force drops tables that CONTAIN ROWS. That is required on a fresh + # install, because core migrations seed a few plugin reference tables + # (7d05 inserts default access protocols) and prune would otherwise + # refuse. On an UPGRADE the same flag would silently destroy a site's + # data for any plugin that is not installed - so never force there. + # Without --force, prune-schema refuses non-empty tables and says so, + # which is the behaviour we want when data exists. + if (-not $script:DbWasEmpty) { + Write-Log 'flask plugin prune-schema --yes (no --force: refuses to drop tables holding data)' + $pruneOut = Invoke-Native $Flask @('plugin','prune-schema','--yes') 'prune not-installed plugin tables' + $skipped = @($pruneOut | Where-Object { $_ -match 'refus|not empty|rows' }) + if ($skipped.Count -gt 0) { + Write-Log 'some plugin tables were kept because they hold data:' 'WARN' + $skipped | ForEach-Object { Write-Log " $_" 'WARN' } + Write-Log ' drop them by hand only if you are certain that data is not needed' 'WARN' + } + } else { + # Provably-empty database only: core migrations seed a few plugin + # reference tables, so prune would otherwise refuse. + Write-Log 'flask plugin prune-schema --yes --force (database was empty at start)' + Invoke-Native $Flask @('plugin','prune-schema','--yes','--force') 'prune not-installed plugin tables' + } + } + finally { Pop-Location } + Write-Log 'stage 3 complete' 'OK' +} + +# ============================================================================= +# STAGE 4 - IIS +# ============================================================================= +function Invoke-Stage4 { + Write-Log 'STAGE 4: IIS site' 'STEP' + Import-Module WebAdministration -ErrorAction Stop + $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe' + + # HttpPlatformHandler is what actually launches waitress; IIS cannot serve + # this application without it. The MSI ships in the bundle but nothing ever + # installed it, so a clean server ran all the way to the stage 5 smoke test - + # Python, venv, schema, plugins and IIS all mutated - and only then failed. + # It must also come BEFORE the section unlock below: system.webServer/ + # httpPlatform does not exist until the module is registered. + $hphDll = Join-Path $env:windir 'system32\inetsrv\httpplatformhandler.dll' + if (Test-Path $hphDll) { + Write-Log 'HttpPlatformHandler already installed' 'OK' + } else { + $hphMsi = Get-ChildItem (Join-Path $BundleRoot 'httpplatformhandler') -Filter '*.msi' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $hphMsi) { + Fail 'HttpPlatformHandler is not installed and no MSI is in the bundle' @' +IIS cannot run this application without the HttpPlatformHandler module. +Add httpplatformhandler\httpPlatformHandler_amd64.msi to the bundle, or install +the module by hand, then re-run stage 4. +'@ + } + Write-Log "installing $($hphMsi.Name)" + if (-not $WhatIfOnly) { + Invoke-Native 'msiexec.exe' @('/i', $hphMsi.FullName, '/quiet', '/norestart') ` + 'HttpPlatformHandler MSI' -TimeoutSec 600 + if (-not (Test-Path $hphDll)) { + Fail 'the HttpPlatformHandler MSI reported success but the module is missing' ` + 'An installer exit code of 0 does not prove it did what was asked.' + } + Write-Log 'HttpPlatformHandler installed' 'OK' + } + } + + # --- client IP ----------------------------------------------------------- + # URL Rewrite is what lets the XFF rule below exist. Install it BEFORE + # writing a web.config that references , or IIS answers every + # request with 500.19 until someone works out which module is missing. + if ($ClientIpSource -eq 'direct') { + $rewriteDll = Join-Path $env:windir 'system32\inetsrv\rewrite.dll' + if (Test-Path $rewriteDll) { + Write-Log 'URL Rewrite already installed' 'OK' + } else { + $rwMsi = Get-ChildItem (Join-Path $BundleRoot 'urlrewrite') -Filter '*.msi' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $rwMsi) { + Fail 'URL Rewrite is not installed and no MSI is in the bundle' @' +-ClientIpSource direct needs the IIS URL Rewrite module to set X-Forwarded-For. +Add urlrewrite\rewrite_amd64.msi to the bundle and rebuild, or re-run with +-ClientIpSource proxy if something in front of IIS already sets the header. +Installing it later by hand also works: this server has no network, so the MSI +has to come from the bundle either way. +'@ + } + Write-Log "installing $($rwMsi.Name)" + if (-not $WhatIfOnly) { + Invoke-Native 'msiexec.exe' @('/i', $rwMsi.FullName, '/quiet', '/norestart') ` + 'URL Rewrite MSI' -TimeoutSec 600 + if (-not (Test-Path $rewriteDll)) { + Fail 'the URL Rewrite MSI reported success but the module is missing' ` + 'An installer exit code of 0 does not prove it did what was asked.' + } + Write-Log 'URL Rewrite installed' 'OK' + } + } + } else { + Write-Log 'client IP comes from an upstream proxy; not installing URL Rewrite' 'OK' + } + + # web.config ships in the repo; its paths need correcting and the client-IP + # rule needs enabling or leaving off. + # + # An EXISTING web.config is never overwritten. It is the one file on the + # server that legitimately carries hand-edits - a nested application for + # /installers, bindings, a proxy-specific rule - and overwriting it reverts + # them silently. On a server where the XFF rule was enabled by hand, that + # alone would turn the GE-Enforce IP allowlist off without a word in any log. + $srcCfg = Join-Path $AppRoot 'deploy\windows\web.config' + $dstCfg = Join-Path $AppRoot 'web.config' + if (Test-Path $dstCfg) { + Write-Log 'web.config already exists; leaving it alone' 'OK' + $existing = Get-Content $dstCfg -Raw + $hasRule = $existing -match '' -and $existing -notmatch 'SHOPDB-CLIENTIP-BEGIN' + if ($ClientIpSource -eq 'direct' -and -not $hasRule) { + Write-Log 'this web.config does NOT set X-Forwarded-For; client IPs will read as 127.0.0.1' 'WARN' + Write-Log ' enable the SHOPDB-CLIENTIP block by hand, or delete web.config and re-run stage 4' 'WARN' + } + if ($ClientIpSource -eq 'proxy' -and $hasRule) { + Write-Log 'this web.config OVERWRITES X-Forwarded-For, which discards the real client IP behind a proxy' 'WARN' + Write-Log ' remove the block by hand if a proxy in front already sets the header' 'WARN' + } + } elseif (Test-Path $srcCfg) { + Write-Log "installing web.config (client IP: $ClientIpSource)" + if (-not $WhatIfOnly) { + $cfg = Get-Content $srcCfg -Raw + $cfg = $cfg.Replace('C:\shopdb-flask', $AppRoot) + if ($ClientIpSource -eq 'direct') { + # Uncomment by deleting the two marker lines. A string operation + # on explicit markers, not a regex over the surrounding prose: + # editing that prose must never silently disable the rule. + if ($cfg -notmatch 'SHOPDB-CLIENTIP-BEGIN') { + Fail 'the shipped web.config has no SHOPDB-CLIENTIP block' ` + 'It was edited. Restore deploy\windows\web.config from the repository.' + } + $cfg = $cfg.Replace('', '') + Write-Log 'X-Forwarded-For rule enabled' 'OK' + } + Set-Content -Path $dstCfg -Value $cfg -Encoding UTF8 + Track 'file' $dstCfg + } + } else { + Fail "web.config not found at $srcCfg or $dstCfg" + } + + # App pool: No Managed Code. This is a Python app; the CLR must not load. + if (Test-Path "IIS:\AppPools\$AppPool") { + Write-Log "app pool $AppPool already exists" 'OK' + } else { + Write-Log "creating app pool $AppPool" + if (-not $WhatIfOnly) { + New-WebAppPool -Name $AppPool | Out-Null + Set-ItemProperty "IIS:\AppPools\$AppPool" -Name managedRuntimeVersion -Value '' + Track 'apppool' $AppPool + } + } + + # ACLs. RX on the tree; Modify on logs\ and instance\ or plugin toggles and + # uploads fail with "internal error". Read on .env for the pool identity. + if (-not $WhatIfOnly) { + $ident = "IIS AppPool\$AppPool" + Write-Log "granting $ident access" + Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot' + foreach ($sub in @('logs','instance')) { + Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub" + } + Invoke-Native 'icacls.exe' @($EnvFile,'/grant',"${ident}:(R)") 'ACL on .env' + } + + # Handler sections are locked server-wide by default. Without unlocking, + # IIS returns 500.19 the moment it reads the app's web.config. + # + # `appcmd unlock config /section:X` unlocks it for EVERY site on the machine, + # which on a shared server hands every other application the ability to + # define its own handlers. Delegate to our own location instead, and fall + # back to the server-wide unlock only if that is refused - a 500.19 the + # operator cannot diagnose is worse than a wider delegation, but it should be + # the second choice, not the first. + $ourLocation = if ($MountAlias) { "$ParentSite/$($MountAlias.Trim('/'))" } else { $SiteName } + foreach ($section in @('system.webServer/handlers','system.webServer/httpPlatform')) { + if ($WhatIfOnly) { Write-Log "would unlock $section for $ourLocation"; continue } + Write-Log "unlocking $section for $ourLocation" + $scoped = & $appcmd unlock config "$ourLocation" /section:$section 2>&1 + $scoped | ForEach-Object { Write-Log " $_" } + if ($LASTEXITCODE -ne 0) { + Write-Log " scoped unlock refused; unlocking $section server-wide" 'WARN' + & $appcmd unlock config /section:$section 2>&1 | ForEach-Object { Write-Log " $_" } + } + } + + # Switching method must not leave BOTH deployments in place: two entry points + # to one directory, one of them serving an SPA built for the wrong base path. + # Remove whichever artifact belongs to the method we are NOT using. + # + # GUARDED. These removals are correct when this installer owns both artifacts + # - it is how switching method avoids leaving two entry points to one + # directory, one of them serving an SPA built for the wrong base path. They + # are NOT correct against an installation someone else built: a wrong + # -MountAlias would delete a live mount with no prompt and no error, and the + # first sign would be the site 404ing. + # + # 'Ours' means there was a version stamp BEFORE this run started - only this + # installer writes one. Stage 2 records that observation; testing the file + # here instead would read the stamp stage 2 has already written. + $weBuiltThis = if ($null -ne $script:PreexistingInstallStamp) { + $script:PreexistingInstallStamp + } else { + # Stage 4 run on its own, so stage 2 has not written anything this run. + Test-Path (Join-Path $AppRoot '.installed-version') + } + if (-not $WhatIfOnly) { + $doomed = @() + if ($MountAlias) { + if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { + $doomed += @{ Kind = 'site'; Name = $SiteName; Site = '' } + } + } else { + foreach ($site in (Get-Website)) { + foreach ($app in (Get-WebApplication -Site $site.Name -ErrorAction SilentlyContinue)) { + if ($app.PhysicalPath -eq $AppRoot) { + $doomed += @{ Kind = 'app'; Name = $app.Path.Trim('/'); Site = $site.Name } + } + } + } + } + + if ($doomed.Count -gt 0 -and -not $weBuiltThis -and -not $AdoptExisting) { + foreach ($d in $doomed) { + if ($d.Kind -eq 'site') { Write-Log (" would remove IIS site '{0}'" -f $d.Name) 'FAIL' } + else { Write-Log (" would remove application '/{0}' under '{1}'" -f $d.Name, $d.Site) 'FAIL' } + } + Fail 'this server has an IIS deployment that this installer did not create' @' +Publishing the way you asked means removing what is listed above, and there is +no version stamp to show this installer put it there. Deleting a mount somebody +else configured is not something to do without being asked. + +If the removal is what you want, re-run with -AdoptExisting. +If not, re-run matching how the application is published today: pass +-MountAlias to keep it as an application under an existing site, or omit +-MountAlias to keep it as a site of its own. +'@ + } + + foreach ($d in $doomed) { + if ($d.Kind -eq 'site') { + Write-Log "removing the previous stand-alone site '$($d.Name)' (now published as a subpath)" 'WARN' + Remove-Website -Name $d.Name -ErrorAction SilentlyContinue + Get-NetFirewallRule -DisplayName ("{0} {1}" -f $d.Name, $SitePort) -ErrorAction SilentlyContinue | + Remove-NetFirewallRule -ErrorAction SilentlyContinue + } else { + Write-Log "removing the previous subpath application '/$($d.Name)' (now its own site)" 'WARN' + Remove-WebApplication -Site $d.Site -Name $d.Name -ErrorAction SilentlyContinue + } + } + } + + if ($MountAlias) { + # METHOD B: an IIS Application under an existing site, reached at + # http://// - no new DNS name and no port in the URL. + # The app's own handler mappings apply only inside the Application, so the + # parent site's handlers (classic ASP, static files) are untouched. + $alias = $MountAlias.Trim('/') + if (-not (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue)) { + Fail "parent site '$ParentSite' does not exist" ` + 'Pass -ParentSite with the name of an existing IIS site, or install without -MountAlias.' + } + $existing = Get-WebApplication -Site $ParentSite -Name $alias -ErrorAction SilentlyContinue + if ($existing) { + Write-Log "application /$alias already exists under '$ParentSite'" 'OK' + } else { + Write-Log "creating application /$alias under '$ParentSite'" + if (-not $WhatIfOnly) { + New-WebApplication -Site $ParentSite -Name $alias -PhysicalPath $AppRoot ` + -ApplicationPool $AppPool | Out-Null + Track 'webapp' ("{0}/{1}" -f $ParentSite, $alias) + } + } + # No firewall rule: the parent site's port is already reachable, which is + # the whole point of this method. + Write-Log "no firewall change needed - served on '$ParentSite' existing bindings" 'OK' + } + else { + # METHOD A: its own site on $SitePort. + if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { + Write-Log "site $SiteName already exists" 'OK' + } else { + Write-Log "creating site $SiteName on port $SitePort" + if (-not $WhatIfOnly) { + New-Website -Name $SiteName -Port $SitePort -PhysicalPath $AppRoot -ApplicationPool $AppPool | Out-Null + Track 'site' $SiteName + } + } + + $ruleName = "$SiteName $SitePort" + if (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue) { + Write-Log 'firewall rule already exists' 'OK' + } else { + Write-Log "adding firewall rule for TCP $SitePort" + if (-not $WhatIfOnly) { + New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP ` + -LocalPort $SitePort -Action Allow | Out-Null + Track 'firewall' $ruleName + } + } + + if (-not $WhatIfOnly) { Start-Website -Name $SiteName -ErrorAction SilentlyContinue } + } + # Stage 2 stops the pool on an upgrade so its files can be replaced. Nothing + # else starts it again, so the smoke test would fail against a stopped site + # and report it as a broken install. + try { + Import-Module WebAdministration -ErrorAction SilentlyContinue + if (Test-Path "IIS:\AppPools\$AppPool") { + if ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started') { + Write-Log "starting app pool $AppPool" + Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } + } + $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue + if ($site -and $site.State -ne 'Started') { + Write-Log "starting site $SiteName" + Start-Website -Name $SiteName -ErrorAction SilentlyContinue + } + } catch { Write-Log "could not start the site: $($_.Exception.Message)" 'WARN' } + + Write-Log 'stage 4 complete' 'OK' +} + +# ============================================================================= +# STAGE 5 - verify and hand off +# ============================================================================= +function Invoke-Stage5 { + Write-Log 'STAGE 5: smoke test' 'STEP' + if ($WhatIfOnly) { Write-Log 'WhatIf: skipping smoke test' 'WARN'; return } + + # Stages can be run one at a time, so do not assume stage 4 just ran. + try { + Import-Module WebAdministration -ErrorAction SilentlyContinue + if ((Test-Path "IIS:\AppPools\$AppPool") -and + ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started')) { + Write-Log "app pool was stopped; starting it before the smoke test" 'WARN' + Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + } + } catch { } + + # FR-150: test the host operators will actually use, not just the loopback. + # localhost alone passes even when the app is unreachable by name and when + # CORS_ORIGINS names a host that does not resolve - so the SPA would fail its + # first XHR on a site the installer just called healthy. + $hostName = if ($SiteHost) { $SiteHost } else { $env:COMPUTERNAME } + # Under method B the app answers on the PARENT site's port, at the alias - + # requesting http://localhost:8090/ would test a site that does not exist. + if ($MountAlias) { + $alias = $MountAlias.Trim('/') + # Prefer an http binding, but fall back to https. A parent site published + # ONLY over https - which is normal, and which the operator did nothing + # wrong to have - left this defaulting to port 80, requesting a URL that + # answers nothing, and failing a working install with a red dialog. + $parentScheme = 'http' + $parentPort = 0 + try { + $bindings = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection + $b = $bindings | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 + if (-not $b) { + $b = $bindings | Where-Object { $_.protocol -eq 'https' } | Select-Object -First 1 + if ($b) { $parentScheme = 'https' } + } + if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } + } catch { } + if ($parentPort -eq 0) { $parentPort = if ($parentScheme -eq 'https') { 443 } else { 80 } } + + $targets = @("{0}://localhost:{1}/{2}/" -f $parentScheme, $parentPort, $alias) + if ($hostName -and $hostName -ne 'localhost') { + $targets += "{0}://{1}:{2}/{3}/" -f $parentScheme, $hostName, $parentPort, $alias + } + # An https parent almost certainly has a certificate for its real name, + # not for 'localhost', and a certificate complaint is not an application + # fault. Accept any certificate for the duration of the smoke test only. + if ($parentScheme -eq 'https') { + Write-Log 'parent site is https-only; certificate validation is skipped for this check' 'WARN' + try { + Add-Type -TypeDefinition @' +using System.Net; +using System.Security.Cryptography.X509Certificates; +public class ShopdbSmokeTestCertPolicy : ICertificatePolicy { + public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { + return true; + } +} +'@ -ErrorAction SilentlyContinue + [System.Net.ServicePointManager]::CertificatePolicy = New-Object ShopdbSmokeTestCertPolicy + } catch { } + } + } else { + $targets = @("http://localhost:$SitePort/") + if ($hostName -and $hostName -ne 'localhost') { + $targets += "http://{0}:{1}/" -f $hostName, $SitePort + } + } + + # First request boots the app and connects to MySQL: the runbook records ~15s. + Write-Log "requesting $($targets[0]) (first request takes ~15s while the app boots)" + $ok = $false + for ($i = 1; $i -le 12; $i++) { + try { + $r = Invoke-WebRequest -Uri $targets[0] -UseBasicParsing -TimeoutSec 20 + if ($r.StatusCode -eq 200) { $ok = $true; break } + } catch { + Start-Sleep -Seconds 5 + } + } + if (-not $ok) { + Fail "site did not return 200 at $($targets[0])" ` + "Check $AppRoot\logs. Empty HttpPlatform log usually means the app-pool identity cannot read $AppRoot or run the venv, or .env is missing/invalid." + } + Write-Log 'site responded 200 on localhost' 'OK' + + # The app is up by now, so the configured-host check needs no boot retry. A + # failure here is a name-resolution or firewall problem, not a broken install, + # so warn rather than Fail: rolling back a working install would be worse. + foreach ($t in $targets[1..($targets.Count - 1)]) { + try { + $r = Invoke-WebRequest -Uri $t -UseBasicParsing -TimeoutSec 20 + if ($r.StatusCode -eq 200) { Write-Log "site responded 200 on $t" 'OK' } + else { Write-Log "unexpected status $($r.StatusCode) at $t" 'WARN' } + } catch { + Write-Log "site did NOT respond at $t - CORS_ORIGINS names this host, so the SPA will fail its first request from other machines" 'WARN' + Write-Log " check DNS/hosts for '$hostName' and the firewall rule on TCP $SitePort" 'WARN' + } + } + + # Is the client-IP rule actually in effect? It is the control that makes + # remote_addr trustworthy: without it IIS forwards whatever X-Forwarded-For + # the CALLER sent, waitress trusts it because it arrives from loopback, and + # the GE-Enforce IP allowlist becomes bypassable from anywhere on the network. + # It does not fail closed. Verified here rather than assumed, because the + # failure is silent and looks exactly like a working server. + if ($ClientIpSource -eq 'direct') { + $liveCfg = Join-Path $AppRoot 'web.config' + $cfgText = if (Test-Path $liveCfg) { Get-Content $liveCfg -Raw } else { '' } + $ruleLive = ($cfgText -match '') -and ($cfgText -notmatch 'SHOPDB-CLIENTIP-BEGIN') + $moduleThere = Test-Path (Join-Path $env:windir 'system32\inetsrv\rewrite.dll') + if ($ruleLive -and $moduleThere) { + Write-Log 'client-IP rule is live; remote_addr is trustworthy' 'OK' + } else { + Write-Log 'CLIENT-IP RULE IS NOT IN EFFECT' 'WARN' + if (-not $moduleThere) { Write-Log ' URL Rewrite module is not installed' 'WARN' } + if (-not $ruleLive) { Write-Log ' web.config does not have an active block' 'WARN' } + Write-Log ' every client will be recorded as 127.0.0.1, and the GE-Enforce IP' 'WARN' + Write-Log ' allowlist is BYPASSABLE - a caller can send its own X-Forwarded-For' 'WARN' + Write-Log ' re-run with -ClientIpSource direct, or use token authentication' 'WARN' + } + } + + # The self-hosted API documentation. It is the thing an operator - or the + # assistant an operator is asking - reaches for on a server with no internet, + # and it is served from a file that has to have been staged into the build. + # A missing file makes /api/docs render an empty page, which nobody notices + # until it is needed. Warn, never fail: the application itself is fine. + $base = $targets[0].TrimEnd('/') + try { + $d = Invoke-WebRequest -Uri "$base/api/docs/openapi.json" -UseBasicParsing -TimeoutSec 20 + if ($d.StatusCode -eq 200) { Write-Log 'API documentation is being served at /api/docs' 'OK' } + else { Write-Log "/api/docs/openapi.json returned $($d.StatusCode)" 'WARN' } + } catch { + Write-Log '/api/docs is NOT working - docs/openapi.json was not staged into this build' 'WARN' + Write-Log ' the site runs fine; offline API reference and LLM assistance will not' 'WARN' + } + + # The install is proven working, so the stage-0 handoff is no longer the only + # copy of the generated password (.env has it and the app is running on it). + # Safe to shred now, and only now - see the note in stage 2. + $handoff = Join-Path $AppRoot '.dbpass' + if (Test-Path $handoff) { + try { + Set-Content -Path $handoff -Value ('0' * 256) -Encoding UTF8 -Force + Remove-Item $handoff -Force + Write-Log 'stage-0 password handoff shredded (install verified)' 'OK' + } catch { Write-Log "could not remove $handoff - delete it manually" 'WARN' } + } + + Write-Host '' + Write-Host ' ShopDB-Flask is installed.' -ForegroundColor Green + Write-Host '' + # FR-151: send the operator to /login, not to /. The site root renders the + # anonymous dashboard; the first-run gate lives on the login route, so an + # operator landing on / sees an empty dashboard and no way to discover setup. + if ($MountAlias) { + Write-Host (" Open: http://{0}/{1}/login" -f $hostName, $MountAlias.Trim('/')) + } else { + Write-Host (" Open: http://{0}:{1}/login" -f $hostName, $SitePort) + } + Write-Host '' + Write-Host ' With no user in the database that page offers to create the first' + Write-Host ' administrator, then runs the setup wizard (site details, features,' + Write-Host ' floor map, common vendors).' + Write-Host '' + Write-Host ' Headless alternative:' + Write-Host (" cd {0}" -f $AppRoot) + Write-Host ' venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example' + Write-Host ' (the password is generated and printed once)' + Write-Host '' + Write-Host (" Install log: {0}" -f $script:LogPath) + Write-Host '' +} + +# ============================================================================= +# Rollback +# ============================================================================= +function Invoke-Rollback { + Write-Log 'rolling back what this run created' 'WARN' + # Reverse order. Never touches an external database, and never removes a + # data directory without explicit confirmation. + for ($i = $script:Created.Count - 1; $i -ge 0; $i--) { + $item = $script:Created[$i] + try { + switch ($item.Kind) { + 'site' { Remove-Website -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed site $($item.Id)" } + 'webapp' { + $parts = $item.Id -split '/', 2 + Remove-WebApplication -Site $parts[0] -Name $parts[1] -ErrorAction SilentlyContinue + Write-Log " removed application /$($parts[1]) under '$($parts[0])'" + } + 'apppool' { Remove-WebAppPool -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed app pool $($item.Id)" } + 'firewall' { Remove-NetFirewallRule -DisplayName $item.Id -ErrorAction SilentlyContinue; Write-Log " removed firewall rule" } + 'file' { + # Overwrite before deleting, same as uninstall does. A rolled + # back install left .env - which holds the database password + # in plaintext by design - recoverable on disk. + try { + if ((Split-Path $item.Id -Leaf) -eq '.env') { + Set-Content -Path $item.Id -Value ('0' * 512) -Encoding UTF8 -Force + } + } catch { } + Remove-Item $item.Id -Force -ErrorAction SilentlyContinue + Write-Log " removed $($item.Id)" + } + 'dir' { Write-Log " left directory $($item.Id) in place (remove manually if wanted)" 'WARN' } + 'python' { Write-Log " left Python install in place at $($item.Id)" 'WARN' } + 'service' { + # Stop and deregister a service THIS run created. Safe because + # stage 0 refuses to run when any MySQL service already exists. + Stop-Service -Name $item.Id -Force -ErrorAction SilentlyContinue + & sc.exe delete $item.Id | Out-Null + Write-Log " removed service $($item.Id)" + } + 'mysql-files' { Write-Log " left MySQL binaries/config at $($item.Id) (remove manually if wanted)" 'WARN' } + # FR-162a: never delete a database directory automatically. Even on + # a failed run the operator may have put data in it, and a wrong + # guess here is unrecoverable. + 'mysql-datadir' { + Write-Log " LEFT MySQL data directory $($item.Id) IN PLACE" 'WARN' + Write-Log ' delete it manually only if you are certain it holds no data' 'WARN' + } + } + } catch { Write-Log " rollback of $($item.Kind) $($item.Id) failed: $($_.Exception.Message)" 'WARN' } + } +} + +# ============================================================================= +# Uninstall +# ============================================================================= +# Deliberately conservative. It removes what the installer creates and NEVER +# touches data without a typed confirmation: no dropped databases, no deleted +# data directory, no uninstalled MySQL that might serve another application. +function Invoke-Uninstall { + Write-Log 'UNINSTALL' 'STEP' + Write-Host '' + Write-Host ' This removes the ShopDB-Flask IIS site, app pool, firewall rule and' -ForegroundColor Yellow + Write-Host (" the application directory {0}." -f $AppRoot) -ForegroundColor Yellow + Write-Host ' It does NOT drop any database and does NOT uninstall MySQL.' -ForegroundColor Yellow + Write-Host '' + if ($OnFailure -ne 'never') { + $answer = Read-Host 'Type UNINSTALL to proceed' + if ($answer -ne 'UNINSTALL') { Write-Log 'aborted by operator'; return } + } + + Import-Module WebAdministration -ErrorAction SilentlyContinue + + # A SUBPATH install has no site of its own - it is an Application under an + # existing one. Removing only the site left the application in place, pointed + # at a directory this is about to delete, so the parent site (at West + # Jefferson, the live classic ASP) served 503 on that path forever and + # Add/Remove Programs reported success. + # + # The alias is whatever MOUNT_PATH says, which is the same value wsgi.py + # mounts on, so it cannot disagree with how the app was actually published. + $mount = '' + $envFile = Join-Path $AppRoot '.env' + if (Test-Path $envFile) { + $line = Get-Content $envFile -ErrorAction SilentlyContinue | + Where-Object { $_ -like 'MOUNT_PATH=*' } | Select-Object -First 1 + if ($line) { $mount = $line.Substring('MOUNT_PATH='.Length).Trim().Trim('/') } + } + if ($mount) { + $removed = $false + foreach ($site in (Get-Website -ErrorAction SilentlyContinue)) { + $app = Get-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue + if ($app) { + Remove-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue + Write-Log "removed application /$mount from site '$($site.Name)'" 'OK' + $removed = $true + } + } + if (-not $removed) { Write-Log "application /$mount not present" } + } + + if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { + Remove-Website -Name $SiteName -ErrorAction SilentlyContinue + Write-Log "removed site $SiteName" 'OK' + } else { Write-Log "site $SiteName not present" } + + if (Test-Path "IIS:\AppPools\$AppPool") { + # Stop first: a running worker holds the app directory open and the + # subsequent Remove-Item then fails with "file in use". + Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + Remove-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + Write-Log "removed app pool $AppPool" 'OK' + } else { Write-Log "app pool $AppPool not present" } + + # Stage 4 creates the rule as "$SiteName $SitePort". This looked for the + # literal "ShopDB-Flask 8090", which is not the same string as the default + # "shopdb-flask 8090" and matches nothing at all on a non-default port, so + # the rule outlived the uninstall. Build the name the same way stage 4 does, + # and sweep any rule left by a differently-ported install of the same site. + $rule = "$SiteName $SitePort" + if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) { + Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue + Write-Log "removed firewall rule '$rule'" 'OK' + } else { Write-Log "firewall rule '$rule' not present" } + Get-NetFirewallRule -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like "$SiteName *" -and $_.DisplayName -ne $rule } | + ForEach-Object { + Remove-NetFirewallRule -DisplayName $_.DisplayName -ErrorAction SilentlyContinue + Write-Log "removed leftover firewall rule '$($_.DisplayName)'" 'OK' + } + + if (Test-Path $AppRoot) { + # .env holds the database password in plaintext by design (the app reads it + # at boot), so shred it rather than just unlinking. + $envPath = Join-Path $AppRoot '.env' + if (Test-Path $envPath) { + Set-Content -Path $envPath -Value ('0' * 1024) -ErrorAction SilentlyContinue + Write-Log 'overwrote .env before deletion (it holds the DB password)' + } + try { + Remove-Item $AppRoot -Recurse -Force -ErrorAction Stop + Write-Log "removed $AppRoot" 'OK' + } catch { + Write-Log "could not fully remove $AppRoot : $($_.Exception.Message)" 'WARN' + Write-Log ' a running worker process usually holds it; try again after iisreset' 'WARN' + } + } else { Write-Log "$AppRoot not present" } + + Write-Host '' + Write-Host ' Uninstalled.' -ForegroundColor Green + Write-Host '' + Write-Host (" The database '{0}' was NOT dropped." -f $DbName) + Write-Host ' MySQL was NOT uninstalled or modified.' + Write-Host ' Remove either manually if this box is being decommissioned.' + Write-Host '' +} + +# ============================================================================= +# Main +# ============================================================================= +Write-Log "ShopDB-Flask installer, stage=$Stage, approot=$AppRoot, bundle=$BundleRoot" 'STEP' +if ($WhatIfOnly) { Write-Log 'WhatIfOnly: no changes will be made' 'WARN' } + +try { + switch ($Stage) { + '0' { Invoke-Stage0 } + '1' { & (Join-Path $PSScriptRoot 'shopdb-preflight.ps1') -SitePort $SitePort -AppRoot $AppRoot } + '2' { Invoke-Stage2 } + '3' { Invoke-Stage3 } + '4' { Invoke-Stage4 } + '5' { Invoke-Stage5 } + # Stage 0 is NOT in 'all' on purpose: most sites already run MySQL, and + # installing a second server on top of an existing one is destructive. + 'all' { Invoke-Stage2; Invoke-Stage3; Invoke-Stage4; Invoke-Stage5 } + 'uninstall' { Invoke-Uninstall } + } + Write-Log "done. log: $script:LogPath" 'OK' + exit 0 +} +catch { + Write-Log $_.Exception.Message 'FAIL' + if ($script:Created.Count -gt 0) { + $doRollback = $false + if ($OnFailure -eq 'always') { $doRollback = $true } + elseif ($OnFailure -eq 'ask') { + Write-Host '' + $answer = Read-Host 'Roll back what this run created? (yes/no)' + $doRollback = ($answer -eq 'yes') + } + if ($doRollback) { Invoke-Rollback } + else { Write-Log 'rollback declined; partial install left in place' 'WARN' } + } + Write-Log "log: $script:LogPath" 'FAIL' + exit 1 +} diff --git a/deploy/windows/installer/shopdb-preflight.ps1 b/deploy/windows/installer/shopdb-preflight.ps1 new file mode 100644 index 00000000..dfaaeee6 --- /dev/null +++ b/deploy/windows/installer/shopdb-preflight.ps1 @@ -0,0 +1,547 @@ +<# +.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 { + Add-Result 'Network' 'Site port' 'FAIL' "TCP $SitePort is in use$owner" ` + "Choose a different port with -SitePort, or stop the listener." + } + } +} + +# ============================================================================= +# 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 { + Add-Result 'IIS' 'HttpPlatformHandler' 'FAIL' 'not installed' ` + 'Install httpPlatformHandler_amd64.msi from the bundle. IIS cannot launch waitress without it.' + } +} + +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 ('
$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.0 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:\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. Add mysqlclient\ to the installer bundle, or install the MySQL client on this server.' + } +} + +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 { + Add-Result 'MySQL' '5.6 index flags' 'FAIL' ("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 } diff --git a/deploy/windows/installer/shopdb.ico b/deploy/windows/installer/shopdb.ico new file mode 100644 index 0000000000000000000000000000000000000000..82df68ac009028ea201167bb43fba957d5d0d059 GIT binary patch literal 52695 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 literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/swatch-bad.bmp b/deploy/windows/installer/swatch-bad.bmp new file mode 100644 index 0000000000000000000000000000000000000000..c3a747a8aefd4ba845dcffad3bd5be9d581168eb GIT binary patch literal 822 ucmZ?rHDhJ~12Z700mK4O%*Y@C7H0s;AK`;whyVkFg4U5yG#UaUHUt1?PZ1UX literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/swatch-ok.bmp b/deploy/windows/installer/swatch-ok.bmp new file mode 100644 index 0000000000000000000000000000000000000000..16ac2a4be866bcf4526bc838438830cc6d6c6b16 GIT binary patch literal 822 ucmZ?rHDhJ~12Z700mK4O%*Y@C7H0s;AK`;whyVkFcfHgo8V!LF8v+0$t>*^- literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/swatch-warn.bmp b/deploy/windows/installer/swatch-warn.bmp new file mode 100644 index 0000000000000000000000000000000000000000..109b7746305c542f3a2d7eff03bf7de2eeba854a GIT binary patch literal 822 tcmZ?rHDhJ~12Z700mK4O%*Y@C7H0s;AK`;whyakBcWo4nhQNpo0RVAEcNPEu literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/verify_bundle_lock.py b/deploy/windows/installer/verify_bundle_lock.py new file mode 100644 index 00000000..80634f09 --- /dev/null +++ b/deploy/windows/installer/verify_bundle_lock.py @@ -0,0 +1,170 @@ +#!/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 +""" +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'), + ('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, 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()) diff --git a/deploy/windows/installer/wizard-image.bmp b/deploy/windows/installer/wizard-image.bmp new file mode 100644 index 0000000000000000000000000000000000000000..1c8fd44ea50bc17dfb35c040849b9d8d170c7a01 GIT binary patch literal 154542 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 C_HPmZ literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-image@125.bmp b/deploy/windows/installer/wizard-image@125.bmp new file mode 100644 index 0000000000000000000000000000000000000000..a3b9c771ca66df25f330eaea10ea5af088f8d7f0 GIT binary patch literal 222390 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 G!2bt;>4TL3 literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-image@250.bmp b/deploy/windows/installer/wizard-image@250.bmp new file mode 100644 index 0000000000000000000000000000000000000000..f074efc667352587d1b9b0767a7dc0a4347d78f4 GIT binary patch literal 889398 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 literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-small.bmp b/deploy/windows/installer/wizard-small.bmp new file mode 100644 index 0000000000000000000000000000000000000000..c064afbcb3715c71fa9446d41c6fd8def8ab25cd GIT binary patch literal 9294 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 Mp98AMBL?*OH~X!G7XSbN literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-small@125.bmp b/deploy/windows/installer/wizard-small@125.bmp new file mode 100644 index 0000000000000000000000000000000000000000..9bc049646bfa036b2561842391c0f15d7e2b2399 GIT binary patch literal 12342 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 HID!8Hz@b#y literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-small@250.bmp b/deploy/windows/installer/wizard-small@250.bmp new file mode 100644 index 0000000000000000000000000000000000000000..a8cd3df6e390ce1a82ecbe8f367e7e01e1b89624 GIT binary patch literal 57462 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 + + SHOPDB-CLIENTIP-END --> diff --git a/frontend/src/composables/setupState.js b/frontend/src/composables/setupState.js index c1e2905e..67806a11 100644 --- a/frontend/src/composables/setupState.js +++ b/frontend/src/composables/setupState.js @@ -2,7 +2,7 @@ // admin into the /setup wizard. Defaults to "complete" so the wizard never // flashes before the real value loads. import { ref } from 'vue' -import { settingsApi } from '../api' +import { settingsApi, setupApi } from '../api' export const setupComplete = ref(true) // Session-only: a fresh admin who clicks "Skip for now" is not nagged again @@ -26,3 +26,31 @@ export async function refreshSetupState() { loaded.value = true return setupComplete.value } + +// First run, before any user exists. Defaults to false so the app never flashes +// the create-admin screen at an established site while this loads. +export const needsAdmin = ref(false) +const adminLoaded = ref(false) + +export function isNeedsAdminLoaded() { + return adminLoaded.value +} + +export async function refreshNeedsAdmin() { + try { + const response = await setupApi.needsAdmin() + needsAdmin.value = response.data?.data?.needsadmin === true + } catch (err) { + // Fail open: if we cannot tell, assume an admin exists rather than trap + // everyone behind a create-admin screen they cannot complete. + needsAdmin.value = false + } + adminLoaded.value = true + return needsAdmin.value +} + +// Called after the first admin is created so the gate stops firing. +export function clearNeedsAdmin() { + needsAdmin.value = false + adminLoaded.value = true +} diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 03c7cbba..f01a802c 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -2,7 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '../stores/auth' import AppLayout from '../views/AppLayout.vue' import SettingsLayout from '../views/settings/SettingsLayout.vue' -import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState' +import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState, + needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState' import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins' import { useToast } from '../composables/toast' import { getFacilityName } from '../utils/siteSettings' @@ -196,6 +197,24 @@ router.afterEach(() => { router.beforeEach(async (to, from, next) => { const authStore = useAuthStore() + // FIRST RUN: until an admin exists, nothing else in the app is useful. Without + // this, a fresh instance serves an anonymous dashboard and the operator has no + // indication that setup is unfinished (they must guess their way to /login). + // Unauthenticated only, so it costs an established site nothing. + if (!authStore.isAuthenticated && to.path !== '/login') { + if (!isNeedsAdminLoaded()) { + // Bounded like the plugin gate: a hung request must not hold navigation + // open. refreshNeedsAdmin fails open on error. + await Promise.race([ + refreshNeedsAdmin(), + new Promise(resolve => setTimeout(resolve, 4000)), + ]) + } + if (needsAdmin.value) { + return next({ path: '/login', query: { firstrun: '1' } }) + } + } + if (to.meta.requiresAuth && !authStore.isAuthenticated) { // Remember where they were headed so login can send them back there. return next({ path: '/login', query: { redirect: to.fullPath } }) diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index a5c43be8..646d32ef 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -51,6 +51,7 @@ import { ref, onMounted } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useAuthStore } from '../stores/auth' import { setupApi } from '../api' +import { clearNeedsAdmin } from '../composables/setupState' import { getSiteLogo } from '../utils/siteSettings' import { withBase, stripBase } from '../utils/basePath' @@ -102,6 +103,7 @@ async function handleCreateAdmin() { loading.value = true try { await setupApi.createAdmin({ username: username.value, email: email.value, password: password.value }) + clearNeedsAdmin() // an admin now exists; stop the first-run gate firing // Log straight in with the new credentials, then on to the setup wizard. const result = await authStore.login(username.value, password.value) if (result.success) router.push('/setup') diff --git a/frontend/src/views/SetupWizard.vue b/frontend/src/views/SetupWizard.vue index 9151de6f..4b943968 100644 --- a/frontend/src/views/SetupWizard.vue +++ b/frontend/src/views/SetupWizard.vue @@ -117,8 +117,9 @@

-

Starter data

-

Seed the data a new site needs. Both are idempotent - safe to run more than once.

+

Reference data

+

Seed the lookup data a new site needs: statuses, types, permissions, and a short list of common vendors. Both are idempotent - safe to run more than once.

+

This does not create any assets. The site starts empty and assets are loaded afterwards via the import API, so an empty dashboard here is expected.