e650eb02202b94bac6e1b262fd1aedd304624e94
15 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e650eb0220 |
fix(installer): database page lost its Username and Password boxes
Reported from the Server 2019 test: the Existing database page rendered a truncated "Username:" label and no input boxes at all below it, so there was no way to enter connection details. CreateInputQueryPage lays its fields out BELOW the description text. The description had grown to include a five-line CREATE DATABASE / CREATE USER / GRANT block, added so a DBA could be handed the exact SQL. With five fields underneath, the last two fell past the bottom of the page surface, where they are simply not drawn - no error, no scrollbar, just missing controls. The description is back to three lines. The SQL moves to docs/INSTALL-WINDOWS.md, which is where someone would look for it anyway and where it can be copied without being retyped from a wizard page. Wizard page descriptions are a fixed budget: anything long enough to be worth reading twice belongs in the guide, not on the page. |
||
|
|
c5797bb339 |
fix(installer): payload verification broke on 8.3 short paths
A Server 2019 install reported all 96 payload files as simultaneously missing and unexpected, with mangled names - wheels/heels/flask.whl, python/ython/python-3.14.6-amd64.exe, mysqlclient/lient/mysql.exe. Exactly five characters of each directory name survived, which is the difference between ADMINI~1 and Administrator. Inno extracts the bundle under C:\Users\ADMINI~1\AppData\Local\Temp\..., an 8.3 SHORT path. Resolve-Path kept that short form while Get-ChildItem returned the long one, so the root was five characters shorter than the prefix being sliced off every FullName, and every relative key came out wrong. The payload was correct; the comparison was not - the verifier refused a perfectly good bundle. The root now comes from Get-Item, which goes through the same provider as Get-ChildItem so their path forms agree, and the prefix is checked with StartsWith before being trimmed. If the two ever disagree again this throws instead of inventing paths. Verified against the real failure mode rather than assumed: running the check through C:\SHOPDB~3\bundle in a Windows VM now passes. Nothing on Linux or in a normally-pathed Windows directory could have caught this - the short name only appears under a profile directory long enough to need one, which is where Setup extracts. |
||
|
|
263ae8e3b4 |
fix(installer): install the Visual C++ runtime before MySQL, and log the MSI
Second failure from the Server 2019 test. The previous fix worked - msiexec went from exit 1639 (ERROR_INVALID_COMMAND_LINE, which is why it printed its usage dialog) to exit 1603 (ERROR_INSTALL_FAILURE), so the command line parses now and the MSI itself is failing. It failed in 1.1 seconds. An MSI that dies that fast has not begun installing; it has failed a launch condition. MySQL 8.4 requires the Visual C++ redistributable and a bare Windows Server does not ship it - the same runtime mysql.exe and mysqldump.exe import, which was visible when their DLL dependencies were trimmed and went unnoticed. Stage 0 now installs VC_redist.x64.exe from the bundle before touching MySQL, skipping it when vcruntime140.dll is already present, and fails with a sentence naming the requirement if the redistributable is absent from the bundle altogether. vcredist\ is an optional locked payload. msiexec also gets /l*v now. A bare 1603 names neither the failing action nor the reason, and it is the most common MySQL install failure - diagnosing this one took a launch-condition inference rather than a log. The MSI log lands beside the installer's own in ProgramData, so the next failure is readable instead of guessed at. |
||
|
|
8d0afc40d3 |
fix(installer): bundled MySQL install failed on a malformed msiexec command line
Reported from a Windows Server 2019 test: a "Windows Installer" dialog listing every msiexec /Option appeared, then the wizard reported that the bundled MySQL database could not be installed. That dialog is msiexec's usage help - it prints it when the command line does not parse - so the install never started. Cause: $MysqlRoot defaulted to 'C:\Program Files\MySQL\MySQL Server 8.4', which contains spaces. Invoke-Native wraps any argument containing whitespace in quotes, producing "INSTALLDIR=C:\Program Files\...". msiexec takes public properties as PROPERTY=value and expects the VALUE quoted - INSTALLDIR="C:\Program Files\..." - so it rejected the line, printed usage, and exited non-zero. This file already carried the rule, next to the Python target: "Never put a space in a path this installer controls." I broke it setting the 8.4 path. Two fixes, because one of them alone leaves the trap in place: - $MysqlRoot is now C:\MySQL84, space-free like C:\Python314. The MySQL client search paths in the installer, the preflight and the operator console all look there first, keeping backups working against the bundled server. - Invoke-Native now quotes PROPERTY=value correctly, so passing a spaced path explicitly no longer produces an unparseable command line. tests/test_installer_defaults.py fails if an installer-controlled path default ever contains a space again. |
||
|
|
5321649e02 |
fix(installer): stop blocking the wizard on things the installer itself installs
The preflight page began refusing to continue while any check was failing, which is right for something the operator must go and fix. HttpPlatformHandler was marked FAIL when absent - so on a server without it the wizard stopped dead, telling the operator the server was not ready, over a module the bundle carries and stage 4 installs a few pages later. The only way forward was to go and install by hand the exact thing the installer was about to install. It is now INFO: reported, not blocking, matching how URL Rewrite is already handled. Nothing the installer SUPPLIES may block the wizard, and tests/test_installer_defaults.py now fails if that rule is broken again. The site-port conflict check is downgraded from FAIL to WARN for the same class of reason: it runs before the operator reaches the Address page, so it tests the DEFAULT port rather than the one they intend to use, and blocking refuses an install over a conflict the very next page lets them resolve. Genuine blockers are unchanged - no IIS, no WebAdministration, wrong Windows edition or architecture, no disk, and the MySQL 5.6 index flags. Those the operator really does have to fix first. |
||
|
|
f72813ed9c |
feat(installer): bundle the database - MySQL 8.4 LTS, not 8.0
The bundled-database option could not actually be built. Stage 0 looks for mysql\mysql-8.0.x-winx64.msi, and Oracle no longer publishes a standalone server MSI for 8.0 - every 8.0.x returns 404. What remains for 8.0 is the MySQL Installer bundle, which is an installer-manager: 'msiexec /i INSTALLDIR=' would install THAT rather than a database, and stage 0 would then fail on a missing mysqld.exe. MySQL 8.0 also reached end of life in April 2026, so bundling it would have put an unsupported database on every new site. 8.4 LTS still ships the standalone MSI (129MB, which is what the '125MB' note in stage 0 was written against) and is supported into 2032. Defaults follow it: install root MySQL Server 8.4, service MySQL84. The operator console still looks for an 8.0 install path as a fallback, for sites already running one. Also bundles mysqlclient\ - mysql.exe and mysqldump.exe with the two OpenSSL DLLs they actually import, 20MB rather than the 51MB of debug and auth-plugin libraries the archive ships. Stage 2 stages it onto the server, so a site whose database is on ANOTHER host can still take the pre-upgrade backup that every upgrade depends on. That was the gap the preflight had started warning about. Bundle is now 221MB. |
||
|
|
2c415a1712 |
fix(installer): correct a false security claim, and clear the should-fix list
CLIENT IP / SPOOFABILITY. docs/geenforce-api-cutover.md claimed that removing the IIS rewrite rule made the allowlist fail closed and that it does NOT become spoofable. The opposite is true. IIS never sets X-Forwarded-For on its own; the rule is the only thing that does. Remove it and IIS still forwards whatever X-Forwarded-For the CALLER sent, waitress trusts it because it arrives from 127.0.0.1, and remote_addr becomes attacker-controlled - so a token-less caller can fetch manifests from anywhere on the network. The document and the _trusted_client_ip docstring now say so, waitress runs with --trusted-proxy-count=1, and stage 5 checks the rule is actually live rather than assuming it. The wizard question is rephrased to something an operator can verify with their network team instead of guessing at. NON-ASCII. The style gate only ever checked .py/.vue/.js/.ts, so documentation accumulated em-dashes, arrows and box-drawing characters against this repo's own convention - including in files added this week. Cleaned, and the gate now uses INCLUDES_ALL so Markdown, JSON and YAML are covered. PLUGIN DEFAULTS. The wizard pre-ticked measuringtools and printedparts, both of which ship default_enabled=false, so every site taking the defaults installed and enabled them against their manifests. Inno has no JSON parser so the list must be hardcoded, but tests/test_installer_defaults.py now fails when it drifts. UPGRADES. The payload copy merges, so a plugin dropped from a site's profile kept its code forever - which defeats a lean build and leaves core's optional-import guards succeeding for a plugin the site no longer has. Stale plugin directories are now deregistered and removed before the copy. add-plugin used 'plugin install', which for the five default_enabled=false plugins left them installed but DISABLED - and printed a green success line anyway. It now goes through apply-profile, and the success line is gated on the exit code. Invoke-Flask records its own exit status, because $LASTEXITCODE keeps a stale value when flask.exe is missing and no native command runs. CHARSET. The utf8mb4 compiler hook lived inline in migrations/env.py, so it covered the CORE chain only: plugin baselines inherited the server default, which on a latin1 server means two charsets in one database. It is now shopdb/utils/mysql_charset.py, imported by both, and preflight reports the database's default charset. BACKUP HONESTY. The dump was described as 'all of your asset data'. Uploaded branding and floor-map images live in instance\ on disk, not in the database, so a restore from the .sql alone comes back with no map. backup now archives instance\ alongside it and says both are needed. VERSIONING. AppVersion was hardcoded at 0.9.0 while the product, the frontend and the newest tag said 0.7.0 - and 0.9.0 collides with a retired contract version. Both builders now generate version.iss from shopdb/__init__.py. Smaller: rollback overwrites .env before deleting it, as uninstall already did; appcmd unlocks are scoped to this site's location rather than server-wide, with the wide unlock as a fallback; DEVELOPMENT-SETUP says Python 3.14; the README plugin list gains printedparts; prune-schema --force is documented as first-provisioning-only; HTTPS is documented as not-the-default with the steps to add it; the DBA SQL is on the wizard's database page; the features page says unticking does not remove an installed feature; and the installer README states that bundle-lock cannot vouch for the exe itself - that needs signing or an out-of-band hash, neither of which is wired up. |
||
|
|
aea2905de0 |
fix(installer): stop it lying, stop it leaking, and make it findable
Nine fixes from a review of the installer against its actual audience: DT leads at sister sites who are not Windows, IIS or Python specialists and who will lean on an AI assistant to get through it. TRUTHFULNESS. The preflight was advisory - an operator read 'IIS is not installed', pressed Next, answered five more pages and the install died partway through with Python already on the box. The results page now blocks while anything is failing, repaints on every run instead of latching after the first, and offers 'Check again' so a fixed problem does not mean starting over. On failure the wizard said 'Nothing was left running', which is false in every path because the stages run with -OnFailure never: it now says the server is part-configured, that re-running is safe, and how to remove it. The final page no longer reads 'ShopDB-Flask is ready' after a failed install. SECRETS. The generated MySQL root password went to Write-Host in a process the wizard runs hidden - so nobody saw it - and stdout is forwarded into the setup log operators are told to send to support, so it was permanently recorded for everyone who did not need it. It now goes to an ACL'd file. Database dumps, which contain every user password hash, landed in a ProgramData directory readable by every user on the box; the directory is now locked at creation. UPGRADES ON REMOTE-DATABASE SITES. mysqldump was looked for only under local MySQL install paths, so a site whose database is on another host silently skipped every pre-upgrade backup - after stage 2 had already stopped the pool and replaced the tree. Find-MysqlTool now prefers a client shipped in the bundle, stage 2 stages it onto the server, preflight reports when it is missing, and mysqlclient\ is an optional locked payload. UNINSTALL. A subpath install is an IIS Application, not a site; removing only the site left the application pointing at a deleted directory, so the parent site - at West Jefferson, the live classic ASP - served 503 on that path forever while Add/Remove Programs reported success. Uninstall now reads MOUNT_PATH and removes the application. The firewall rule was created as "$SiteName $SitePort" and removed as the literal 'ShopDB-Flask 8090', which matches nothing. DAY-2 TOOLING. Every shortcut now passes -AppRoot and -SitePort, and the console forwards them through its own elevation and 32-bit relaunches instead of discarding them - a non-default directory or port made it report a healthy site as broken, from a shortcut the installer wrote. 'Open ShopDB-Flask' resolved to a hardcoded localhost:8090 that was wrong for every subpath install; it now asks the console, which reads the address the installer recorded, and no longer demands administrator to open a browser. SMOKE TEST. The parent-site port lookup filtered for an http binding and defaulted to 80, so an https-only parent site failed a working install with a red dialog. DOCS AND /api/docs. The installer was invisible: nothing in docs/, README.md or CLAUDE.md mentioned it, so a DT lead or their assistant landed on the manual IIS runbook and hand-built the very server the installer then refuses to upgrade. docs/INSTALL-WINDOWS.md and docs/OPERATE-WINDOWS.md are now the canonical route, the two manual runbooks are bannered as reference-only, README and CLAUDE.md route by target, and llms.txt tells an assistant which document to follow and to ask for 'check -Json' before diagnosing. Both ship on the server, along with openapi.json and llms.txt - without those the self-hosted /api/docs was broken on every installed box, which matters most to the sites least able to debug it. Stage 5 now checks it actually serves. shopdb-admin.ps1 gains 'check -Json': one structured, secret-free block covering version, publishing method, IIS state, HTTP reachability, database, Python version, plugins and errors. That is the cheapest useful answer to 'the operator will ask an LLM' - it works with no infrastructure, which a install-time MCP server could not. |
||
|
|
e58f376643 |
fix(installer): the adopt-existing guard never fired
Stage 4 decided whether the IIS objects it was about to reconcile were its own by testing for .installed-version. Stage 2 writes that file, and stage 2 always runs first in a '-Stage all' install - so by the time the guard looked, the stamp it had just written made every server look like one this installer built, including the hand-built ones the guard exists to protect. Stage 2 now records whether a stamp was present BEFORE it writes its own, and stage 4 reads that observation. Running stage 4 alone still tests the file, which is correct there: no stage 2 has run to disturb it. Found by review, not by test - the guard has no coverage, because exercising it needs a live IIS. |
||
|
|
3606d8d696 |
feat(sbom): ship a CycloneDX bill of materials with every build
An air-gapped site cannot be scanned from anywhere else, so when a CVE lands the only way to answer 'is that component here, and at what version' was to RDP in and go looking. The frontend was the real blind spot: nothing recorded which version of leaflet, dompurify, jspdf or html2canvas ends up inside the compiled SPA. scripts/generate_sbom.py emits CycloneDX 1.6 covering both ecosystems - every pin in requirements.txt with the sha256 the installer enforces, and every package in package-lock.json. Build-only npm packages are marked scope 'excluded' rather than dropped, so 'not here' stays distinguishable from 'not looked for'. Dependency edges are real: uv's '# via' comments give the Python graph and package-lock gives the npm one. Hand-rolled rather than cyclonedx-py plus cyclonedx-npm because both inputs are already pinned and committed - this is a format translation, not a scan - and because the build box may be a work PC with nothing but Python and Node. It is deterministic by construction: same inputs, byte-identical output, so regenerating does not churn. Staged into the application tree by both builders, so it installs onto the server with the app. shopdb-admin.ps1 verify reports it and searches it by component name, which is the question actually being asked. Packages appearing at several depths in package-lock (node_modules/vite and node_modules/vitest/node_modules/vite) are merged, and a copy reachable outside the dev tree makes the component count as shipped. Emitting both produced duplicate bom-refs, which CycloneDX forbids and scanners reject; getting the dev merge backwards would have hidden a shipped package from a CVE search. Not covered by bundle-lock.json on purpose: its provenance is git, not the third-party payload. |
||
|
|
1bf3cb2e1c |
feat(installer): refuse to damage an installation it did not create
Two guards for a server deployed by hand, which the West Jefferson production box is. An existing venv is reused, which is right for a repair or an upgrade of an install this made, and wrong when the venv belongs to a different Python. The wheelhouse is tagged for one minor version, so pip finds no candidate for the compiled packages and dies partway through - after Python has been installed and the application tree replaced. The two versions are now compared up front and the run stops with both numbers and what to do about it. Switching deployment method removes the other method's IIS artifact. That is correct when this installer owns both and dangerous when it does not: a wrong -MountAlias would call Remove-WebApplication on a live mount with no prompt and no error, and the first sign would be the site returning 404. It now refuses unless a version stamp shows this installer made the install, or -AdoptExisting is passed, and the refusal lists exactly what it would have removed. |
||
|
|
13d831eb90 |
feat(installer): require the wheelhouse to satisfy requirements.txt, and lock the real payload
The lock records what IS in the wheelhouse, not what the application NEEDS, so an incomplete wheelhouse was locked, blessed and shipped - and only failed on an air-gapped server. That is 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. The bundle built here was short exactly that one wheel. Both verifiers now cross-check wheels/ against the staged requirements.txt, ignoring markers, since a requirement guarded by sys_platform == 'win32' is precisely the one that must be present. Names are normalised to PEP 427 wheel form, so mysql-connector-python matches mysql_connector_python. bundle-lock.json is the first real lock: 42 files, cp314/win_amd64 - 39 wheels, Python 3.14.6, HttpPlatformHandler 1.2 and URL Rewrite. MySQL is absent and optional; a site choosing the bundled-database option adds it and re-locks. The naming gate now skips the installer's build output. It contains a staged copy of the application plus a second SPA build under dist-subpath, which --exclude-dir=dist does not match, so a staged bundle failed the gate on vendored minified JS nobody in this repository wrote. |
||
|
|
44237b5cbd |
feat(installer): bundle URL Rewrite, ask where client IPs come from, verify installs
IIS does not set X-Forwarded-For on its own and HttpPlatformHandler connects from loopback, so without a rewrite rule 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 stop working, silently. The rule needed URL Rewrite, which the installer told operators to download - from an air-gapped server. URL Rewrite now ships in the bundle, and the wizard asks which case applies, because the two answers are mutually exclusive. Directly exposed: install it and set X-Forwarded-For from REMOTE_ADDR, which is what stops a client spoofing its own. Behind a proxy: leave the rule off, since REMOTE_ADDR is the proxy and applying it would discard the real client IP. The rule is enabled by deleting two explicit marker lines rather than by a regex over the surrounding comment, so editing that prose cannot silently disable it. An existing web.config is no longer overwritten. It is the one file on a server that legitimately carries hand-edits, and replacing it reverted them without a word - on a server where the X-Forwarded-For rule had been enabled by hand, that alone would have turned the GE-Enforce IP allowlist off. The installer reports what it found instead. pip now runs with --require-hashes and --only-binary=:all:. Hash-checking is requested explicitly rather than inferred from the lockfile, so shipping an unhashed requirements.txt fails loudly instead of quietly dropping the check. shopdb-admin.ps1 gains a verify command: which bundle this server was installed from, and whether the installed packages still match what shipped. The .iss states its compiler floor. WizardStyle uses the built-in windows11 custom style, which needs Inno Setup 6.6.0; older compilers now fail with that sentence rather than 'WizardStyle is invalid'. |
||
|
|
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. |
||
| 0fa5f1e910 |
feat(deploy): add the air-gapped Windows installer
Roughly 2500 lines of tested installer had been living in ~/Downloads and an untracked folder - nothing was under version control. It goes here rather than in a repo of its own because it depends on application internals: the `flask plugin` verbs, site-profile.json, MOUNT_PATH, and the plugin registry. Versioned separately it would drift out of step with the thing it installs. Contents: the read-only preflight, the staged installer (bundled MySQL, runtime, schema, IIS, verify, uninstall), the operator console, the Inno Setup wizard, the bundle builder and the artwork generator. bundle/ and Output/ are ignored - regenerable, and ~220MB. plugins.iss is ignored because build-installer.sh generates it from the staged payload. The artwork IS committed so a Windows build box does not need Python and cairosvg. Verified end to end on Windows Server 2025 against a bundled MySQL 8.0 and an existing MySQL 5.6: fresh install, upgrade with backup and rollback, re-run idempotency, uninstall, and both deployment methods including switching between them. Not yet verified: a hypervisor-level air-gapped run, and any load from a real browser (every HTTP check so far used curl, which sends no Origin header). |