Windows installer: payload hash-locking, SBOM, client-IP handling, operator docs
6
deploy/windows/installer/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Build output and staged payload - regenerable, and ~220MB.
|
||||
bundle/
|
||||
Output/
|
||||
# Generated by build-installer.sh from the staged bundle.
|
||||
plugins.iss
|
||||
version.iss
|
||||
245
deploy/windows/installer/README.md
Normal file
@@ -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://<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).
|
||||
1174
deploy/windows/installer/ShopDBFlask.iss
Normal file
301
deploy/windows/installer/build-installer.ps1
Normal file
@@ -0,0 +1,301 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage a lean per-site installer bundle on Windows, and verify its third-party
|
||||
payload against bundle-lock.json.
|
||||
|
||||
.DESCRIPTION
|
||||
The Windows equivalent of build-installer.sh, for a work PC with no Bash. It
|
||||
does the whole job natively - it does NOT shell out to build-site.sh - so the
|
||||
only tools needed are Python, Node and (to compile) Inno Setup.
|
||||
|
||||
What it does, in order:
|
||||
|
||||
1. Resolves the site's plugin closure from its profile.
|
||||
2. Builds the SPA twice, because Vite compiles the base path in and it
|
||||
therefore cannot be chosen at install time: once for /<alias> (subpath
|
||||
deployment) and once for / (its own site).
|
||||
3. Stages the backend tree: core, the chosen plugins only, and the runtime
|
||||
files a deployable tree needs.
|
||||
4. Writes plugins.iss so the wizard's plugin page matches the payload.
|
||||
5. Copies the installer scripts from THIS directory.
|
||||
6. Verifies wheels\, python\ and the MSIs against bundle-lock.json, and
|
||||
FAILS if the bundle is not exactly what the lock describes.
|
||||
|
||||
Step 6 is the point of the script. A bundle that does not match its lock is
|
||||
not shipped, and "the wheelhouse was missing" is caught here rather than by
|
||||
an operator halfway through installing on a server with no network.
|
||||
|
||||
.PARAMETER Profile
|
||||
Path to the site profile (see deploy\site-profile.example.json).
|
||||
|
||||
.PARAMETER RepoRoot
|
||||
The repository. Defaults to four levels up from this script, which is correct
|
||||
for a normal checkout.
|
||||
|
||||
.PARAMETER SkipFrontend
|
||||
Reuse the SPA builds already staged in the bundle. For iterating on the
|
||||
installer itself, where two Vite builds per run is most of the wall clock.
|
||||
Never use it for a bundle you intend to ship.
|
||||
|
||||
.PARAMETER AllowUnlocked
|
||||
Stage the bundle and report payload problems WITHOUT failing. For assembling
|
||||
a bundle before its lock exists. A bundle built this way must not be shipped;
|
||||
run refresh-bundle-lock.ps1, commit the lock, then build again without this.
|
||||
|
||||
.EXAMPLE
|
||||
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
|
||||
.\build-installer.ps1 -Profile C:\sites\wjf.json -SkipFrontend
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $Profile,
|
||||
[string] $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path,
|
||||
[string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'),
|
||||
[string] $SubpathAlias = 'shopdb',
|
||||
[switch] $SkipFrontend,
|
||||
[switch] $AllowUnlocked
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'bundle-lock.ps1')
|
||||
|
||||
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
||||
function Step { param($m) Write-Host ''; Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Die { param($m, $fix = '') Write-Host ''; Write-Host " $m" -ForegroundColor Red
|
||||
if ($fix) { Write-Host " $fix" -ForegroundColor Yellow }; exit 1 }
|
||||
|
||||
function Invoke-Tool {
|
||||
# Runs a build tool and stops on a non-zero exit. npm writes progress to
|
||||
# stderr on SUCCESS, so stderr alone must never be treated as failure.
|
||||
param([string] $Exe, [string[]] $Arguments, [string] $WorkDir, [string] $What)
|
||||
Push-Location $WorkDir
|
||||
try {
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $Exe @Arguments 2>&1 | ForEach-Object { Say " $_" 'DarkGray' }
|
||||
$code = $LASTEXITCODE
|
||||
$ErrorActionPreference = $prev
|
||||
if ($code -ne 0) { Die "$What failed (exit $code)" }
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Profile)) { Die "profile not found: $Profile" }
|
||||
if (-not (Test-Path $RepoRoot)) { Die "repo not found: $RepoRoot" }
|
||||
$Profile = (Resolve-Path $Profile).Path
|
||||
$RepoRoot = (Resolve-Path $RepoRoot).Path
|
||||
$AppOut = Join-Path $BundleRoot 'app'
|
||||
|
||||
$python = (Get-Command python -ErrorAction SilentlyContinue)
|
||||
if (-not $python) { $python = Get-Command py -ErrorAction SilentlyContinue }
|
||||
if (-not $python) { Die 'Python not found on PATH' 'Install Python 3.14 and re-open the shell.' }
|
||||
$npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
|
||||
if (-not $npm -and -not $SkipFrontend) { Die 'npm not found on PATH' 'Install Node, or pass -SkipFrontend to reuse the staged SPA.' }
|
||||
|
||||
Say ''
|
||||
Say " repo : $RepoRoot"
|
||||
Say " profile : $Profile"
|
||||
Say " bundle : $BundleRoot"
|
||||
|
||||
# --- 1. plugin closure ------------------------------------------------------
|
||||
# Same resolver the Linux builder uses, so both produce the same set from one
|
||||
# profile instead of two implementations of the closure rules.
|
||||
Step 'Resolving plugin closure'
|
||||
$closure = (& $python.Source (Join-Path $RepoRoot 'scripts\resolve_plugin_closure.py') $Profile $RepoRoot)
|
||||
if ($LASTEXITCODE -ne 0 -or -not $closure) { Die 'could not resolve the plugin closure from the profile' }
|
||||
$closure = $closure.Trim()
|
||||
Say " $closure" 'White'
|
||||
|
||||
# --- 2. frontend ------------------------------------------------------------
|
||||
$frontend = Join-Path $RepoRoot 'frontend'
|
||||
$subStaged = Join-Path $BundleRoot 'spa-subpath'
|
||||
$rootStaged= Join-Path $BundleRoot 'spa-root'
|
||||
|
||||
if ($SkipFrontend) {
|
||||
if (-not (Test-Path $subStaged) -or -not (Test-Path $rootStaged)) {
|
||||
Die '-SkipFrontend was passed but no SPA build is staged' 'Run once without it.'
|
||||
}
|
||||
Say ''
|
||||
Say ' Reusing the staged SPA builds (-SkipFrontend). NOT shippable if the frontend changed.' 'Yellow'
|
||||
} else {
|
||||
Step "Building the SPA for /$SubpathAlias/"
|
||||
$env:SITE_PLUGINS = $closure
|
||||
$env:VITE_BASE_PATH = "/$SubpathAlias/"
|
||||
try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'subpath frontend build' }
|
||||
finally { Remove-Item Env:\VITE_BASE_PATH -ErrorAction SilentlyContinue }
|
||||
Remove-Item $subStaged -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item (Join-Path $frontend 'dist') $subStaged -Recurse -Force
|
||||
# The alias is fixed at BUILD time; the installer reads this and refuses to
|
||||
# publish under a different one rather than serving a page that cannot load
|
||||
# its own assets.
|
||||
Set-Content -Path (Join-Path $subStaged '.alias') -Value $SubpathAlias -Encoding ASCII
|
||||
|
||||
# Root build LAST, so frontend\dist is left in the state a developer expects.
|
||||
Step 'Building the SPA for /'
|
||||
try { Invoke-Tool $npm.Source @('run','build','--silent') $frontend 'root frontend build' }
|
||||
finally { Remove-Item Env:\SITE_PLUGINS -ErrorAction SilentlyContinue }
|
||||
Remove-Item $rootStaged -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item (Join-Path $frontend 'dist') $rootStaged -Recurse -Force
|
||||
}
|
||||
|
||||
# --- 3. backend tree --------------------------------------------------------
|
||||
Step "Staging the application tree"
|
||||
Remove-Item $AppOut -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path (Join-Path $AppOut 'plugins') -Force | Out-Null
|
||||
|
||||
Copy-Item (Join-Path $RepoRoot 'shopdb') $AppOut -Recurse -Force
|
||||
foreach ($name in $closure.Split(',')) {
|
||||
$src = Join-Path $RepoRoot ('plugins\' + $name.Trim())
|
||||
if (-not (Test-Path $src)) { Die "plugin in the closure is not on disk: $name" }
|
||||
Copy-Item $src (Join-Path $AppOut 'plugins') -Recurse -Force
|
||||
}
|
||||
|
||||
# Runtime files a deployable tree needs beyond the Python packages. Without
|
||||
# these the tree imports but cannot be run or migrated.
|
||||
foreach ($f in @('wsgi.py', 'requirements.txt')) {
|
||||
Copy-Item (Join-Path $RepoRoot $f) $AppOut -Force
|
||||
}
|
||||
Copy-Item (Join-Path $RepoRoot 'migrations') $AppOut -Recurse -Force
|
||||
# ONLY web.config, not all of deploy\. The bundle is staged INSIDE deploy\, so
|
||||
# copying the whole tree would recurse into its own output, and the rest of
|
||||
# deploy\ is installer source that has no business on an application server.
|
||||
# shopdb-install.ps1 reads it from exactly this path.
|
||||
$cfgSrc = Join-Path $RepoRoot 'deploy\windows\web.config'
|
||||
if (Test-Path $cfgSrc) {
|
||||
New-Item -ItemType Directory -Path (Join-Path $AppOut 'deploy\windows') -Force | Out-Null
|
||||
Copy-Item $cfgSrc (Join-Path $AppOut 'deploy\windows') -Force
|
||||
}
|
||||
|
||||
# A CycloneDX SBOM of everything this tree depends on, Python and npm together.
|
||||
# Staged INTO the tree so it installs onto the server with the application: an
|
||||
# air-gapped site cannot be scanned remotely, so the only way to answer "are we
|
||||
# exposed to this CVE, and where" is for the answer to be sitting on the box.
|
||||
Step 'Generating SBOM'
|
||||
& $python.Source (Join-Path $RepoRoot 'scripts\generate_sbom.py') $RepoRoot `
|
||||
-o (Join-Path $AppOut 'sbom.cdx.json') | ForEach-Object { Say " $_" 'White' }
|
||||
if ($LASTEXITCODE -ne 0) { Die 'SBOM generation failed' }
|
||||
|
||||
# Docs the running site serves, plus the runbooks an air-gapped server has no
|
||||
# other way to reach. Without openapi.json and llms.txt the self-hosted /api/docs
|
||||
# page is broken on every installed server.
|
||||
Step 'Staging docs'
|
||||
$docsOut = Join-Path $AppOut 'docs'
|
||||
New-Item -ItemType Directory -Path $docsOut -Force | Out-Null
|
||||
foreach ($doc in @('openapi.json', 'llms.txt', 'api-inventory.json',
|
||||
'INSTALL-WINDOWS.md', 'OPERATE-WINDOWS.md',
|
||||
'BACKUP-RESTORE.md', 'UPGRADE.md')) {
|
||||
$src = Join-Path $RepoRoot ('docs\' + $doc)
|
||||
if (Test-Path $src) { Copy-Item $src $docsOut -Force; Say " $doc" }
|
||||
}
|
||||
|
||||
# Stage the profile INTO the tree: `flask plugin apply-profile` at provisioning
|
||||
# reads the same profile the tree was staged from, so the installed plugin set
|
||||
# and the shipped plugin code cannot drift.
|
||||
Copy-Item $Profile (Join-Path $AppOut 'site-profile.json') -Force
|
||||
|
||||
# The installer expects frontend\dist and frontend\dist-subpath.
|
||||
$feOut = Join-Path $AppOut 'frontend'
|
||||
New-Item -ItemType Directory -Path $feOut -Force | Out-Null
|
||||
Copy-Item $rootStaged (Join-Path $feOut 'dist') -Recurse -Force
|
||||
Copy-Item $subStaged (Join-Path $feOut 'dist-subpath') -Recurse -Force
|
||||
|
||||
Get-ChildItem $AppOut -Recurse -Directory -Filter '__pycache__' -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem $AppOut -Recurse -File -Filter '*.pyc' -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# --- 4. plugins.iss ---------------------------------------------------------
|
||||
Step 'Writing plugins.iss'
|
||||
$shipped = (Get-ChildItem (Join-Path $AppOut 'plugins') -Directory | Select-Object -ExpandProperty Name) -join ','
|
||||
$aliasBuilt = ''
|
||||
$aliasFile = Join-Path $feOut 'dist-subpath\.alias'
|
||||
if (Test-Path $aliasFile) { $aliasBuilt = (Get-Content $aliasFile -TotalCount 1).Trim() }
|
||||
@"
|
||||
; GENERATED by build-installer.ps1 - do not edit.
|
||||
; The plugins present in bundle\app\plugins at build time.
|
||||
#define AvailablePlugins "$shipped"
|
||||
; The alias the subpath SPA was built for, or empty if this bundle has no
|
||||
; subpath build - in which case the wizard must not offer that option.
|
||||
#define SubpathAlias "$aliasBuilt"
|
||||
"@ | Set-Content -Path (Join-Path $PSScriptRoot 'plugins.iss') -Encoding ASCII
|
||||
Say " $shipped" 'White'
|
||||
|
||||
# The product version, read from the code rather than restated in the .iss.
|
||||
Step 'Writing version.iss'
|
||||
$initText = Get-Content (Join-Path $RepoRoot 'shopdb\__init__.py') -Raw
|
||||
if ($initText -notmatch "(?m)^__version__\s*=\s*'([^']+)'") { Die 'could not read __version__ from shopdb\__init__.py' }
|
||||
$appVersion = $Matches[1]
|
||||
@"
|
||||
; GENERATED by build-installer.ps1 from shopdb/__init__.py - do not edit.
|
||||
#define AppVersion "$appVersion"
|
||||
"@ | Set-Content -Path (Join-Path $PSScriptRoot 'version.iss') -Encoding ASCII
|
||||
Say " $appVersion" 'White'
|
||||
|
||||
# --- 5. installer scripts ---------------------------------------------------
|
||||
# From THIS directory, which is the reviewed copy under version control. They
|
||||
# used to be copied from a downloads folder, so the logic that shipped was not
|
||||
# the logic that was committed and the build only worked on one machine.
|
||||
Step 'Copying installer scripts'
|
||||
foreach ($f in @('shopdb-install.ps1', 'shopdb-preflight.ps1', 'bundle-lock.ps1')) {
|
||||
$src = Join-Path $PSScriptRoot $f
|
||||
if (-not (Test-Path $src)) { Die "installer script missing from the repo: $f" }
|
||||
Copy-Item $src $BundleRoot -Force
|
||||
Say " $f"
|
||||
}
|
||||
|
||||
# --- 6. payload verification ------------------------------------------------
|
||||
Step 'Verifying the third-party payload against bundle-lock.json'
|
||||
$lockPath = Join-Path $PSScriptRoot 'bundle-lock.json'
|
||||
$lock = Read-BundleLock $lockPath
|
||||
if (-not $lock) {
|
||||
$msg = "no bundle-lock.json at $lockPath"
|
||||
if ($AllowUnlocked) { Say " $msg - continuing because -AllowUnlocked was passed" 'Yellow' }
|
||||
else {
|
||||
Die $msg @'
|
||||
Add the wheels and installers to the bundle, then:
|
||||
.\refresh-bundle-lock.ps1 (review the list)
|
||||
.\refresh-bundle-lock.ps1 -Yes (write it)
|
||||
and commit bundle-lock.json. Pass -AllowUnlocked to stage a bundle without one -
|
||||
it must not be shipped.
|
||||
'@
|
||||
}
|
||||
} else {
|
||||
$problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock
|
||||
if ($problems.Count -eq 0) {
|
||||
Say (" payload matches the lock ({0}, {1})" -f `
|
||||
(Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'Green'
|
||||
# Ships WITH the bundle: the installer re-checks the payload on the
|
||||
# target server before running any of it, so tampering between build and
|
||||
# install is caught too.
|
||||
Copy-Item $lockPath $BundleRoot -Force
|
||||
} else {
|
||||
Say ''
|
||||
foreach ($p in $problems) { Say " $p" 'Red' }
|
||||
Say ''
|
||||
if ($AllowUnlocked) {
|
||||
Say ' -AllowUnlocked: continuing anyway. DO NOT SHIP this bundle.' 'Yellow'
|
||||
} else {
|
||||
Die ("{0} payload problem(s) - the bundle is not what the lock describes" -f $problems.Count) @'
|
||||
Either the payload is wrong (fix the bundle) or it changed on purpose (run
|
||||
refresh-bundle-lock.ps1, read the diff, and commit the new lock).
|
||||
'@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- summary ----------------------------------------------------------------
|
||||
Write-Host ''
|
||||
Say " Bundle staged at: $BundleRoot" 'White'
|
||||
foreach ($d in @('app', 'wheels', 'python', 'httpplatformhandler', 'urlrewrite', '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 ''
|
||||
140
deploy/windows/installer/build-installer.sh
Executable file
@@ -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 <site-profile.json> [repo-path]
|
||||
#
|
||||
# The wheelhouse cannot be built here. Wheels are cp314 win_amd64 and must be
|
||||
# produced ON Windows with the matching Python:
|
||||
# pip download -r requirements.txt -d wheels --only-binary=:all:
|
||||
# Copy that wheels\ directory in before compiling.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROFILE="${1:?usage: build-installer.sh <site-profile.json> [repo-path]}"
|
||||
REPO="${2:-$HOME/projects/shopdb-flask}"
|
||||
BUNDLE="$HERE/bundle"
|
||||
|
||||
[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; }
|
||||
[ -d "$REPO" ] || { echo "repo not found: $REPO"; exit 1; }
|
||||
|
||||
echo "==> Staging lean app tree from $PROFILE"
|
||||
rm -rf "$BUNDLE/app"
|
||||
bash "$REPO/scripts/build-site.sh" "$PROFILE" "$BUNDLE/app"
|
||||
|
||||
# build-site.sh emits the SPA as frontend-dist; the installer's web.config and
|
||||
# static route expect frontend\dist.
|
||||
if [ -d "$BUNDLE/app/frontend-dist" ]; then
|
||||
mkdir -p "$BUNDLE/app/frontend"
|
||||
rm -rf "$BUNDLE/app/frontend/dist"
|
||||
mv "$BUNDLE/app/frontend-dist" "$BUNDLE/app/frontend/dist"
|
||||
fi
|
||||
# The /shopdb-based build, used when the operator picks the subpath deployment.
|
||||
if [ -d "$BUNDLE/app/frontend-dist-subpath" ]; then
|
||||
rm -rf "$BUNDLE/app/frontend/dist-subpath"
|
||||
mv "$BUNDLE/app/frontend-dist-subpath" "$BUNDLE/app/frontend/dist-subpath"
|
||||
fi
|
||||
|
||||
# Tell the .iss which plugins this bundle actually carries, so the wizard's
|
||||
# plugin page always matches the payload instead of a hand-maintained list.
|
||||
echo "==> Writing plugins.iss"
|
||||
PLUGINS=$(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ',' | sed 's/,$//')
|
||||
# The subpath SPA is built with its base path compiled in, so whether the wizard
|
||||
# can OFFER a subpath install is a property of the bundle, not a runtime choice.
|
||||
SUBPATH_ALIAS_BUILT=""
|
||||
if [ -f "$BUNDLE/app/frontend/dist-subpath/.alias" ]; then
|
||||
SUBPATH_ALIAS_BUILT="$(cat "$BUNDLE/app/frontend/dist-subpath/.alias")"
|
||||
fi
|
||||
|
||||
cat > "$HERE/plugins.iss" <<EOF
|
||||
; GENERATED by build-installer.sh - do not edit.
|
||||
; The plugins present in bundle\\app\\plugins at build time.
|
||||
#define AvailablePlugins "$PLUGINS"
|
||||
; The alias the subpath SPA was built for, or empty if this bundle has no
|
||||
; subpath build - in which case the wizard must not offer that option.
|
||||
#define SubpathAlias "$SUBPATH_ALIAS_BUILT"
|
||||
EOF
|
||||
echo " $PLUGINS"
|
||||
|
||||
# The product version, read from the code rather than restated here. A hardcoded
|
||||
# AppVersion in the .iss had drifted two minor versions from shopdb/__init__.py.
|
||||
echo "==> Writing version.iss"
|
||||
APPVERSION=$(sed -n "s/^__version__ = '\\(.*\\)'/\\1/p" "$REPO/shopdb/__init__.py" | head -1)
|
||||
[ -n "$APPVERSION" ] || { echo "could not read __version__ from shopdb/__init__.py"; exit 1; }
|
||||
cat > "$HERE/version.iss" <<EOF
|
||||
; GENERATED by build-installer.sh from shopdb/__init__.py - do not edit.
|
||||
#define AppVersion "$APPVERSION"
|
||||
EOF
|
||||
echo " $APPVERSION"
|
||||
|
||||
# From THIS directory, which is the reviewed copy under version control. These
|
||||
# used to be copied from $HOME/Downloads, so the installer logic that shipped was
|
||||
# not the logic that was committed, and the build only worked on one machine.
|
||||
echo "==> Copying installer scripts"
|
||||
mkdir -p "$BUNDLE"
|
||||
for f in shopdb-install.ps1 shopdb-preflight.ps1 bundle-lock.ps1; do
|
||||
[ -f "$HERE/$f" ] || { echo "installer script missing from the repo: $f"; exit 1; }
|
||||
cp "$HERE/$f" "$BUNDLE/"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Bundle staged at: $BUNDLE"
|
||||
for d in app wheels python httpplatformhandler urlrewrite 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.)"
|
||||
197
deploy/windows/installer/bundle-lock.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
233
deploy/windows/installer/bundle-lock.ps1
Normal file
@@ -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)
|
||||
}
|
||||
159
deploy/windows/installer/make-branding.py
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Inno Setup wizard artwork from the app's own brand assets.
|
||||
|
||||
Everything here is derived from frontend/public/*.svg so the installer and the
|
||||
running application are visibly the same product. Nothing is redrawn by hand.
|
||||
|
||||
Inno stretches artwork to fit and does not resample well, so render at the exact
|
||||
sizes it asks for and supply the 125%/250% variants for high-DPI displays.
|
||||
|
||||
WizardImageFile 164x314, 192x386, 384x772
|
||||
WizardSmallImageFile 55x55, 64x64, 138x138
|
||||
SetupIconFile .ico with 16/24/32/48/64/128/256
|
||||
|
||||
Usage: python3 make-branding.py [output-dir]
|
||||
"""
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cairosvg
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ASSETS = Path.home() / "projects/shopdb-flask/frontend/public"
|
||||
OUT = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)
|
||||
|
||||
# Sampled from the application's own palette so the installer does not look like
|
||||
# a different product wearing the same badge.
|
||||
NAVY = (10, 34, 74) # deep base
|
||||
BLUE = (16, 74, 150) # GE blue
|
||||
CYAN = (0, 158, 224) # accent
|
||||
WHITE = (255, 255, 255)
|
||||
MUTED = (176, 197, 226)
|
||||
|
||||
|
||||
def render_svg(name, width=None, height=None):
|
||||
png = cairosvg.svg2png(url=str(ASSETS / name), output_width=width, output_height=height)
|
||||
return Image.open(io.BytesIO(png)).convert("RGBA")
|
||||
|
||||
|
||||
def recolour(img, colour):
|
||||
"""Replace RGB while keeping the alpha mask. Source marks are dark-on-light;
|
||||
on a dark panel they must be inverted or they disappear."""
|
||||
solid = Image.new("RGBA", img.size, colour + (255,))
|
||||
solid.putalpha(img.getchannel("A"))
|
||||
return solid
|
||||
|
||||
|
||||
def font(size, bold=False):
|
||||
for path in (
|
||||
f"/usr/share/fonts/truetype/dejavu/DejaVuSans{'-Bold' if bold else ''}.ttf",
|
||||
f"/usr/share/fonts/truetype/liberation/LiberationSans{'-Bold' if bold else '-Regular'}.ttf",
|
||||
):
|
||||
if Path(path).exists():
|
||||
return ImageFont.truetype(path, size)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def vertical_gradient(size, top, bottom):
|
||||
w, h = size
|
||||
img = Image.new("RGB", size)
|
||||
draw = ImageDraw.Draw(img)
|
||||
for y in range(h):
|
||||
t = y / max(1, h - 1)
|
||||
# Ease the ramp so the middle does not look flat.
|
||||
t = t * t * (3 - 2 * t)
|
||||
draw.line(
|
||||
[(0, y), (w, y)],
|
||||
fill=tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)),
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
def banner(w, h):
|
||||
img = vertical_gradient((w, h), BLUE, NAVY)
|
||||
draw = ImageDraw.Draw(img)
|
||||
k = w / 164.0 # scale factor from the 100% design
|
||||
|
||||
# Faint diagonal wash: stops the flat area under the text reading as empty.
|
||||
glow = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||
gd = ImageDraw.Draw(glow)
|
||||
gd.polygon([(0, int(h * 0.52)), (w, int(h * 0.30)), (w, h), (0, h)],
|
||||
fill=(255, 255, 255, 10))
|
||||
img = Image.alpha_composite(img.convert("RGBA"), glow).convert("RGB")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
margin = int(22 * k)
|
||||
|
||||
# GE Aerospace wordmark at the top, above the product name - the corporate
|
||||
# mark leads, the product sits under it. (Previously the bare monogram was
|
||||
# here and the wordmark was stranded at the bottom.)
|
||||
mark_w = w - (margin * 2)
|
||||
mark = render_svg("ge-aerospace-logo.svg", mark_w, int(mark_w * 32 / 138))
|
||||
mark = recolour(mark, WHITE)
|
||||
img.paste(mark, (margin, int(34 * k)), mark)
|
||||
|
||||
# Product name, directly beneath it.
|
||||
y = int(34 * k) + mark.height + int(30 * k)
|
||||
draw.text((margin, y), "ShopDB", font=font(int(26 * k), bold=True), fill=WHITE)
|
||||
y += int(31 * k)
|
||||
|
||||
# Hairline rule, then the descriptor. Cheap way to look considered.
|
||||
draw.rectangle([margin, y, margin + int(30 * k), y + max(1, int(2 * k))], fill=CYAN)
|
||||
y += int(14 * k)
|
||||
for line in ("Asset management", "for the shop floor"):
|
||||
draw.text((margin, y), line, font=font(int(10.5 * k)), fill=MUTED)
|
||||
y += int(15 * k)
|
||||
|
||||
# Accent bar flush to the bottom edge.
|
||||
bar = max(2, int(4 * k))
|
||||
draw.rectangle([0, h - bar, w, h], fill=CYAN)
|
||||
return img
|
||||
|
||||
|
||||
def small(size):
|
||||
"""Header mark on every page after the welcome page. White plate so it sits
|
||||
correctly on the wizard's own header, in light or dark mode."""
|
||||
img = Image.new("RGB", (size, size), WHITE)
|
||||
m = int(size * 0.80)
|
||||
mono = recolour(render_svg("ge-monogram.svg", m, m), BLUE)
|
||||
off = (size - m) // 2
|
||||
img.paste(mono, (off, off), mono)
|
||||
return img
|
||||
|
||||
|
||||
def icon(path):
|
||||
"""Installer icon. Rounded navy tile with the monogram, so it reads at 16px
|
||||
instead of turning into mush."""
|
||||
base = 256
|
||||
img = Image.new("RGBA", (base, base), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rounded_rectangle([0, 0, base - 1, base - 1], radius=int(base * 0.22), fill=BLUE + (255,))
|
||||
d.rounded_rectangle([0, 0, base - 1, int(base * 0.5)], radius=int(base * 0.22),
|
||||
fill=(30, 96, 175, 255))
|
||||
d.rounded_rectangle([0, int(base * 0.3), base - 1, base - 1], radius=int(base * 0.22),
|
||||
fill=BLUE + (255,))
|
||||
m = int(base * 0.62)
|
||||
mono = recolour(render_svg("ge-monogram.svg", m, m), WHITE)
|
||||
img.paste(mono, ((base - m) // 2, (base - m) // 2), mono)
|
||||
img.save(path, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64),
|
||||
(128, 128), (256, 256)])
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
made = []
|
||||
for w, h, name in [(164, 314, "wizard-image.bmp"),
|
||||
(192, 386, "wizard-image@125.bmp"),
|
||||
(384, 772, "wizard-image@250.bmp")]:
|
||||
banner(w, h).save(OUT / name, "BMP"); made.append((name, f"{w}x{h}"))
|
||||
for s, name in [(55, "wizard-small.bmp"), (64, "wizard-small@125.bmp"),
|
||||
(138, "wizard-small@250.bmp")]:
|
||||
small(s).save(OUT / name, "BMP"); made.append((name, f"{s}x{s}"))
|
||||
icon(OUT / "shopdb.ico"); made.append(("shopdb.ico", "multi-size"))
|
||||
for name, dims in made:
|
||||
print(f" {name:<26} {dims:<10} {(OUT / name).stat().st_size // 1024} KB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
110
deploy/windows/installer/refresh-bundle-lock.ps1
Normal file
@@ -0,0 +1,110 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Regenerate bundle-lock.json from the staged bundle, after showing what would
|
||||
change.
|
||||
|
||||
.DESCRIPTION
|
||||
Run this on the machine that assembled the wheelhouse, once the bundle holds
|
||||
the payload you intend to ship:
|
||||
|
||||
bundle\wheels\ the wheels, built with the matching Python
|
||||
bundle\python\ the Python installer
|
||||
bundle\httpplatformhandler\ the IIS module MSI
|
||||
bundle\urlrewrite\ URL Rewrite MSI (optional)
|
||||
bundle\mysql\ MySQL MSI (optional)
|
||||
|
||||
Then COMMIT the resulting bundle-lock.json. That commit is the review: it is
|
||||
the only place a change to what runs as SYSTEM on a customer's server becomes
|
||||
visible to a human. A lock regenerated and committed without reading the diff
|
||||
provides nothing, so this refuses to overwrite an existing lock until you
|
||||
have seen the change and passed -Yes.
|
||||
|
||||
.EXAMPLE
|
||||
.\refresh-bundle-lock.ps1 # show the diff, write nothing
|
||||
.\refresh-bundle-lock.ps1 -Yes # write it
|
||||
|
||||
.NOTES
|
||||
Building the wheelhouse itself no longer requires Windows. From any machine:
|
||||
pip download -r requirements.txt -d wheels --only-binary=:all: `
|
||||
--platform win_amd64 --python-version 314 --implementation cp --abi cp314
|
||||
Do it wherever you like; this script records what came out.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $BundleRoot = (Join-Path $PSScriptRoot 'bundle'),
|
||||
[string] $LockPath = (Join-Path $PSScriptRoot 'bundle-lock.json'),
|
||||
[string] $PythonTag = 'cp314',
|
||||
[string] $Platform = 'win_amd64',
|
||||
[switch] $Yes
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'bundle-lock.ps1')
|
||||
|
||||
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
||||
|
||||
if (-not (Test-Path $BundleRoot)) {
|
||||
Say "bundle not found: $BundleRoot" 'Red'
|
||||
Say 'Stage it first with build-installer.ps1 (or build-installer.sh), then add' 'Yellow'
|
||||
Say 'the wheels and installers by hand.' 'Yellow'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Say ''
|
||||
Say " Hashing $BundleRoot" 'Cyan'
|
||||
$new = New-BundleLock -BundleRoot $BundleRoot -PythonTag $PythonTag -Platform $Platform
|
||||
|
||||
foreach ($name in $new.payloads.Keys) {
|
||||
Say (" {0,-22} {1,4} files" -f $name, $new.payloads[$name].files.Count)
|
||||
}
|
||||
|
||||
$old = Read-BundleLock $LockPath
|
||||
if (-not $old) {
|
||||
Say ''
|
||||
Say ' No existing lock - this will be the first one.' 'Yellow'
|
||||
} else {
|
||||
# Diff by file, per payload, so the operator sees exactly which artifacts
|
||||
# changed rather than "the lock is different".
|
||||
Say ''
|
||||
Say ' Changes against the committed lock:' 'Cyan'
|
||||
$changes = 0
|
||||
foreach ($name in $new.payloads.Keys) {
|
||||
$oldFiles = @{}
|
||||
if ($old.payloads.PSObject.Properties.Name -contains $name) {
|
||||
foreach ($p in $old.payloads.$name.files.PSObject.Properties) { $oldFiles[$p.Name] = $p.Value.sha256 }
|
||||
}
|
||||
$newFiles = $new.payloads[$name].files
|
||||
foreach ($rel in ($newFiles.Keys | Sort-Object)) {
|
||||
if (-not $oldFiles.ContainsKey($rel)) { Say " + $name/$rel" 'Green'; $changes++ }
|
||||
elseif ($oldFiles[$rel] -ne $newFiles[$rel].sha256) { Say " ~ $name/$rel (content changed)" 'Yellow'; $changes++ }
|
||||
}
|
||||
foreach ($rel in ($oldFiles.Keys | Sort-Object)) {
|
||||
if (-not $newFiles.ContainsKey($rel)) { Say " - $name/$rel" 'Red'; $changes++ }
|
||||
}
|
||||
}
|
||||
foreach ($p in $old.payloads.PSObject.Properties.Name) {
|
||||
if (-not $new.payloads.Contains($p)) { Say " - $p/ (whole payload gone)" 'Red'; $changes++ }
|
||||
}
|
||||
if ($changes -eq 0) {
|
||||
Say ' none - the bundle already matches the lock' 'Green'
|
||||
exit 0
|
||||
}
|
||||
Say ''
|
||||
Say (" {0} change(s)." -f $changes) 'White'
|
||||
}
|
||||
|
||||
if (-not $Yes) {
|
||||
Say ''
|
||||
Say ' Nothing written. Read the list above, then re-run with -Yes.' 'Yellow'
|
||||
Say ' Commit the resulting bundle-lock.json - that commit IS the review.' 'Yellow'
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ConvertTo-Json defaults to a depth of 2, which silently flattens the per-file
|
||||
# entries into "System.Collections.Hashtable" strings and produces a lock that
|
||||
# verifies against nothing.
|
||||
$new | ConvertTo-Json -Depth 8 | Set-Content -Path $LockPath -Encoding UTF8
|
||||
Say ''
|
||||
Say " Written: $LockPath" 'Green'
|
||||
Say ' Commit it.' 'Green'
|
||||
816
deploy/windows/installer/shopdb-admin.ps1
Normal file
@@ -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 /<alias>, 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 <name>' '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 <plugin-name>' '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 <name>' '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 }
|
||||
}
|
||||
2104
deploy/windows/installer/shopdb-install.ps1
Normal file
547
deploy/windows/installer/shopdb-preflight.ps1
Normal file
@@ -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 ('<section name="' + $name + '"') |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $line) {
|
||||
# httpPlatform is registered by the HttpPlatformHandler MSI. Before
|
||||
# that, `appcmd unlock config /section:system.webServer/httpPlatform`
|
||||
# fails with "Unknown config section".
|
||||
Add-Result 'IIS' "Section $name" 'SKIP' 'section not registered yet' `
|
||||
'Install HttpPlatformHandler FIRST; only then can this section be unlocked.'
|
||||
} elseif ($line.Line -match 'overrideModeDefault="Deny"') {
|
||||
Add-Result 'IIS' "Section $name" 'WARN' 'locked (overrideModeDefault="Deny")' `
|
||||
"Installer must run: appcmd unlock config /section:system.webServer/$name (else IIS 500.19)"
|
||||
} else {
|
||||
Add-Result 'IIS' "Section $name" 'PASS' 'not locked'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'URL Rewrite module' {
|
||||
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
|
||||
if (-not (Test-Path $appcmd)) {
|
||||
Add-Result 'IIS' 'URL Rewrite module' 'SKIP' 'IIS not installed; cannot check'
|
||||
return
|
||||
}
|
||||
$modules = & $appcmd list module 2>$null
|
||||
if ($modules -match 'RewriteModule') {
|
||||
Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (the X-Forwarded-For rule can be enabled)'
|
||||
} else {
|
||||
# Not a FAIL: it is only needed for -ClientIpSource direct, and the
|
||||
# installer carries the MSI and installs it itself. Worth reporting
|
||||
# because without the rule IIS sends no X-Forwarded-For at all, so every
|
||||
# client reads as 127.0.0.1 and the GE-Enforce IP allowlist, the
|
||||
# visitor-location lookup and per-host login rate limiting go quiet.
|
||||
Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' `
|
||||
'The installer installs it from the bundle when -ClientIpSource is direct. Behind a reverse proxy that already sets X-Forwarded-For, use -ClientIpSource proxy and leave it out.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Check 'IIS' 'Existing sites' {
|
||||
if (-not $script:IisPresent) {
|
||||
Add-Result 'IIS' 'Existing sites' 'SKIP' 'IIS not installed'
|
||||
return
|
||||
}
|
||||
try {
|
||||
Import-Module WebAdministration -ErrorAction Stop
|
||||
$sites = @(Get-Website)
|
||||
if ($sites.Count -eq 0) { Add-Result 'IIS' 'Existing sites' 'INFO' 'none' ; return }
|
||||
$desc = ($sites | ForEach-Object {
|
||||
$b = ($_.bindings.Collection | ForEach-Object { $_.bindingInformation }) -join ','
|
||||
"$($_.Name) [$($_.State)] $b"
|
||||
}) -join '; '
|
||||
Add-Result 'IIS' 'Existing sites' 'INFO' $desc
|
||||
# Adoption sites typically run the classic ASP shopdb here already.
|
||||
if ($desc -match '8080') {
|
||||
Add-Result 'IIS' 'Classic ASP site' 'INFO' 'a site is bound on 8080 (likely the classic ASP shopdb)' `
|
||||
'Install ShopDB as a separate site on its own port; do not disturb this one.'
|
||||
}
|
||||
} catch {
|
||||
Add-Result 'IIS' 'Existing sites' 'WARN' "could not enumerate: $($_.Exception.Message)" ''
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 4. MySQL (detect BEFORE offering bundled vs existing)
|
||||
# =============================================================================
|
||||
|
||||
Invoke-Check 'MySQL' 'Service' {
|
||||
$svcs = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^MySQL' -or $_.DisplayName -match 'MySQL' })
|
||||
if ($svcs.Count -eq 0) {
|
||||
Add-Result 'MySQL' 'Service' 'INFO' 'no MySQL service found' `
|
||||
'Bundled MySQL 8.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 }
|
||||
BIN
deploy/windows/installer/shopdb.ico
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
deploy/windows/installer/swatch-bad.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
deploy/windows/installer/swatch-ok.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
deploy/windows/installer/swatch-warn.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
170
deploy/windows/installer/verify_bundle_lock.py
Normal file
@@ -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 <bundle-root> <bundle-lock.json>
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Must match $script:BundlePayloads in bundle-lock.ps1.
|
||||
PAYLOADS = [
|
||||
('wheels', True, 'Python wheels for the offline install'),
|
||||
('python', True, 'the Python installer'),
|
||||
('httpplatformhandler', True, 'the IIS module that launches waitress'),
|
||||
('urlrewrite', False, 'IIS URL Rewrite, for the client-IP rule'),
|
||||
('mysqlclient', False, 'mysql/mysqldump, for backups against a remote database'),
|
||||
('mysql', False, 'MySQL, for the bundled-database option'),
|
||||
]
|
||||
|
||||
|
||||
def digest(path):
|
||||
sha = hashlib.sha256()
|
||||
with open(path, 'rb') as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b''):
|
||||
sha.update(chunk)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def payload_files(directory):
|
||||
"""Every file under the directory, keyed by forward-slashed relative path."""
|
||||
found = {}
|
||||
if not os.path.isdir(directory):
|
||||
return found
|
||||
for root, _dirs, files in os.walk(directory):
|
||||
for name in files:
|
||||
full = os.path.join(root, name)
|
||||
rel = os.path.relpath(full, directory).replace(os.sep, '/')
|
||||
found[rel] = {'sha256': digest(full), 'size': os.path.getsize(full)}
|
||||
return found
|
||||
|
||||
|
||||
def normalize(name):
|
||||
"""PEP 427 wheel filename form: runs of non-alphanumerics become one _."""
|
||||
return re.sub(r'[^A-Za-z0-9.]+', '_', name).lower()
|
||||
|
||||
|
||||
def requirement_pins(requirements_path):
|
||||
"""Every 'name==version' pinned in a lockfile, including marked-out ones.
|
||||
|
||||
Markers are deliberately IGNORED. A requirement guarded by
|
||||
sys_platform == 'win32' is exactly the case that must be present, because the
|
||||
target is Windows and the wheelhouse is usually assembled somewhere else.
|
||||
"""
|
||||
pins = {}
|
||||
with open(requirements_path) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line)
|
||||
if match:
|
||||
pins[normalize(match.group(1))] = match.group(2)
|
||||
return pins
|
||||
|
||||
|
||||
def check_wheelhouse_covers_requirements(bundle_root):
|
||||
"""The lock records what IS in the wheelhouse, not what the app NEEDS.
|
||||
|
||||
Without this, an incomplete wheelhouse gets locked and blessed, and the
|
||||
install fails on an air-gapped server. That is not hypothetical: assembling
|
||||
the wheelhouse on Linux silently omits colorama, a win32-only dependency of
|
||||
click, because pip evaluates environment markers against the machine doing
|
||||
the downloading rather than the machine being targeted.
|
||||
"""
|
||||
wheels = os.path.join(bundle_root, 'wheels')
|
||||
requirements = os.path.join(bundle_root, 'app', 'requirements.txt')
|
||||
if not os.path.isdir(wheels) or not os.path.exists(requirements):
|
||||
return []
|
||||
have = os.listdir(wheels)
|
||||
problems = []
|
||||
for name, version in sorted(requirement_pins(requirements).items()):
|
||||
prefix = '%s-%s-' % (name, version)
|
||||
if not any(f.lower().startswith(prefix) for f in have):
|
||||
problems.append(
|
||||
'wheels/ has no wheel for %s==%s, which requirements.txt pins '
|
||||
'(a marked-out dependency still installs on Windows)' % (name, version))
|
||||
return problems
|
||||
|
||||
|
||||
def verify(bundle_root, lock):
|
||||
problems = []
|
||||
locked = lock.get('payloads')
|
||||
if not locked:
|
||||
return ['bundle-lock.json has no "payloads" section']
|
||||
|
||||
for name, required, what in PAYLOADS:
|
||||
directory = os.path.join(bundle_root, name)
|
||||
present = os.path.isdir(directory)
|
||||
if name not in locked:
|
||||
if present:
|
||||
problems.append(
|
||||
'%s/ is present but is not in bundle-lock.json - regenerate the lock' % name)
|
||||
elif required:
|
||||
problems.append(
|
||||
'%s/ is required but is in neither the bundle nor the lock' % name)
|
||||
continue
|
||||
if not present:
|
||||
if required or locked[name].get('required'):
|
||||
problems.append('%s/ is in the lock but missing from the bundle (%s)' % (name, what))
|
||||
continue
|
||||
|
||||
expected = locked[name].get('files', {})
|
||||
actual = payload_files(directory)
|
||||
for rel, want in sorted(expected.items()):
|
||||
got = actual.get(rel)
|
||||
if got is None:
|
||||
problems.append('%s/%s is in the lock but missing from the bundle' % (name, rel))
|
||||
elif got['sha256'] != want['sha256']:
|
||||
problems.append(
|
||||
'%s/%s does NOT match the lock (expected sha256 %s..., got %s...)'
|
||||
% (name, rel, want['sha256'][:12], got['sha256'][:12]))
|
||||
elif int(got['size']) != int(want['size']):
|
||||
# Impossible for a matching sha256, so the lock was hand-edited.
|
||||
problems.append(
|
||||
'%s/%s size disagrees with the lock - the lock has been edited by hand'
|
||||
% (name, rel))
|
||||
for rel in sorted(actual):
|
||||
if rel not in expected:
|
||||
problems.append(
|
||||
'%s/%s is in the bundle but NOT in the lock (unexpected extra file)'
|
||||
% (name, rel))
|
||||
problems.extend(check_wheelhouse_covers_requirements(bundle_root))
|
||||
return problems
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit('usage: verify_bundle_lock.py <bundle-root> <bundle-lock.json>')
|
||||
bundle_root, lock_path = sys.argv[1], sys.argv[2]
|
||||
if not os.path.exists(lock_path):
|
||||
print('no bundle-lock.json at %s' % lock_path)
|
||||
return 1
|
||||
with open(lock_path) as fh:
|
||||
lock = json.load(fh)
|
||||
problems = verify(bundle_root, lock)
|
||||
for problem in problems:
|
||||
print(problem)
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
BIN
deploy/windows/installer/wizard-image.bmp
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
deploy/windows/installer/wizard-image@125.bmp
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
deploy/windows/installer/wizard-image@250.bmp
Normal file
|
After Width: | Height: | Size: 869 KiB |
BIN
deploy/windows/installer/wizard-small.bmp
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
deploy/windows/installer/wizard-small@125.bmp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
deploy/windows/installer/wizard-small@250.bmp
Normal file
|
After Width: | Height: | Size: 56 KiB |
@@ -11,7 +11,7 @@
|
||||
- HttpPlatformHandler IIS module installed
|
||||
(https://www.iis.net/downloads/microsoft/httpplatformhandler)
|
||||
- URL Rewrite module installed (only for the optional X-Forwarded-For rule)
|
||||
- Python 3.12 + a venv at APP_ROOT\venv with requirements.txt + waitress
|
||||
- Python 3.14 + a venv at APP_ROOT\venv with requirements.txt + waitress
|
||||
- Secrets live in APP_ROOT\.env (wsgi.py load_dotenv() reads it). Keep them
|
||||
OUT of this file. Lock .env ACLs to the app pool identity + admins.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<httpPlatform
|
||||
processPath="C:\shopdb-flask\venv\Scripts\waitress-serve.exe"
|
||||
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for wsgi:app"
|
||||
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for --trusted-proxy-count=1 wsgi:app"
|
||||
stdoutLogEnabled="true"
|
||||
stdoutLogFile="C:\shopdb-flask\logs\httpplatform"
|
||||
startupTimeLimit="120"
|
||||
@@ -49,16 +49,29 @@
|
||||
</httpPlatform>
|
||||
|
||||
<!--
|
||||
OPTIONAL: forward the real client IP so audit logs and the kiosk
|
||||
visitor-location feature (IP -> business unit) see the caller, not the
|
||||
loopback that HttpPlatformHandler connects from.
|
||||
Forward the real client IP, so the audit log, the kiosk visitor-location
|
||||
feature (IP -> business unit), the GE-Enforce IP allowlist and per-host
|
||||
login rate limiting all see the caller rather than the loopback address
|
||||
HttpPlatformHandler connects from.
|
||||
|
||||
This block is COMMENTED OUT by default because it needs the URL Rewrite
|
||||
module; with it uncommented but URL Rewrite not installed, IIS returns
|
||||
HTTP 500.19 ("configuration section not well-formed / cannot be read").
|
||||
Install URL Rewrite (https://www.iis.net/downloads/microsoft/url-rewrite)
|
||||
and then uncomment the <rewrite> block below to enable it.
|
||||
IIS does not set X-Forwarded-For on its own. Without the rule below there
|
||||
is no such header at all, and every client looks like 127.0.0.1 - so the
|
||||
allowlist and the visitor-location lookup silently stop working.
|
||||
|
||||
ONLY CORRECT WHEN IIS IS DIRECTLY EXPOSED. It overwrites the header with
|
||||
REMOTE_ADDR, which is what stops a client spoofing its own X-Forwarded-For.
|
||||
Behind a reverse proxy (ARR, a load balancer) REMOTE_ADDR is the PROXY, so
|
||||
this rule would destroy the real client IP - there, leave it disabled and
|
||||
let the proxy set the header.
|
||||
|
||||
It ships DISABLED because it needs the URL Rewrite module; enabled without
|
||||
it, IIS returns HTTP 500.19 ("configuration section not well-formed").
|
||||
|
||||
The installer handles both: -ClientIpSource direct installs URL Rewrite
|
||||
from the bundle and enables this; -ClientIpSource proxy leaves it alone.
|
||||
By hand: install URL Rewrite, then delete the two marker lines below.
|
||||
-->
|
||||
<!-- SHOPDB-CLIENTIP-BEGIN
|
||||
<rewrite>
|
||||
<allowedServerVariables>
|
||||
<add name="HTTP_X_FORWARDED_FOR" />
|
||||
@@ -73,7 +86,7 @@
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
-->
|
||||
SHOPDB-CLIENTIP-END -->
|
||||
|
||||
</system.webServer>
|
||||
|
||||
|
||||