Files
shopdb-flask/deploy/windows/installer/README.md
cproudlock 88af7fd9ce feat(installer): lock the third-party payload, and build on Windows without Bash
The bundle carries ~40 wheels, a Python installer and two MSIs. All of them run
as SYSTEM on the target server, and nothing verified any of them. 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.

bundle-lock.json now records that payload exactly - sha256 and byte size per
file - and verification is set equality: a missing file, an unexpected extra
file, or changed content all fail. Both builders check it and refuse to produce
an unverified bundle; the lock ships inside the bundle and shopdb-install.ps1
re-checks it on the server before running any of it.

This is deliberately a layer above requirements.txt hashes. pip lists every
artifact of a pinned version (cffi 2.1.0 alone has 100 hashes), so it proves a
wheel is genuine, not that it is the wheel this bundle was built and tested
with; it ignores extra files in the wheelhouse; and it covers none of the
executables.

refresh-bundle-lock.ps1 regenerates the lock but refuses to overwrite one until
the operator has seen the diff, because the commit is the review - it is the
only place a change to what runs as SYSTEM becomes visible to a human.

build-installer.ps1 is the whole build natively on Windows, so a work PC needs
no Bash. It shares the plugin closure resolver with build-site.sh.

Both builders now copy the installer scripts from the repository. They were
copied from a downloads folder, so the logic that shipped was not the logic that
was committed and the build worked on exactly one machine.

Two verifiers exist because PowerShell is the only thing guaranteed present on
the target server, while the Linux builder should not need pwsh.
tests/test_bundle_lock.py runs both against the same fixtures and fails if they
disagree.
2026-08-03 11:17:45 -04:00

7.9 KiB

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.

# Linux / WSL
./build-installer.sh ../../site-profile.example.json
# 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.

Changing what ships

.\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:

shopdb-admin.ps1 verify

Client IP addresses

IIS does not set X-Forwarded-For on its own, and HttpPlatformHandler connects from loopback. Without a rule, every client reads as 127.0.0.1 - so the GE-Enforce IP allowlist, the dashboard's visitor-location lookup and per-host login rate limiting all stop working, silently.

The wizard asks, because the two answers are mutually exclusive:

  • Clients connect directly (-ClientIpSource direct, the default) - installs URL Rewrite from the bundle and sets X-Forwarded-For from REMOTE_ADDR. Overwriting the header is what stops a client spoofing its own.
  • A proxy sits in front (-ClientIpSource proxy) - leaves the rule off. Behind ARR or a load balancer REMOTE_ADDR is the proxy, so applying the rule would discard the real client IP.

An existing web.config is never overwritten - it is the one file on a server that legitimately carries hand-edits. The installer reports what it found instead.

Deployment methods

Chosen in the wizard, and the bundle carries a SPA build for each because Vite compiles the base path in - it cannot be switched at install time.

  • Its own site on a port (default 8090).
  • Subpath under an existing site, e.g. http://<server-fqdn>/shopdb/. Needs no new DNS record. Three things must agree - the IIS application alias, MOUNT_PATH in .env, and the SPA's build-time base - so the alias is fixed per bundle (SUBPATH_ALIAS, default shopdb) and the installer refuses if the bundle's build does not match what was asked for.

Switching between methods removes the other one's IIS artifact and reconciles MOUNT_PATH and CORS_ORIGINS, so a server never ends up with both.

Upgrades

Run a newer installer over an existing install. It:

  • backs the database up first, verifies the dump is complete, and refuses to migrate if it cannot;
  • restores from that backup if migrations fail, and reports honestly that DDL the failed migration committed cannot be undone;
  • refuses to run a bundle older than what is installed;
  • keeps .env unless new credentials are supplied, and copies it aside first;
  • stops the app pool before replacing files, then starts it again.

Whether a run is an upgrade is decided by probing the target database, not by whether the app directory exists - a rebuilt server pointed at an existing database is an upgrade, and treating it as fresh would drop tables.

Testing notes

Verified end to end on Windows Server 2025 against both a bundled MySQL 8.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).