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 00000000..82df68ac Binary files /dev/null and b/deploy/windows/installer/shopdb.ico differ diff --git a/deploy/windows/installer/swatch-bad.bmp b/deploy/windows/installer/swatch-bad.bmp new file mode 100644 index 00000000..c3a747a8 Binary files /dev/null and b/deploy/windows/installer/swatch-bad.bmp differ diff --git a/deploy/windows/installer/swatch-ok.bmp b/deploy/windows/installer/swatch-ok.bmp new file mode 100644 index 00000000..16ac2a4b Binary files /dev/null and b/deploy/windows/installer/swatch-ok.bmp differ diff --git a/deploy/windows/installer/swatch-warn.bmp b/deploy/windows/installer/swatch-warn.bmp new file mode 100644 index 00000000..109b7746 Binary files /dev/null and b/deploy/windows/installer/swatch-warn.bmp differ 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 00000000..1c8fd44e Binary files /dev/null and b/deploy/windows/installer/wizard-image.bmp differ diff --git a/deploy/windows/installer/wizard-image@125.bmp b/deploy/windows/installer/wizard-image@125.bmp new file mode 100644 index 00000000..a3b9c771 Binary files /dev/null and b/deploy/windows/installer/wizard-image@125.bmp differ diff --git a/deploy/windows/installer/wizard-image@250.bmp b/deploy/windows/installer/wizard-image@250.bmp new file mode 100644 index 00000000..f074efc6 Binary files /dev/null and b/deploy/windows/installer/wizard-image@250.bmp differ diff --git a/deploy/windows/installer/wizard-small.bmp b/deploy/windows/installer/wizard-small.bmp new file mode 100644 index 00000000..c064afbc Binary files /dev/null and b/deploy/windows/installer/wizard-small.bmp differ diff --git a/deploy/windows/installer/wizard-small@125.bmp b/deploy/windows/installer/wizard-small@125.bmp new file mode 100644 index 00000000..9bc04964 Binary files /dev/null and b/deploy/windows/installer/wizard-small@125.bmp differ diff --git a/deploy/windows/installer/wizard-small@250.bmp b/deploy/windows/installer/wizard-small@250.bmp new file mode 100644 index 00000000..a8cd3df6 Binary files /dev/null and b/deploy/windows/installer/wizard-small@250.bmp differ diff --git a/deploy/windows/web.config b/deploy/windows/web.config index b5b0704f..8d0a5c87 100644 --- a/deploy/windows/web.config +++ b/deploy/windows/web.config @@ -11,7 +11,7 @@ - HttpPlatformHandler IIS module installed (https://www.iis.net/downloads/microsoft/httpplatformhandler) - URL Rewrite module installed (only for the optional X-Forwarded-For rule) - - Python 3.12 + a venv at APP_ROOT\venv with requirements.txt + waitress + - Python 3.14 + a venv at APP_ROOT\venv with requirements.txt + waitress - Secrets live in APP_ROOT\.env (wsgi.py load_dotenv() reads it). Keep them OUT of this file. Lock .env ACLs to the app pool identity + admins. @@ -28,7 +28,7 @@ + + 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.