Files
shopdb-flask/deploy/windows/installer/shopdb-install.ps1
cproudlock e58f376643 fix(installer): the adopt-existing guard never fired
Stage 4 decided whether the IIS objects it was about to reconcile were its own by
testing for .installed-version. Stage 2 writes that file, and stage 2 always runs
first in a '-Stage all' install - so by the time the guard looked, the stamp it
had just written made every server look like one this installer built, including
the hand-built ones the guard exists to protect.

Stage 2 now records whether a stamp was present BEFORE it writes its own, and
stage 4 reads that observation. Running stage 4 alone still tests the file, which
is correct there: no stage 2 has run to disturb it.

Found by review, not by test - the guard has no coverage, because exercising it
needs a live IIS.
2026-08-03 13:47:07 -04:00

1893 lines
97 KiB
PowerShell

<#
.SYNOPSIS
ShopDB-Flask offline installer for Windows (IIS + HttpPlatformHandler + waitress).
.DESCRIPTION
Automates docs/INSTALL-WINDOWS-IIS.md. Fully offline: every prerequisite comes
from the bundle directory. Never contacts the network.
Stages (each can be run alone with -Stage):
1 preflight - read-only discovery (shopdb-preflight.ps1)
2 runtime - Python, venv, wheels, .env
3 data - schema, seeds, plugins
4 iis - app pool, ACLs, site, firewall
5 verify - smoke test + handoff
all - 2,3,4,5 in order (default)
Written for stock Windows PowerShell 5.1. No pwsh-only syntax, no modules,
no network.
.PARAMETER BundleRoot
Directory holding the offline bundle. Expected layout:
python\python-3.14.x-amd64.exe
wheels\*.whl
app\ (release tree: wsgi.py, shopdb\, plugins\, frontend\dist\, ...)
httpplatformhandler\httpPlatformHandler_amd64.msi
urlrewrite\ (optional, -ClientIpSource direct)
mysql\ (optional, bundled-database option)
bundle-lock.json + bundle-lock.ps1
The payload is checked against bundle-lock.json before anything runs. A
bundle whose wheels or MSIs do not match the lock exactly - missing, extra or
altered - is refused, because every one of them executes as SYSTEM here.
.PARAMETER DbHost
Existing-MySQL option: the server hostname. The PASSWORD IS NEVER A PARAMETER -
it is prompted for as a SecureString, because Windows command lines are
readable by any user (Win32_Process) and are captured in PowerShell
transcripts and history. If a valid .env already exists it is reused and
nothing is prompted.
.EXAMPLE
.\shopdb-install.ps1 -BundleRoot D:\shopdb-bundle -SiteHost shopdb.plant.local
.\shopdb-install.ps1 -Stage 4 -BundleRoot D:\shopdb-bundle
.NOTES
Idempotent. Re-running preserves an existing .env (regenerating JWT_SECRET_KEY
would invalidate every issued session).
#>
[CmdletBinding()]
param(
# Stage 0 installs the bundled MySQL 8.0 (greenfield sites only). It is NOT in
# 'all': a site with an existing MySQL must never have a second server dropped
# on top of it, and that is the common case (docs/DEPLOY-WINDOWS-IIS.md: the
# target box already runs the classic ASP shopdb against MySQL 5.6). Run it
# explicitly, and only when preflight reports no MySQL on 3306.
[ValidateSet('0','1','2','3','4','5','all','uninstall')] [string] $Stage = 'all',
# Bundled-MySQL (stage 0) settings. The root and app passwords are GENERATED,
# never supplied: see New-Secret. Root is shown once and not persisted.
[string] $MysqlRoot = 'C:\Program Files\MySQL\MySQL Server 8.0',
[string] $MysqlDataDir = 'C:\ProgramData\MySQL\data',
[string] $MysqlIni = 'C:\ProgramData\MySQL\my.ini',
[string] $MysqlService = 'MySQL80',
[Parameter(Mandatory=$true)] [string] $BundleRoot,
[string] $AppRoot = 'C:\shopdb-flask',
[int] $SitePort = 8090,
[string] $SiteName = 'shopdb-flask',
[string] $AppPool = 'shopdbflask',
[string] $SiteHost = '', # for CORS_ORIGINS; defaults to this machine
# Deliberately NOT a password parameter. Command lines are readable by any
# user via Win32_Process and are captured in PowerShell transcripts.
# Supply host/user/db here; the password is prompted for.
[string] $DbHost = '',
[int] $DbPort = 3306,
[string] $DbName = 'shopdb_flask',
[string] $DbUser = 'shopdb',
# Unattended installs only. Path to a file whose FIRST LINE is the DB password.
# The installer reads it, overwrites it and deletes it. ACL the file to
# SYSTEM + Administrators before writing it. Omit this for an interactive run
# and the password is prompted for instead. It is never a parameter value:
# command lines are world-readable via Win32_Process and are captured in
# PowerShell transcripts and ConsoleHost_history.txt.
[string] $DbPasswordFile = '',
# The site's plugin set is DECLARED in a profile (ADR-013), not listed here.
# The bundle is built lean for that same profile by scripts/build-site.sh, so
# a plugin the site did not choose is absent from the backend tree entirely.
# That absence is what makes core's `try: from plugins.X.models import ...
# except ImportError` guards correct: the import genuinely fails, no
# relationship backref is created, and no query joins the missing table.
#
# Do NOT go back to installing a fixed list against a full tree. That was the
# cause of the 500s on /api/assets and /api/dashboard/summary
# ("Table 'shopdb_flask.measuringtools' doesn't exist"): the plugin's CODE
# shipped regardless, so the import succeeded, SQLAlchemy created the
# Asset.measuringtool backref and LEFT OUTER JOINed a table that only exists
# if the plugin was installed.
[string] $SiteProfile = '',
# Comma-separated plugin names chosen at INSTALL time (the wizard's plugin
# page). When supplied this REWRITES site-profile.json before apply-profile
# runs, so the operator's choice wins over whatever the bundle was built with.
# Omit it and the profile shipped in the bundle is used unchanged.
[string] $SitePlugins = '',
# Subpath deployment (docs/INSTALL-WINDOWS-IIS.md method B). Empty = the app
# gets its own IIS site on -SitePort (method A, the default). Set to an alias
# such as 'shopdb' and it becomes an IIS Application under -ParentSite, i.e.
# http://<server-fqdn>/shopdb/ - no new DNS record, no port in the URL.
# The alias MUST match the one the bundle's subpath SPA was built with; the
# installer checks that and refuses if they disagree.
[string] $MountAlias = '',
[string] $ParentSite = 'Default Web Site',
# How this server learns a request's real client IP.
#
# direct IIS is exposed to clients. Install URL Rewrite from the bundle
# and set X-Forwarded-For from REMOTE_ADDR. Overwriting the header
# is what stops a client spoofing it.
# proxy A reverse proxy (ARR, load balancer) sits in front and already
# sets X-Forwarded-For. Leave the rule off - REMOTE_ADDR would be
# the proxy, so applying it would DESTROY the real client IP.
#
# Without one or the other, IIS sends no X-Forwarded-For at all and every
# client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard
# visitor-location lookup and per-host login rate limiting all break quietly.
[ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct',
# Required before this installer will alter an installation it did not
# create. Everything here is built for a greenfield server: it makes its own
# Python, its own venv and its own IIS objects, and its upgrade path assumes
# the thing it is upgrading came out of a previous run.
#
# Pointed at a server that was deployed by hand, the IIS reconciliation would
# DELETE the existing application or site as part of switching deployment
# method - silently, because removing the artifact of the method you are not
# using is correct behaviour when the installer owns both. It is not correct
# when someone else built it. So it asks first.
[switch] $AdoptExisting,
[switch] $WhatIfOnly,
# Unattended runs cannot answer a prompt. Choose the failure behaviour up front.
[ValidateSet('ask','always','never')] [string] $OnFailure = 'ask'
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
# NOT $env:TEMP. When this runs under the Inno wrapper, TEMP points at Setup's
# own extraction directory, which is deleted when Setup exits - so the log the
# operator is told to send to support disappears with it, and a failed install
# leaves nothing to diagnose. ProgramData persists and is admin-writable.
$script:LogDir = Join-Path $env:ProgramData 'ShopDB-Flask\logs'
try { if (-not (Test-Path $script:LogDir)) { New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null } }
catch { $script:LogDir = $env:TEMP }
$script:LogPath = Join-Path $script:LogDir ("shopdb-install-{0}.log" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
$script:Created = New-Object System.Collections.ArrayList # for rollback
# Was this server already stamped by a previous run of THIS installer, as observed
# BEFORE stage 2 writes its own stamp? Stage 4 needs the answer to know whether the
# IIS objects it is about to reconcile are its own. $null means nothing has looked
# yet, which is the case when stage 4 is run on its own.
$script:PreexistingInstallStamp = $null
function Write-Log {
param([string] $Message, [string] $Level = 'INFO')
$line = "{0} [{1}] {2}" -f (Get-Date -Format 'HH:mm:ss'), $Level, $Message
Add-Content -Path $script:LogPath -Value $line
$colour = 'Gray'
if ($Level -eq 'OK') { $colour = 'Green' }
if ($Level -eq 'WARN') { $colour = 'Yellow' }
if ($Level -eq 'FAIL') { $colour = 'Red' }
if ($Level -eq 'STEP') { $colour = 'Cyan' }
Write-Host $line -ForegroundColor $colour
}
function Fail {
param([string] $Message, [string] $Fix = '')
Write-Log $Message 'FAIL'
if ($Fix) { Write-Log " fix: $Fix" 'FAIL' }
Write-Log "log: $script:LogPath"
throw $Message
}
function Track { param([string] $Kind, [string] $Id) $null = $script:Created.Add(@{Kind=$Kind; Id=$Id}) }
function Invoke-Native {
# Runs an external command and fails loudly.
#
# Three PowerShell 5.1 traps are handled here, each found the hard way on a
# real Server 2025 box:
# 1. `& $exe @array` can collapse the array into ONE argument. Start-Process
# -ArgumentList binds arrays correctly.
# 2. Native tools write progress to stderr on SUCCESS, and
# $ErrorActionPreference='Stop' turns that into a terminating error.
# 3. Argument VALUES must never be logged: a DATABASE_URL or password would
# land in the log we invite operators to send to support.
param([string] $Exe, [string[]] $Arguments, [string] $What, [string] $StdinFile = '',
[int] $TimeoutSec = 0)
Write-Log (" exec {0} ({1} args)" -f (Split-Path $Exe -Leaf), $Arguments.Count)
# Start-Process -ArgumentList joins the array with spaces WITHOUT quoting, so
# any argument containing whitespace gets split into several. That is how
# "IIS AppPool\name:(OI)(CI)RX" became the parameter "IIS" (icacls exit 87),
# and how TargetDir=C:\Program Files\... installed Python into C:\Program\.
$quoted = $Arguments | ForEach-Object {
if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
}
$so = [System.IO.Path]::GetTempFileName()
$se = [System.IO.Path]::GetTempFileName()
try {
if ($StdinFile) {
# Feeding SQL as a file on stdin, not as -e "source <path>": the mysql
# client reads that path as a DATABASE NAME and fails with
# ERROR 1049 Unknown database '<path>'.
$p = Start-Process -FilePath $Exe -ArgumentList $quoted -Wait -PassThru `
-NoNewWindow -RedirectStandardInput $StdinFile `
-RedirectStandardOutput $so -RedirectStandardError $se
} else {
$p = Start-Process -FilePath $Exe -ArgumentList $quoted -Wait -PassThru `
-NoNewWindow -RedirectStandardOutput $so -RedirectStandardError $se
}
# A hung child must not hang the install. msiexec in particular deadlocks
# when a second Windows Installer transaction holds the global mutex: two
# msiexec processes sat there for 40 minutes with no output and no error,
# and -Wait alone gives you no way out.
if ($TimeoutSec -gt 0 -and -not $p.HasExited) {
if (-not $p.WaitForExit($TimeoutSec * 1000)) {
try { $p.Kill() } catch { }
Fail "$What timed out after $TimeoutSec seconds" @'
The command was killed. For an MSI this usually means another Windows Installer
transaction held the global mutex: check for stray msiexec processes
(Get-Process msiexec), end them, then re-run this stage.
'@
}
}
$out = @()
if (Test-Path $so) { $out += Get-Content $so -ErrorAction SilentlyContinue }
if (Test-Path $se) { $out += Get-Content $se -ErrorAction SilentlyContinue }
if ($p.ExitCode -ne 0) {
$out | Select-Object -Last 8 | ForEach-Object { Write-Log " $_" 'FAIL' }
Fail "$What failed (exit $($p.ExitCode))"
}
return $out
}
finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue }
}
function Protect-File {
# Owner-only ACL: Administrators + SYSTEM, inheritance broken.
param([string] $Path)
Invoke-Native 'icacls.exe' @($Path,'/inheritance:r',
'/grant','BUILTIN\Administrators:(F)','/grant','NT AUTHORITY\SYSTEM:(F)') 'ACL'
}
function New-Secret {
param([int] $Bytes = 48)
$b = New-Object byte[] $Bytes
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b)
# URL-safe, no padding: avoids characters that need escaping in .env or URLs.
return [Convert]::ToBase64String($b).Replace('+','-').Replace('/','_').TrimEnd('=')
}
# Paths derived once.
$Py = Join-Path $AppRoot 'venv\Scripts\python.exe'
$Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
$Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe'
$EnvFile = Join-Path $AppRoot '.env'
$WheelDir = Join-Path $BundleRoot 'wheels'
$AppSource = Join-Path $BundleRoot 'app'
# =============================================================================
# Version awareness and database safety (upgrades)
# =============================================================================
# An upgrade is just this installer run against an existing install. That is
# only safe if three things are true, and none of them were before:
# - it knows whether it is going forwards or backwards;
# - the database is backed up BEFORE migrations touch it;
# - a failed migration puts the backup back rather than leaving a half-state.
function Assert-BundleIntegrity {
<#
Refuse to run against a payload that is not exactly what was built and
reviewed.
This runs BEFORE anything is installed, because everything downstream is an
executable that runs as SYSTEM on this server: the Python installer, the
HttpPlatformHandler and URL Rewrite MSIs, and ~40 wheels. requirements.txt
hashes cover the wheels once pip gets to them; nothing covered the rest,
and nothing noticed a stale extra wheel sitting in the wheelhouse.
There is deliberately no override. A bundle that fails here was altered
after it was built, and the fix is to get a correct bundle rather than to
wave this one through on a server nobody can see.
#>
$checker = Join-Path $BundleRoot 'bundle-lock.ps1'
$lockFile = Join-Path $BundleRoot 'bundle-lock.json'
if (-not (Test-Path $checker)) {
Fail 'bundle-lock.ps1 is missing from the bundle' `
'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.'
}
if (-not (Test-Path $lockFile)) {
Fail 'bundle-lock.json is missing from the bundle' `
'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.'
}
. $checker
$lock = Read-BundleLock $lockFile
$problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock
if ($problems.Count -gt 0) {
foreach ($p in $problems) { Write-Log " $p" 'FAIL' }
Fail ("the bundle does not match bundle-lock.json ({0} problem(s))" -f $problems.Count) @'
The payload was changed after this installer was built. Do not install from it.
Obtain a bundle whose payload matches its lock, or rebuild one and re-compile.
'@
}
Write-Log ("bundle payload verified against bundle-lock.json ({0}, {1})" -f `
(Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'OK'
}
function Get-WheelhousePythonTag {
# Which Python the wheelhouse was built for. The lock is authoritative; a
# bundle predating it is read from the wheel filenames instead.
$lockFile = Join-Path $BundleRoot 'bundle-lock.json'
if (Test-Path $lockFile) {
$tag = Get-JsonProperty (Get-Content $lockFile -Raw | ConvertFrom-Json) 'pythontag'
if ($tag -and ($tag -match '^cp(\d)(\d+)$')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) }
}
$wheel = Get-ChildItem $WheelDir -Filter '*.whl' -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '-cp(\d)(\d+)-' } | Select-Object -First 1
if ($wheel -and ($wheel.Name -match '-cp(\d)(\d+)-')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) }
return ''
}
function Assert-VenvMatchesWheelhouse {
<#
An existing venv is REUSED rather than rebuilt, which is right for a repair
or an upgrade of an install this made. It is wrong when the venv belongs to
a different Python: the wheelhouse is tagged for one minor version, so pip
finds no candidate for cffi, cryptography, greenlet or mysql-connector and
dies partway through - after Python has already been installed and the app
tree already replaced.
Fail before any of that, and say which two versions disagree.
#>
if (-not (Test-Path $Py)) { return } # greenfield: nothing to disagree with
$wanted = Get-WheelhousePythonTag
if (-not $wanted) { return } # unknowable; the pip failure will have to do
$found = ''
try {
$found = (& $Py -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>$null | Select-Object -First 1)
if ($found) { $found = $found.Trim() }
} catch { return }
if (-not $found -or ($found -eq $wanted)) { return }
Fail ("the existing venv is Python {0}, but this bundle's wheelhouse is for {1}" -f $found, $wanted) @'
The wheelhouse is locked to one Python minor version. Installing it into a venv
built by a different one fails at the first compiled package, halfway through.
On a server this installer built: delete APP_ROOT\venv and re-run. The venv is
rebuilt from the bundle and holds nothing of yours.
On a server someone else built: this is not an upgrade, it is a runtime change.
Take a database backup first, and expect to reconcile web.config by hand.
'@
}
function Get-BundleVersion {
# The version being installed, read from the payload itself so it can never
# disagree with the code that is about to be copied.
$init = Join-Path $AppSource 'shopdb\__init__.py'
if (-not (Test-Path $init)) { return '' }
$m = Select-String -Path $init -Pattern "^__version__\s*=\s*'([^']+)'" | Select-Object -First 1
if ($m) { return $m.Matches[0].Groups[1].Value }
return ''
}
function Get-InstalledVersion {
$f = Join-Path $AppRoot '.installed-version'
if (Test-Path $f) { return (Get-Content $f -TotalCount 1).Trim() }
# An install predating version stamping still has an app tree.
if (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py')) { return 'unknown' }
return ''
}
function Compare-Version {
# -1 / 0 / 1, comparing dotted numeric versions. 'unknown' sorts as older so
# a stamped bundle over an unstamped install is treated as an upgrade.
param([string] $A, [string] $B)
if ($A -eq $B) { return 0 }
if ($A -eq 'unknown' -or $A -eq '') { return -1 }
if ($B -eq 'unknown' -or $B -eq '') { return 1 }
$x = @($A -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') })
$y = @($B -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') })
for ($i = 0; $i -lt [Math]::Max($x.Count, $y.Count); $i++) {
$xi = if ($i -lt $x.Count) { $x[$i] } else { 0 }
$yi = if ($i -lt $y.Count) { $y[$i] } else { 0 }
if ($xi -gt $yi) { return 1 }
if ($xi -lt $yi) { return -1 }
}
return 0
}
function Find-MysqlTool {
param([string] $Name) # mysql.exe or mysqldump.exe
$roots = @('C:\Program Files\MySQL', 'C:\mysql56\bin', 'C:\Program Files (x86)\MySQL')
foreach ($r in $roots) {
if (Test-Path $r) {
$hit = Get-ChildItem $r -Filter $Name -Recurse -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($hit) { return $hit.FullName }
}
}
return ''
}
function Get-DbFromEnv {
$line = Get-Content $EnvFile -ErrorAction SilentlyContinue |
Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1
if ($line -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') {
return @{ User = $Matches[1]; Pass = [uri]::UnescapeDataString($Matches[2])
Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] }
}
return $null
}
function Test-DatabaseEmpty {
<#
Is the TARGET DATABASE empty right now?
This - not the presence of C:\shopdb-flask - is what decides whether it is
safe to force-drop tables. A rebuilt server pointed at a site's existing,
populated database has no app directory, so filesystem-based detection
called it a "fresh install" and ran prune-schema --yes --force, destroying
the rows of every unselected plugin with no backup taken.
Returns $true ONLY when we positively confirm zero tables. Anything we
cannot determine returns $false, because the failure mode of guessing
"empty" is irreversible data loss and the failure mode of guessing
"populated" is merely a redundant backup.
#>
$probe = Join-Path $env:TEMP ("shopdb-dbprobe-{0}.py" -f (Get-Date -Format 'HHmmssfff'))
@'
import os, sys
try:
from sqlalchemy import create_engine, inspect
url = None
for line in open(".env", encoding="utf-8", errors="replace"):
if line.startswith("DATABASE_URL="):
url = line.split("=", 1)[1].strip()
if not url:
print("UNKNOWN"); sys.exit(0)
print("EMPTY" if len(inspect(create_engine(url)).get_table_names()) == 0 else "POPULATED")
except Exception:
print("UNKNOWN")
'@ | Set-Content -Path $probe -Encoding ASCII
try {
Push-Location $AppRoot
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try { $out = & $Py $probe 2>&1 } finally { $ErrorActionPreference = $prev; Pop-Location }
$verdict = ($out | Where-Object { $_ -match '^(EMPTY|POPULATED|UNKNOWN)$' } | Select-Object -Last 1)
Write-Log "database probe: $verdict"
return ($verdict -eq 'EMPTY')
}
finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue }
}
function Backup-Database {
# Returns the backup path, or '' if it could not be taken.
param([string] $Reason = 'pre-upgrade')
$db = Get-DbFromEnv
if (-not $db) { Write-Log 'cannot read DATABASE_URL; skipping backup' 'WARN'; return '' }
$dump = Find-MysqlTool 'mysqldump.exe'
if (-not $dump) { Write-Log 'mysqldump not found; skipping backup' 'WARN'; return '' }
$dir = Join-Path $env:ProgramData 'ShopDB-Flask\backups'
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$file = Join-Path $dir ("{0}-{1}-{2}.sql" -f $db.Name, $Reason, (Get-Date -Format 'yyyyMMdd-HHmmss'))
Write-Log "backing up $($db.Name) before migrating"
# --single-transaction keeps it consistent without locking the whole server.
# Password goes in the argument array, never interpolated into a log line.
$args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)",
'--single-transaction', '--routines', '--triggers', $db.Name)
$so = [System.IO.Path]::GetTempFileName()
$se = [System.IO.Path]::GetTempFileName()
try {
# Start-Process -ArgumentList does NOT quote, so a password containing a
# space splits into several arguments and the dump fails. Quote anything
# with whitespace, exactly as Invoke-Native does.
$quoted = $args | ForEach-Object {
if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
}
Write-Log (" exec mysqldump.exe ({0} args)" -f $args.Count)
$p = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow `
-RedirectStandardOutput $so -RedirectStandardError $se
if ($p.ExitCode -ne 0) {
Write-Log "mysqldump exited $($p.ExitCode); no usable backup" 'WARN'
return ''
}
# VALIDATE before anything relies on this file. mysqldump can exit 0 and
# still leave a truncated dump - a full disk, or a table it could not
# read part-way through. A backup that is trusted but unusable is worse
# than no backup at all, because migrations then proceed on its strength.
if (-not (Test-Path $so)) { Write-Log 'mysqldump produced no file' 'WARN'; return '' }
$len = (Get-Item $so).Length
if ($len -lt 512) {
Write-Log "backup is only $len bytes; refusing to treat it as valid" 'WARN'; return ''
}
$tail = @(Get-Content $so -Tail 5 -ErrorAction SilentlyContinue)
if (-not ($tail -match 'Dump completed')) {
Write-Log 'backup lacks the mysqldump completion marker - it is truncated' 'WARN'
return ''
}
Move-Item $so $file -Force
$mb = [math]::Round((Get-Item $file).Length / 1MB, 1)
Write-Log "backup written and verified complete: $file ($mb MB)" 'OK'
return $file
}
finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue }
}
function Restore-Database {
param([string] $BackupFile)
if (-not $BackupFile -or -not (Test-Path $BackupFile)) {
Write-Log 'no backup available to restore from' 'FAIL'; return $false
}
$db = Get-DbFromEnv
$mysql = Find-MysqlTool 'mysql.exe'
if (-not $db -or -not $mysql) { Write-Log 'cannot restore: client or .env missing' 'FAIL'; return $false }
Write-Log "restoring $($db.Name) from $BackupFile" 'WARN'
$args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", $db.Name)
try {
# mysqldump emits DROP TABLE IF EXISTS before each CREATE, so replaying
# genuinely restores every table the dump contains.
Invoke-Native $mysql $args 'database restore' -StdinFile $BackupFile | Out-Null
} catch { Write-Log "restore failed: $($_.Exception.Message)" 'FAIL'; return $false }
# What the replay CANNOT undo: tables the failed migration created that the
# dump knows nothing about. MySQL DDL is not transactional, so those CREATEs
# are already committed and no dump-replay removes them.
#
# We report them rather than dropping them. Dropping would mean deciding, on
# a machine holding a site's only copy of its data, that a table is safe to
# destroy - and the installer deliberately does not claim that authority.
# (Restoring into a side database instead is not available either: the app
# user has rights on this database only, not CREATE DATABASE.)
$dumped = @()
foreach ($line in (Get-Content $BackupFile -ErrorAction SilentlyContinue)) {
if ($line -match '^CREATE TABLE `([^`]+)`') { $dumped += $Matches[1] }
}
$probe = Join-Path $env:TEMP ("shopdb-tables-{0}.py" -f (Get-Date -Format 'HHmmssfff'))
@'
import sys
try:
from sqlalchemy import create_engine, inspect
url = None
for line in open(".env", encoding="utf-8", errors="replace"):
if line.startswith("DATABASE_URL="):
url = line.split("=", 1)[1].strip()
for t in sorted(inspect(create_engine(url)).get_table_names()):
print("TABLE " + t)
except Exception:
pass
'@ | Set-Content -Path $probe -Encoding ASCII
$current = @()
try {
Push-Location $AppRoot
$prev = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
try { $current = @(& $Py $probe 2>&1 | Where-Object { $_ -match '^TABLE ' } |
ForEach-Object { $_.Substring(6).Trim() }) }
finally { $ErrorActionPreference = $prev; Pop-Location }
} finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue }
$residue = @($current | Where-Object { $dumped -notcontains $_ })
if ($residue.Count -eq 0) {
Write-Log 'data restored from backup; no leftover tables from the failed migration' 'OK'
} else {
# Say exactly what is true. The previous wording claimed the database was
# "restored to its previous state", which is a false assurance when the
# schema still carries changes the migration committed before it failed.
Write-Log 'data restored from backup, but the schema is NOT identical to before' 'WARN'
Write-Log (" {0} table(s) created by the failed migration remain:" -f $residue.Count) 'WARN'
$residue | ForEach-Object { Write-Log " $_" 'WARN' }
Write-Log ' they hold no data and are harmless to the running application,' 'WARN'
Write-Log ' but a DBA should drop them before the next upgrade attempt.' 'WARN'
}
Write-Log " backup used: $BackupFile"
return $true
}
# =============================================================================
# STAGE 0 - bundled MySQL 8.0 (greenfield sites only)
# =============================================================================
# The MSI installs BINARIES ONLY: no service, no data directory, no config. The
# full sequence is msiexec -> write my.ini -> --initialize-insecure -> --install
# -> Start-Service -> bootstrap SQL. Every step below has bitten us once.
function Invoke-Stage0 {
Write-Log 'STAGE 0: bundled MySQL 8.0' 'STEP'
$msi = Get-ChildItem (Join-Path $BundleRoot 'mysql') -Filter '*.msi' -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $msi) {
Fail "no MySQL MSI in $BundleRoot\mysql" @'
Stage 0 installs the BUNDLED database. Add mysql\mysql-8.0.x-winx64.msi to the
bundle, or skip stage 0 and use the site's existing server (the common case).
'@
}
# Refuse to stack a second server on an existing one. Two servers fighting
# over 3306, or a silent takeover of the classic ASP app's database, is far
# worse than stopping here.
$existing = Get-Service -Name 'MySQL*' -ErrorAction SilentlyContinue
if ($existing) {
Fail ("MySQL service already present: {0}" -f (($existing | ForEach-Object Name) -join ', ')) @'
This box already runs MySQL. Do NOT install the bundled server on top of it:
it would collide on port 3306 and could disrupt the existing application.
Skip stage 0 and point the installer at the existing server with -DbHost.
'@
}
$inUse = Get-NetTCPConnection -LocalPort $DbPort -State Listen -ErrorAction SilentlyContinue
if ($inUse) { Fail "port $DbPort is already in use" 'Free the port, or use the existing server.' }
if ($WhatIfOnly) { Write-Log 'WhatIf: skipping MySQL install' 'WARN'; return }
# --- binaries -----------------------------------------------------------
$mysqld = Join-Path $MysqlRoot 'bin\mysqld.exe'
if (Test-Path $mysqld) {
# Re-running stage 0 after a partial failure must not re-run the MSI.
# Reinstalling an already-registered product is a no-op that STILL needs
# the Windows Installer service, and if msiserver is stopped the client
# blocks forever at 0% CPU with no transaction ever opening and no error.
# The binaries are what this step exists to produce, so if they are here,
# skip straight to configuration. Also makes the stage idempotent (T-005).
Write-Log "MySQL binaries already present at $MysqlRoot; skipping MSI" 'OK'
}
else {
# The Windows Installer service is Manual-start and is supposed to start
# on demand. When it does not, msiexec waits on it indefinitely - so start
# it explicitly rather than trusting on-demand activation.
$msiSvc = Get-Service msiserver -ErrorAction SilentlyContinue
if ($msiSvc -and $msiSvc.Status -ne 'Running') {
Write-Log 'starting Windows Installer service (msiserver)'
try { Start-Service msiserver -ErrorAction Stop } catch {
Write-Log "could not start msiserver: $($_.Exception.Message)" 'WARN'
}
}
# Quiet MSI. INSTALLDIR must have no trailing backslash or the MSI mis-parses it.
Write-Log "installing $($msi.Name) (125MB, takes a minute)"
Invoke-Native 'msiexec.exe' @('/i', $msi.FullName, '/quiet', '/norestart',
"INSTALLDIR=$MysqlRoot") 'MySQL MSI' -TimeoutSec 900
}
# An installer exit code of 0 does NOT mean it did what you asked - the Python
# bootstrapper returned 0 while installing to the wrong directory. Verify.
if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" }
Track 'mysql-files' $MysqlRoot
Write-Log 'MySQL binaries installed' 'OK'
# --- config -------------------------------------------------------------
# Space-free paths on purpose: every quoting bug in this script came from a
# path containing a space being split into two arguments.
New-Item -ItemType Directory -Force -Path (Split-Path $MysqlIni -Parent) | Out-Null
$iniText = @"
[mysqld]
basedir=$MysqlRoot
datadir=$MysqlDataDir
port=$DbPort
bind-address=127.0.0.1
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
default-storage-engine=INNODB
innodb_file_per_table=1
max_connections=200
log-error=$MysqlDataDir\mysql-error.log
"@
Set-Content -Path $MysqlIni -Value $iniText -Encoding ASCII
Write-Log "wrote $MysqlIni"
Track 'mysql-files' $MysqlIni
# --- data directory -----------------------------------------------------
# --initialize-insecure REFUSES a non-empty datadir, so a previous failed
# attempt must be cleared before retrying.
if (Test-Path $MysqlDataDir) {
$items = Get-ChildItem $MysqlDataDir -Force -ErrorAction SilentlyContinue
if ($items) {
Fail "$MysqlDataDir already exists and is not empty" @'
mysqld --initialize-insecure refuses a non-empty data directory. If this is a
failed previous run, remove the directory and re-run stage 0. If it holds real
data, STOP: you are about to destroy a database.
'@
}
}
Write-Log 'initializing data directory'
Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize'
Track 'mysql-datadir' $MysqlDataDir
# --- service ------------------------------------------------------------
# Argument ORDER MATTERS: --install <name> BEFORE --defaults-file. Verified
# empirically - reversed, mysqld exits 1 and the service is not registered.
Write-Log "registering service $MysqlService"
Invoke-Native $mysqld @('--install', $MysqlService, "--defaults-file=$MysqlIni") 'MySQL service register'
Track 'service' $MysqlService
Start-Service -Name $MysqlService
$svc = Get-Service -Name $MysqlService
if ($svc.Status -ne 'Running') { Fail "service $MysqlService did not start" "Check $MysqlDataDir\mysql-error.log" }
Set-Service -Name $MysqlService -StartupType Automatic
Write-Log "service $MysqlService running" 'OK'
# --- bootstrap ----------------------------------------------------------
# --initialize-insecure leaves root with an EMPTY password, so this must run
# immediately. Generated, never supplied; root is shown once and not stored.
$rootPass = New-Secret -Bytes 24
$appPass = New-Secret -Bytes 24
# Written OUTSIDE %TEMP% and ACL'd BEFORE the secrets go in - a file created
# with default ACLs and populated afterwards is readable in the gap.
$sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql'
New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null
New-Item -ItemType File -Force -Path $sqlPath | Out-Null
Protect-File $sqlPath
$sql = @"
CREATE DATABASE IF NOT EXISTS $DbName CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '$DbUser'@'localhost' IDENTIFIED BY '$appPass';
CREATE USER IF NOT EXISTS '$DbUser'@'127.0.0.1' IDENTIFIED BY '$appPass';
GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'localhost';
GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'127.0.0.1';
ALTER USER 'root'@'localhost' IDENTIFIED BY '$rootPass';
FLUSH PRIVILEGES;
"@
Set-Content -Path $sqlPath -Value $sql -Encoding ASCII
try {
$mysqlExe = Join-Path $MysqlRoot 'bin\mysql.exe'
# -e "source <file>" fails with ERROR 1049 Unknown database '<path>': the
# client reads the path as a database name. Redirect stdin instead. That
# also stops PowerShell eating the backticks MySQL uses to quote identifiers.
Invoke-Native $mysqlExe @('-uroot', '--skip-password', '--protocol=TCP',
"--port=$DbPort") 'MySQL bootstrap' -StdinFile $sqlPath
}
finally {
# Unconditional: this file holds both passwords.
Set-Content -Path $sqlPath -Value ('0' * 512) -Encoding ASCII -ErrorAction SilentlyContinue
Remove-Item $sqlPath -Force -ErrorAction SilentlyContinue
}
# Hand the app password to stage 2 through an ACL'd FILE, never stdout -
# stdout lands in the log that FR-161 invites operators to send to support.
$handoff = Join-Path $AppRoot '.dbpass'
New-Item -ItemType File -Force -Path $handoff | Out-Null
Protect-File $handoff
Set-Content -Path $handoff -Value $appPass -Encoding UTF8
Write-Log "database $DbName and user $DbUser created" 'OK'
Write-Log "app password written to $handoff (stage 2 consumes and deletes it)"
Write-Host ''
Write-Host ' MySQL root password (shown ONCE, not stored anywhere):' -ForegroundColor Yellow
Write-Host (" {0}" -f $rootPass) -ForegroundColor Yellow
Write-Host ' Write it down now. It cannot be recovered.' -ForegroundColor Yellow
Write-Host ''
Write-Log 'stage 0 complete' 'OK'
}
# =============================================================================
# STAGE 2 - runtime: Python, venv, wheels, .env
# =============================================================================
function Invoke-Stage2 {
Write-Log 'STAGE 2: runtime and configuration' 'STEP'
if (-not (Test-Path $BundleRoot)) { Fail "bundle not found: $BundleRoot" }
if (-not (Test-Path $WheelDir)) { Fail "wheelhouse not found: $WheelDir" 'Rebuild the bundle with deploy\windows\installer\build-installer.ps1 (or .sh); it refuses to produce a bundle without one.' }
if (-not (Test-Path $AppSource)) { Fail "application payload not found: $AppSource" }
Assert-BundleIntegrity
Assert-VenvMatchesWheelhouse
# Captured HERE, before the stamp below is written. Stage 4's guard against
# touching an installation this did not create tested the stamp directly, and
# stage 2 always runs first in a '-Stage all' install - so the stamp it had
# just written made every server look like one of ours and the guard never
# fired on the case it exists for.
$script:PreexistingInstallStamp = Test-Path (Join-Path $AppRoot '.installed-version')
# --- Python, all users --------------------------------------------------
# A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity
# cannot read. That produces a 500 with an empty HttpPlatform log.
$pyInstaller = Get-ChildItem (Join-Path $BundleRoot 'python') -Filter '*.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
# SPACE-FREE deliberately. `TargetDir=C:\Program Files\Python314` splits at
# the space when passed to the bootstrapper and installs into C:\Program\.
# Same class of failure as MySQL's --defaults-file. Never put a space in a
# path this installer controls.
$pyTarget = 'C:\Python314'
if (Test-Path (Join-Path $pyTarget 'python.exe')) {
Write-Log "Python already present at $pyTarget" 'OK'
} elseif ($null -eq $pyInstaller) {
Fail "no Python installer in $BundleRoot\python"
} else {
Write-Log "installing Python (all users) from $($pyInstaller.Name)"
if (-not $WhatIfOnly) {
Invoke-Native $pyInstaller.FullName @(
'/quiet','InstallAllUsers=1','PrependPath=0','Include_launcher=1',
'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget"
) 'Python install'
Track 'python' $pyTarget
}
}
$sysPy = Join-Path $pyTarget 'python.exe'
if (-not $WhatIfOnly -and -not (Test-Path $sysPy)) { Fail "Python not at $sysPy after install" }
# --- application payload ------------------------------------------------
if (-not (Test-Path $AppRoot)) {
New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null
Track 'dir' $AppRoot
}
# --- upgrade detection ---------------------------------------------------
$script:BundleVersion = Get-BundleVersion
$script:InstalledVersion = Get-InstalledVersion
$script:IsUpgrade = ($script:InstalledVersion -ne '')
$script:DbWasEmpty = $false # established for real in stage 3
if ($script:IsUpgrade) {
Write-Log ("upgrading: installed {0} -> bundle {1}" -f $script:InstalledVersion, $script:BundleVersion) 'STEP'
$cmp = Compare-Version $script:BundleVersion $script:InstalledVersion
if ($cmp -lt 0) {
# Migrations only go forwards. Running older code against a newer
# schema fails in ways that are hard to unpick, so refuse outright.
Fail ("this bundle ({0}) is OLDER than what is installed ({1})" -f $script:BundleVersion, $script:InstalledVersion) @'
Downgrading is not supported: the database schema has already been migrated
forwards, and older code cannot read it. Install a build at least as new as
what is on this server, or restore a backup taken before the upgrade.
'@
}
if ($cmp -eq 0) {
Write-Log 'same version already installed; re-running is harmless (repair)' 'WARN'
}
} else {
Write-Log ("installing version {0}" -f $script:BundleVersion)
}
# UPGRADE PATH: on an existing install the app pool holds python.exe and the
# site files open, so copying over them fails with "file in use" partway
# through - leaving a half-replaced application. Stop the pool first and note
# that we did, so stage 4 knows to start it again.
$script:PoolWasStopped = $false
if (-not $WhatIfOnly) {
try {
Import-Module WebAdministration -ErrorAction Stop
if ((Test-Path "IIS:\AppPools\$AppPool") -and
((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started')) {
Write-Log "stopping app pool $AppPool so its files can be replaced" 'WARN'
Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
# The worker does not exit instantly; copying too soon still fails.
for ($i = 0; $i -lt 15; $i++) {
Start-Sleep -Seconds 1
if (-not (Get-Process w3wp -ErrorAction SilentlyContinue)) { break }
}
$script:PoolWasStopped = $true
}
} catch { }
}
Write-Log "copying application payload to $AppRoot"
if (-not $WhatIfOnly) {
Copy-Item (Join-Path $AppSource '*') -Destination $AppRoot -Recurse -Force
}
# Pick the SPA build that matches the deployment method. Vite compiles the
# base path in, so the wrong one loads a page that then fetches its assets
# from the wrong prefix and renders nothing.
if (-not $WhatIfOnly) {
$rootDist = Join-Path $AppRoot 'frontend\dist'
$subDist = Join-Path $AppRoot 'frontend\dist-subpath'
if ($MountAlias) {
if (-not (Test-Path $subDist)) {
Fail 'this bundle has no subpath frontend build' @'
The bundle was built without the /<alias> SPA, so a subpath install would serve
a page that cannot load its own assets. Rebuild with scripts/build-site.sh
(which produces both), or install without -MountAlias.
'@
}
# The alias is fixed at BUILD time; refuse rather than mis-serve.
$built = ''
$aliasFile = Join-Path $subDist '.alias'
if (Test-Path $aliasFile) { $built = (Get-Content $aliasFile -TotalCount 1).Trim() }
if ($built -and ($built -ne $MountAlias.Trim('/'))) {
Fail ("this bundle's subpath build is for '/{0}', not '/{1}'" -f $built, $MountAlias.Trim('/')) `
'Rebuild the bundle with SUBPATH_ALIAS set to the alias you want.'
}
Remove-Item $rootDist -Recurse -Force -ErrorAction SilentlyContinue
Move-Item $subDist $rootDist -Force
Write-Log ("using the /{0} frontend build" -f $MountAlias.Trim('/')) 'OK'
} else {
# Not needed for a root install; leave nothing confusing behind.
Remove-Item $subDist -Recurse -Force -ErrorAction SilentlyContinue
}
}
if (-not $WhatIfOnly -and $script:BundleVersion) {
# Stamped AFTER the copy: if the copy dies, the stamp still reflects what
# is actually on disk rather than what we hoped to put there.
Set-Content -Path (Join-Path $AppRoot '.installed-version') `
-Value $script:BundleVersion -Encoding ASCII
}
# Keep the lock with the install so `shopdb-admin.ps1 verify` can say WHICH
# bundle this server was built from, months later and offline.
if (-not $WhatIfOnly) {
$bundledLock = Join-Path $BundleRoot 'bundle-lock.json'
if (Test-Path $bundledLock) { Copy-Item $bundledLock $AppRoot -Force }
}
foreach ($sub in @('logs','instance')) {
$p = Join-Path $AppRoot $sub
if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null }
}
# --- venv ---------------------------------------------------------------
if (Test-Path $Py) {
Write-Log 'venv already exists' 'OK'
} else {
Write-Log 'creating venv'
if (-not $WhatIfOnly) {
Invoke-Native $sysPy @('-m','venv',(Join-Path $AppRoot 'venv')) 'venv creation'
Track 'dir' (Join-Path $AppRoot 'venv')
}
}
# --- offline dependency install ----------------------------------------
# PIP_NO_INDEX makes a network attempt impossible rather than merely
# unnecessary. On an air-gapped box pip otherwise hangs on DNS timeouts.
#
# --require-hashes puts pip in hash-checking mode: every wheel must match a
# sha256 listed in requirements.txt or the install ABORTS. Without it pip
# took whatever file in the wheelhouse satisfied the version pin, so a
# swapped or hand-dropped wheel installed silently. The flag is passed
# explicitly rather than relying on pip inferring it from the presence of
# hashes, so shipping an unhashed requirements.txt fails loudly here instead
# of quietly dropping the check.
#
# --only-binary=:all: refuses to fall back to building from an sdist. On a
# server with no compiler and no network that fallback cannot succeed; it
# just turns a clear "no wheel for this platform" into a confusing build
# error deep in someone's setup.py.
Write-Log 'installing dependencies from the wheelhouse (offline, hash-checked)'
if (-not $WhatIfOnly) {
$env:PIP_NO_INDEX = '1'
$env:PIP_FIND_LINKS = $WheelDir
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
try {
Invoke-Native $Py @('-m','pip','install','--no-index',"--find-links=$WheelDir",
'--require-hashes','--only-binary=:all:',
'-r',(Join-Path $AppRoot 'requirements.txt')) 'dependency install'
} finally {
Remove-Item Env:\PIP_NO_INDEX, Env:\PIP_FIND_LINKS -ErrorAction SilentlyContinue
}
# waitress and tzdata are load-bearing on Windows; verify rather than assume.
foreach ($mod in @('waitress','tzdata')) {
& $Py -c "import $mod" 2>$null
if ($LASTEXITCODE -ne 0) {
Fail "$mod did not install" "The wheelhouse is incomplete. $mod is required (waitress is the WSGI server; tzdata provides the IANA timezone database Windows lacks)."
}
}
Write-Log 'waitress and tzdata present' 'OK'
}
# --- .env ---------------------------------------------------------------
if (Test-Path $EnvFile) {
# Preserving .env is right for a plain re-run: regenerating JWT_SECRET_KEY
# would invalidate every issued session.
#
# But if the caller EXPLICITLY supplied connection details - a wizard where
# someone just typed a password, or -DbHost/-DbPasswordFile on the command
# line - then silently keeping the old DATABASE_URL ignores what they asked
# for and fails later with a bare "Access denied", pointing at the database
# instead of at the stale file that actually caused it.
# So: keep the secrets, refresh the connection string.
# Rewrite the connection string ONLY when a password was actually
# supplied. Previously any -DbHost (which the wizard always passes, from
# a page whose defaults were 127.0.0.1/shopdb_flask) rewrote DATABASE_URL
# - so a site whose database lives on another server had its real
# connection string silently replaced with defaults during an upgrade,
# and .env was the only record of it.
if ($DbPasswordFile) {
Write-Log '.env exists and new database details were supplied; updating DATABASE_URL' 'WARN'
$dbPass = ''
if ($DbPasswordFile) {
if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" }
$dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8)
if ($dbPass) { $dbPass = $dbPass.Trim() }
if ($DbPasswordFile -ne (Join-Path $AppRoot '.dbpass')) {
Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force -ErrorAction SilentlyContinue
Remove-Item $DbPasswordFile -Force -ErrorAction SilentlyContinue
}
}
if (-not $dbPass) { Fail 'no database password supplied to update .env' }
if (-not $DbHost) { $DbHost = '127.0.0.1' }
$escUser = [uri]::EscapeDataString($DbUser)
$escPass = [uri]::EscapeDataString($dbPass)
$newUrl = "DATABASE_URL=mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4"
# Keep a copy before touching it. .env holds the DB password in
# plaintext by design, so the backup gets the same owner-only ACL and
# is the operator's way back if the new details turn out wrong.
$envBak = "$EnvFile.bak-{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss')
Copy-Item $EnvFile $envBak -Force -ErrorAction SilentlyContinue
if (Test-Path $envBak) {
Protect-File $envBak
Write-Log "previous .env kept at $envBak"
}
$kept = Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*DATABASE_URL=' }
Set-Content -Path $EnvFile -Value ($kept + $newUrl) -Encoding ASCII
Protect-File $EnvFile
Write-Log 'DATABASE_URL updated; SECRET_KEY and JWT_SECRET_KEY kept' 'OK'
} else {
Write-Log '.env exists and no new password was supplied; keeping it unchanged' 'OK'
Write-Log ' (its database settings and secret keys are left exactly as they are)'
}
# MOUNT_PATH must ALWAYS track the chosen deployment method, whether or
# not the connection string changed. Switching from a subpath install
# back to its own site otherwise leaves MOUNT_PATH=/shopdb behind, and
# wsgi.py keeps mounting the app at /shopdb - so the new site answers
# 404 on every path and the smoke test fails on a correct install.
# CORS_ORIGINS is tied to the deployment method too: switching between
# methods changes the port people arrive on, and a stale value fails every
# browser request while curl (which does not send Origin) still works.
$wantOrigin = ''
if ($MountAlias) {
$parentPort = 80
try {
Import-Module WebAdministration -ErrorAction SilentlyContinue
$b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection |
Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] }
} catch { }
if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME }
if ($parentPort -eq 80) { $wantOrigin = "http://{0}" -f $SiteHost }
else { $wantOrigin = "http://{0}:{1}" -f $SiteHost, $parentPort }
} elseif ($SiteHost) {
$wantOrigin = "http://{0}:{1}" -f $SiteHost, $SitePort
}
$envLines = @(Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*MOUNT_PATH=' })
if ($wantOrigin) {
$envLines = @($envLines | Where-Object { $_ -notmatch '^\s*CORS_ORIGINS=' })
$envLines += ("CORS_ORIGINS={0}" -f $wantOrigin)
Write-Log "CORS_ORIGINS set to $wantOrigin" 'OK'
}
if ($MountAlias) {
$envLines += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/'))
Write-Log ("MOUNT_PATH set to /{0}" -f $MountAlias.Trim('/')) 'OK'
} else {
Write-Log 'MOUNT_PATH removed - the app serves from the site root' 'OK'
}
Set-Content -Path $EnvFile -Value ($envLines -join "`r`n") -Encoding ASCII
Protect-File $EnvFile
} else {
if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME }
# Stage 0 leaves an ACL'd handoff file when it created the database itself.
# Prefer it: a greenfield install then needs no password from anyone, and
# the generated password never passes through a human or a command line.
# This MUST come before the -DbHost check below - stage 0 always installs
# to the local box, so the handoff implies the host as well as the password.
$handoff = Join-Path $AppRoot '.dbpass'
if (-not $DbPasswordFile -and (Test-Path $handoff)) {
$DbPasswordFile = $handoff
if (-not $DbHost) { $DbHost = '127.0.0.1' }
Write-Log 'using the bundled-MySQL password handoff from stage 0 (host 127.0.0.1)'
}
if (-not $DbHost) {
Fail 'no database host supplied and no existing .env' @'
Pass -DbHost <server>. The password is prompted for, never passed on the
command line. For a bundled-database install run stage 0 first: it creates the
database and leaves an ACL'd handoff file, after which no password is needed.
'@
}
# Two ways in, both keeping the password off the command line (which any
# user can read via Win32_Process, and which lands in transcripts and
# ConsoleHost_history.txt).
if ($DbPasswordFile) {
# Unattended path. The caller writes an ACL'd file; we consume and
# shred it so it does not outlive the install.
if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" }
$dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8)
if ($dbPass) { $dbPass = $dbPass.Trim() }
Write-Log "read DB password from $DbPasswordFile"
if ($DbPasswordFile -eq $handoff) {
# Do NOT shred the stage-0 handoff here. It holds the ONLY copy of a
# GENERATED password: .env is the sole other copy, and rollback
# deletes .env. Shredding both on a failed install leaves an
# unrecoverable box - the database exists, the app user exists, and
# nobody alive knows the password. Stage 5 shreds it once the
# install is proven working.
Write-Log 'keeping the stage-0 handoff until stage 5 confirms the install'
} else {
try {
# Operator-supplied file: they know the password, so consume it
# immediately. Overwrite before delete - a plain Remove-Item
# leaves the bytes on disk.
Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force
Remove-Item $DbPasswordFile -Force
Write-Log 'password file overwritten and removed'
} catch { Write-Log "could not remove $DbPasswordFile - delete it manually" 'WARN' }
}
}
else {
# Interactive path. Read-Host -AsSecureString reads the CONSOLE, not
# stdin: piping into it does not work, it just blocks forever with no
# error and no timeout. So refuse up front rather than hang a remote or
# scheduled run.
if (-not [Environment]::UserInteractive) {
Fail 'no interactive console for the password prompt' @'
This session has no console, so the password prompt would hang indefinitely
(Read-Host -AsSecureString reads the console directly and ignores piped stdin).
Re-run with -DbPasswordFile <path> pointing at a file whose first line is the
password. The installer reads it, overwrites it and deletes it.
'@
}
$sec = Read-Host -Prompt ("Password for MySQL user '{0}' on {1}" -f $DbUser, $DbHost) -AsSecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
try { $dbPass = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
}
if (-not $dbPass) { Fail 'no password entered' }
# Percent-encode: passwords routinely contain characters that break a URL.
$escUser = [uri]::EscapeDataString($DbUser)
$escPass = [uri]::EscapeDataString($dbPass)
$DatabaseUrl = "mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4"
# CORS_ORIGINS must be the ORIGIN PEOPLE ACTUALLY USE, and an explicit one -
# a wildcard makes the app refuse to boot. Under a subpath install the app
# is reached on the PARENT site's port (usually 80), so pinning the
# stand-alone port here would make every browser request fail CORS on a
# correctly installed server. Origin is scheme+host+port only - never the path.
if ($MountAlias) {
$parentPort = 80
try {
Import-Module WebAdministration -ErrorAction SilentlyContinue
$b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection |
Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] }
} catch { }
if ($parentPort -eq 80) { $origin = "http://{0}" -f $SiteHost }
else { $origin = "http://{0}:{1}" -f $SiteHost, $parentPort }
} else {
$origin = "http://{0}:{1}" -f $SiteHost, $SitePort
}
$content = @(
'FLASK_ENV=production'
("SECRET_KEY={0}" -f (New-Secret))
("JWT_SECRET_KEY={0}" -f (New-Secret))
("DATABASE_URL={0}" -f $DatabaseUrl)
("CORS_ORIGINS={0}" -f $origin)
'LOG_LEVEL=INFO'
)
if ($MountAlias) {
# wsgi.py reads MOUNT_PATH and moves the prefix from PATH_INFO to
# SCRIPT_NAME, so Flask both routes and GENERATES urls under it.
# Added to the ARRAY, before the join - appending to the joined string
# glues it onto the previous line ("LOG_LEVEL=INFOMOUNT_PATH=/shopdb").
$content += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/'))
}
$content = $content -join "`r`n"
Write-Log "writing .env (CORS_ORIGINS=$origin)"
if (-not $WhatIfOnly) {
Set-Content -Path $EnvFile -Value $content -Encoding ASCII
Track 'file' $EnvFile
# .env holds the database password in plaintext BY DESIGN - the app
# reads it at boot. Filesystem permissions are the only control.
# Administrators and SYSTEM only; the app-pool identity gets Read in stage 4.
Protect-File $EnvFile
# Clear the plaintext copy from this session's memory.
$dbPass = $null; $content = $null
[GC]::Collect()
}
}
Write-Log 'stage 2 complete' 'OK'
}
# =============================================================================
# STAGE 3 - data: schema, seeds, plugins
# =============================================================================
function Invoke-Stage3 {
Write-Log 'STAGE 3: schema, seed data, plugins' 'STEP'
if (-not (Test-Path $Flask)) { Fail 'venv not found; run stage 2 first' }
# Resolve the site profile. An explicit -SiteProfile wins; otherwise use the
# one the bundle was built from, which scripts/build-site.sh stages alongside
# the app tree so the profile and the lean tree can never drift apart.
$ProfilePath = $SiteProfile
if (-not $ProfilePath) { $ProfilePath = Join-Path $AppRoot 'site-profile.json' }
if (-not (Test-Path $ProfilePath)) {
Fail "site profile not found: $ProfilePath" @'
The installer no longer carries a hardcoded plugin list - the site's set is
declared in a profile and the bundle is built lean for it.
Either pass -SiteProfile <path>, or rebuild the bundle with
scripts/build-site.sh <profile.json>, which stages the profile into the app tree.
See deploy/site-profile.example.json for the format.
'@
}
# Report the chosen set up front: it decides both what gets installed and what
# prune-schema drops, so it belongs in the log FR-161 sends to support.
try {
$declared = (Get-Content $ProfilePath -Raw | ConvertFrom-Json).plugins -join ', '
Write-Log "site profile: $ProfilePath"
Write-Log "declared plugins: $declared"
} catch { Fail "site profile is not valid JSON: $ProfilePath" }
$env:FLASK_APP = 'shopdb'
Push-Location $AppRoot
try {
# The app ships its own preflight. Use it rather than reimplementing:
# it checks Python, required env, DB connectivity and the MySQL 5.6 flags.
Write-Log 'running flask db-utils preflight'
# TRAP (documented in Invoke-Native, and hit again here): the app logs
# plugin initialisation to STDERR even on success. With 2>&1 and
# $ErrorActionPreference='Stop' PowerShell turns that into a terminating
# NativeCommandError, so a healthy preflight aborts the install with a
# plugin INFO line as the "error". Judge by the OUTPUT, not by stderr.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try { $pf = & $Flask db-utils preflight 2>&1 }
finally { $ErrorActionPreference = $prevEAP }
$pf | ForEach-Object { Write-Log " $_" }
# Match the preflight's own status column (" FAIL <check>"), not any line
# that merely contains the word - plugin log lines can too.
$pfFailed = @($pf | Where-Object { $_ -match '^\s*FAIL\s' }).Count -gt 0
if ($LASTEXITCODE -ne 0 -or $pfFailed) {
Fail 'application preflight reported failures' 'Fix the FAIL items above, then re-run stage 3.'
}
if ($WhatIfOnly) { Write-Log 'WhatIf: skipping migrations and seeds' 'WARN'; return }
# Decide fresh-vs-existing from the DATABASE, and do it HERE rather than
# relying on a variable set in stage 2 - stage 3 is documented as
# separately runnable ("re-run stage 3"), and under Set-StrictMode
# reading an unset $script:IsUpgrade is a terminating error.
$script:DbWasEmpty = Test-DatabaseEmpty
if (-not $script:DbWasEmpty) {
$script:IsUpgrade = $true
Write-Log 'existing data found in the target database - treating this as an upgrade' 'WARN'
}
# Back up BEFORE Alembic touches anything. Skipped only when the database
# is provably empty; on anything else it is the only thing standing
# between a failed migration and a half-migrated database.
$script:PreUpgradeBackup = ''
if (-not $script:DbWasEmpty) {
$script:PreUpgradeBackup = Backup-Database 'pre-upgrade'
if (-not $script:PreUpgradeBackup) {
Fail 'could not back up the database before upgrading' @'
Refusing to migrate without a backup. Fix mysqldump access (or take a backup
manually with shopdb-admin.ps1 backup) and run this again.
'@
}
}
# Alembic owns the schema. Nothing else may create tables.
Write-Log 'flask db upgrade'
try {
Invoke-Native $Flask @('db','upgrade') 'schema upgrade'
} catch {
if ($script:PreUpgradeBackup) {
Write-Log 'migration failed - putting the database back' 'FAIL'
if (Restore-Database $script:PreUpgradeBackup) {
Fail 'the upgrade failed; your data was restored from the pre-upgrade backup' @'
Your DATA is intact. The schema may still carry tables the failed migration
created before it stopped - the log lists any of them, and they are empty.
The application files on disk are the NEW version, so re-install the PREVIOUS
build to get a matching pair, then send the install log to support.
'@
}
}
throw
}
foreach ($seed in @('reference-data','permissions','settings')) {
Write-Log "flask seed $seed"
Invoke-Native $Flask @('seed',$seed) "seed $seed"
}
# Plugin registry starts empty on a fresh box. apply-profile resolves the
# declared set's hard-dependency closure and installs + enables it in
# dependency order, idempotently (ADR-013). It fails loudly if the profile
# names a plugin that is not on disk - which on a lean bundle correctly
# catches a profile/bundle mismatch.
# An install-time selection overrides the bundled profile. Written here,
# after the payload copy, so it survives stage 2 replacing the app tree.
if ($SitePlugins) {
$chosen = @($SitePlugins -split ',' | ForEach-Object { $_.Trim() } |
Where-Object { $_ })
$onDisk = @(Get-ChildItem (Join-Path $AppRoot 'plugins') -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
Select-Object -ExpandProperty Name)
$missing = @($chosen | Where-Object { $onDisk -notcontains $_ })
if ($missing.Count -gt 0) {
Fail ("these plugins are not in this bundle: {0}" -f ($missing -join ', ')) `
'Rebuild the installer from a profile that includes them.'
}
$profileJson = @{ site = $SiteHost; plugins = $chosen; locked = @() } |
ConvertTo-Json -Depth 3
Set-Content -Path $ProfilePath -Value $profileJson -Encoding ASCII
Write-Log ("site profile rewritten from the installer selection: {0}" -f ($chosen -join ', ')) 'OK'
}
Write-Log "flask plugin apply-profile $ProfilePath"
Invoke-Native $Flask @('plugin','apply-profile',$ProfilePath) 'apply site profile'
# Per-plugin Alembic chains (ADR-008) run after the plugins are registered.
Write-Log 'flask plugin upgrade-all'
Invoke-Native $Flask @('plugin','upgrade-all') 'plugin migrations'
# ADR-014 Phase 2. The shared core Alembic baseline creates EVERY plugin's
# tables regardless of this site's choice, so a lean site still carries the
# omitted plugins' (empty) tables. Drop them, leaving core + chosen.
#
# Both flags are required and they mean different things: --yes executes
# (the command is a dry-run preview otherwise), --force permits dropping a
# table that holds rows. --force is needed even on a fresh install because
# core migrations seed a few plugin reference tables (e.g. 7d05 inserts
# default access protocols). Safe here and ONLY here: this runs during
# initial provisioning, before any site data exists.
# NOTE: ADR-014's prose says lean provisioning "uses --force"; that alone
# would silently do nothing but print a preview. It needs both.
# --force drops tables that CONTAIN ROWS. That is required on a fresh
# install, because core migrations seed a few plugin reference tables
# (7d05 inserts default access protocols) and prune would otherwise
# refuse. On an UPGRADE the same flag would silently destroy a site's
# data for any plugin that is not installed - so never force there.
# Without --force, prune-schema refuses non-empty tables and says so,
# which is the behaviour we want when data exists.
if (-not $script:DbWasEmpty) {
Write-Log 'flask plugin prune-schema --yes (no --force: refuses to drop tables holding data)'
$pruneOut = Invoke-Native $Flask @('plugin','prune-schema','--yes') 'prune not-installed plugin tables'
$skipped = @($pruneOut | Where-Object { $_ -match 'refus|not empty|rows' })
if ($skipped.Count -gt 0) {
Write-Log 'some plugin tables were kept because they hold data:' 'WARN'
$skipped | ForEach-Object { Write-Log " $_" 'WARN' }
Write-Log ' drop them by hand only if you are certain that data is not needed' 'WARN'
}
} else {
# Provably-empty database only: core migrations seed a few plugin
# reference tables, so prune would otherwise refuse.
Write-Log 'flask plugin prune-schema --yes --force (database was empty at start)'
Invoke-Native $Flask @('plugin','prune-schema','--yes','--force') 'prune not-installed plugin tables'
}
}
finally { Pop-Location }
Write-Log 'stage 3 complete' 'OK'
}
# =============================================================================
# STAGE 4 - IIS
# =============================================================================
function Invoke-Stage4 {
Write-Log 'STAGE 4: IIS site' 'STEP'
Import-Module WebAdministration -ErrorAction Stop
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
# HttpPlatformHandler is what actually launches waitress; IIS cannot serve
# this application without it. The MSI ships in the bundle but nothing ever
# installed it, so a clean server ran all the way to the stage 5 smoke test -
# Python, venv, schema, plugins and IIS all mutated - and only then failed.
# It must also come BEFORE the section unlock below: system.webServer/
# httpPlatform does not exist until the module is registered.
$hphDll = Join-Path $env:windir 'system32\inetsrv\httpplatformhandler.dll'
if (Test-Path $hphDll) {
Write-Log 'HttpPlatformHandler already installed' 'OK'
} else {
$hphMsi = Get-ChildItem (Join-Path $BundleRoot 'httpplatformhandler') -Filter '*.msi' -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $hphMsi) {
Fail 'HttpPlatformHandler is not installed and no MSI is in the bundle' @'
IIS cannot run this application without the HttpPlatformHandler module.
Add httpplatformhandler\httpPlatformHandler_amd64.msi to the bundle, or install
the module by hand, then re-run stage 4.
'@
}
Write-Log "installing $($hphMsi.Name)"
if (-not $WhatIfOnly) {
Invoke-Native 'msiexec.exe' @('/i', $hphMsi.FullName, '/quiet', '/norestart') `
'HttpPlatformHandler MSI' -TimeoutSec 600
if (-not (Test-Path $hphDll)) {
Fail 'the HttpPlatformHandler MSI reported success but the module is missing' `
'An installer exit code of 0 does not prove it did what was asked.'
}
Write-Log 'HttpPlatformHandler installed' 'OK'
}
}
# --- client IP -----------------------------------------------------------
# URL Rewrite is what lets the XFF rule below exist. Install it BEFORE
# writing a web.config that references <rewrite>, or IIS answers every
# request with 500.19 until someone works out which module is missing.
if ($ClientIpSource -eq 'direct') {
$rewriteDll = Join-Path $env:windir 'system32\inetsrv\rewrite.dll'
if (Test-Path $rewriteDll) {
Write-Log 'URL Rewrite already installed' 'OK'
} else {
$rwMsi = Get-ChildItem (Join-Path $BundleRoot 'urlrewrite') -Filter '*.msi' -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $rwMsi) {
Fail 'URL Rewrite is not installed and no MSI is in the bundle' @'
-ClientIpSource direct needs the IIS URL Rewrite module to set X-Forwarded-For.
Add urlrewrite\rewrite_amd64.msi to the bundle and rebuild, or re-run with
-ClientIpSource proxy if something in front of IIS already sets the header.
Installing it later by hand also works: this server has no network, so the MSI
has to come from the bundle either way.
'@
}
Write-Log "installing $($rwMsi.Name)"
if (-not $WhatIfOnly) {
Invoke-Native 'msiexec.exe' @('/i', $rwMsi.FullName, '/quiet', '/norestart') `
'URL Rewrite MSI' -TimeoutSec 600
if (-not (Test-Path $rewriteDll)) {
Fail 'the URL Rewrite MSI reported success but the module is missing' `
'An installer exit code of 0 does not prove it did what was asked.'
}
Write-Log 'URL Rewrite installed' 'OK'
}
}
} else {
Write-Log 'client IP comes from an upstream proxy; not installing URL Rewrite' 'OK'
}
# web.config ships in the repo; its paths need correcting and the client-IP
# rule needs enabling or leaving off.
#
# An EXISTING web.config is never overwritten. It is the one file on the
# server that legitimately carries hand-edits - a nested application for
# /installers, bindings, a proxy-specific rule - and overwriting it reverts
# them silently. On a server where the XFF rule was enabled by hand, that
# alone would turn the GE-Enforce IP allowlist off without a word in any log.
$srcCfg = Join-Path $AppRoot 'deploy\windows\web.config'
$dstCfg = Join-Path $AppRoot 'web.config'
if (Test-Path $dstCfg) {
Write-Log 'web.config already exists; leaving it alone' 'OK'
$existing = Get-Content $dstCfg -Raw
$hasRule = $existing -match '<rewrite>' -and $existing -notmatch 'SHOPDB-CLIENTIP-BEGIN'
if ($ClientIpSource -eq 'direct' -and -not $hasRule) {
Write-Log 'this web.config does NOT set X-Forwarded-For; client IPs will read as 127.0.0.1' 'WARN'
Write-Log ' enable the SHOPDB-CLIENTIP block by hand, or delete web.config and re-run stage 4' 'WARN'
}
if ($ClientIpSource -eq 'proxy' -and $hasRule) {
Write-Log 'this web.config OVERWRITES X-Forwarded-For, which discards the real client IP behind a proxy' 'WARN'
Write-Log ' remove the <rewrite> block by hand if a proxy in front already sets the header' 'WARN'
}
} elseif (Test-Path $srcCfg) {
Write-Log "installing web.config (client IP: $ClientIpSource)"
if (-not $WhatIfOnly) {
$cfg = Get-Content $srcCfg -Raw
$cfg = $cfg.Replace('C:\shopdb-flask', $AppRoot)
if ($ClientIpSource -eq 'direct') {
# Uncomment by deleting the two marker lines. A string operation
# on explicit markers, not a regex over the surrounding prose:
# editing that prose must never silently disable the rule.
if ($cfg -notmatch 'SHOPDB-CLIENTIP-BEGIN') {
Fail 'the shipped web.config has no SHOPDB-CLIENTIP block' `
'It was edited. Restore deploy\windows\web.config from the repository.'
}
$cfg = $cfg.Replace('<!-- SHOPDB-CLIENTIP-BEGIN', '').Replace('SHOPDB-CLIENTIP-END -->', '')
Write-Log 'X-Forwarded-For rule enabled' 'OK'
}
Set-Content -Path $dstCfg -Value $cfg -Encoding UTF8
Track 'file' $dstCfg
}
} else {
Fail "web.config not found at $srcCfg or $dstCfg"
}
# App pool: No Managed Code. This is a Python app; the CLR must not load.
if (Test-Path "IIS:\AppPools\$AppPool") {
Write-Log "app pool $AppPool already exists" 'OK'
} else {
Write-Log "creating app pool $AppPool"
if (-not $WhatIfOnly) {
New-WebAppPool -Name $AppPool | Out-Null
Set-ItemProperty "IIS:\AppPools\$AppPool" -Name managedRuntimeVersion -Value ''
Track 'apppool' $AppPool
}
}
# ACLs. RX on the tree; Modify on logs\ and instance\ or plugin toggles and
# uploads fail with "internal error". Read on .env for the pool identity.
if (-not $WhatIfOnly) {
$ident = "IIS AppPool\$AppPool"
Write-Log "granting $ident access"
Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot'
foreach ($sub in @('logs','instance')) {
Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub"
}
Invoke-Native 'icacls.exe' @($EnvFile,'/grant',"${ident}:(R)") 'ACL on .env'
}
# Handler sections are locked server-wide by default. Without unlocking,
# IIS returns 500.19 the moment it reads the app's web.config.
foreach ($section in @('system.webServer/handlers','system.webServer/httpPlatform')) {
Write-Log "unlocking $section"
if (-not $WhatIfOnly) {
& $appcmd unlock config /section:$section 2>&1 | ForEach-Object { Write-Log " $_" }
}
}
# Switching method must not leave BOTH deployments in place: two entry points
# to one directory, one of them serving an SPA built for the wrong base path.
# Remove whichever artifact belongs to the method we are NOT using.
#
# GUARDED. These removals are correct when this installer owns both artifacts
# - it is how switching method avoids leaving two entry points to one
# directory, one of them serving an SPA built for the wrong base path. They
# are NOT correct against an installation someone else built: a wrong
# -MountAlias would delete a live mount with no prompt and no error, and the
# first sign would be the site 404ing.
#
# 'Ours' means there was a version stamp BEFORE this run started - only this
# installer writes one. Stage 2 records that observation; testing the file
# here instead would read the stamp stage 2 has already written.
$weBuiltThis = if ($null -ne $script:PreexistingInstallStamp) {
$script:PreexistingInstallStamp
} else {
# Stage 4 run on its own, so stage 2 has not written anything this run.
Test-Path (Join-Path $AppRoot '.installed-version')
}
if (-not $WhatIfOnly) {
$doomed = @()
if ($MountAlias) {
if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) {
$doomed += @{ Kind = 'site'; Name = $SiteName; Site = '' }
}
} else {
foreach ($site in (Get-Website)) {
foreach ($app in (Get-WebApplication -Site $site.Name -ErrorAction SilentlyContinue)) {
if ($app.PhysicalPath -eq $AppRoot) {
$doomed += @{ Kind = 'app'; Name = $app.Path.Trim('/'); Site = $site.Name }
}
}
}
}
if ($doomed.Count -gt 0 -and -not $weBuiltThis -and -not $AdoptExisting) {
foreach ($d in $doomed) {
if ($d.Kind -eq 'site') { Write-Log (" would remove IIS site '{0}'" -f $d.Name) 'FAIL' }
else { Write-Log (" would remove application '/{0}' under '{1}'" -f $d.Name, $d.Site) 'FAIL' }
}
Fail 'this server has an IIS deployment that this installer did not create' @'
Publishing the way you asked means removing what is listed above, and there is
no version stamp to show this installer put it there. Deleting a mount somebody
else configured is not something to do without being asked.
If the removal is what you want, re-run with -AdoptExisting.
If not, re-run matching how the application is published today: pass
-MountAlias <alias> to keep it as an application under an existing site, or omit
-MountAlias to keep it as a site of its own.
'@
}
foreach ($d in $doomed) {
if ($d.Kind -eq 'site') {
Write-Log "removing the previous stand-alone site '$($d.Name)' (now published as a subpath)" 'WARN'
Remove-Website -Name $d.Name -ErrorAction SilentlyContinue
Get-NetFirewallRule -DisplayName ("{0} {1}" -f $d.Name, $SitePort) -ErrorAction SilentlyContinue |
Remove-NetFirewallRule -ErrorAction SilentlyContinue
} else {
Write-Log "removing the previous subpath application '/$($d.Name)' (now its own site)" 'WARN'
Remove-WebApplication -Site $d.Site -Name $d.Name -ErrorAction SilentlyContinue
}
}
}
if ($MountAlias) {
# METHOD B: an IIS Application under an existing site, reached at
# http://<server-fqdn>/<alias>/ - no new DNS name and no port in the URL.
# The app's own handler mappings apply only inside the Application, so the
# parent site's handlers (classic ASP, static files) are untouched.
$alias = $MountAlias.Trim('/')
if (-not (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue)) {
Fail "parent site '$ParentSite' does not exist" `
'Pass -ParentSite with the name of an existing IIS site, or install without -MountAlias.'
}
$existing = Get-WebApplication -Site $ParentSite -Name $alias -ErrorAction SilentlyContinue
if ($existing) {
Write-Log "application /$alias already exists under '$ParentSite'" 'OK'
} else {
Write-Log "creating application /$alias under '$ParentSite'"
if (-not $WhatIfOnly) {
New-WebApplication -Site $ParentSite -Name $alias -PhysicalPath $AppRoot `
-ApplicationPool $AppPool | Out-Null
Track 'webapp' ("{0}/{1}" -f $ParentSite, $alias)
}
}
# No firewall rule: the parent site's port is already reachable, which is
# the whole point of this method.
Write-Log "no firewall change needed - served on '$ParentSite' existing bindings" 'OK'
}
else {
# METHOD A: its own site on $SitePort.
if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) {
Write-Log "site $SiteName already exists" 'OK'
} else {
Write-Log "creating site $SiteName on port $SitePort"
if (-not $WhatIfOnly) {
New-Website -Name $SiteName -Port $SitePort -PhysicalPath $AppRoot -ApplicationPool $AppPool | Out-Null
Track 'site' $SiteName
}
}
$ruleName = "$SiteName $SitePort"
if (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue) {
Write-Log 'firewall rule already exists' 'OK'
} else {
Write-Log "adding firewall rule for TCP $SitePort"
if (-not $WhatIfOnly) {
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP `
-LocalPort $SitePort -Action Allow | Out-Null
Track 'firewall' $ruleName
}
}
if (-not $WhatIfOnly) { Start-Website -Name $SiteName -ErrorAction SilentlyContinue }
}
# Stage 2 stops the pool on an upgrade so its files can be replaced. Nothing
# else starts it again, so the smoke test would fail against a stopped site
# and report it as a broken install.
try {
Import-Module WebAdministration -ErrorAction SilentlyContinue
if (Test-Path "IIS:\AppPools\$AppPool") {
if ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started') {
Write-Log "starting app pool $AppPool"
Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
}
}
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
if ($site -and $site.State -ne 'Started') {
Write-Log "starting site $SiteName"
Start-Website -Name $SiteName -ErrorAction SilentlyContinue
}
} catch { Write-Log "could not start the site: $($_.Exception.Message)" 'WARN' }
Write-Log 'stage 4 complete' 'OK'
}
# =============================================================================
# STAGE 5 - verify and hand off
# =============================================================================
function Invoke-Stage5 {
Write-Log 'STAGE 5: smoke test' 'STEP'
if ($WhatIfOnly) { Write-Log 'WhatIf: skipping smoke test' 'WARN'; return }
# Stages can be run one at a time, so do not assume stage 4 just ran.
try {
Import-Module WebAdministration -ErrorAction SilentlyContinue
if ((Test-Path "IIS:\AppPools\$AppPool") -and
((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started')) {
Write-Log "app pool was stopped; starting it before the smoke test" 'WARN'
Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
}
} catch { }
# FR-150: test the host operators will actually use, not just the loopback.
# localhost alone passes even when the app is unreachable by name and when
# CORS_ORIGINS names a host that does not resolve - so the SPA would fail its
# first XHR on a site the installer just called healthy.
$hostName = if ($SiteHost) { $SiteHost } else { $env:COMPUTERNAME }
# Under method B the app answers on the PARENT site's port, at the alias -
# requesting http://localhost:8090/ would test a site that does not exist.
if ($MountAlias) {
$alias = $MountAlias.Trim('/')
$parentPort = 80
try {
$b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection |
Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] }
} catch { }
$targets = @("http://localhost:$parentPort/$alias/")
if ($hostName -and $hostName -ne 'localhost') {
$targets += "http://{0}:{1}/{2}/" -f $hostName, $parentPort, $alias
}
} else {
$targets = @("http://localhost:$SitePort/")
if ($hostName -and $hostName -ne 'localhost') {
$targets += "http://{0}:{1}/" -f $hostName, $SitePort
}
}
# First request boots the app and connects to MySQL: the runbook records ~15s.
Write-Log "requesting $($targets[0]) (first request takes ~15s while the app boots)"
$ok = $false
for ($i = 1; $i -le 12; $i++) {
try {
$r = Invoke-WebRequest -Uri $targets[0] -UseBasicParsing -TimeoutSec 20
if ($r.StatusCode -eq 200) { $ok = $true; break }
} catch {
Start-Sleep -Seconds 5
}
}
if (-not $ok) {
Fail "site did not return 200 at $($targets[0])" `
"Check $AppRoot\logs. Empty HttpPlatform log usually means the app-pool identity cannot read $AppRoot or run the venv, or .env is missing/invalid."
}
Write-Log 'site responded 200 on localhost' 'OK'
# The app is up by now, so the configured-host check needs no boot retry. A
# failure here is a name-resolution or firewall problem, not a broken install,
# so warn rather than Fail: rolling back a working install would be worse.
foreach ($t in $targets[1..($targets.Count - 1)]) {
try {
$r = Invoke-WebRequest -Uri $t -UseBasicParsing -TimeoutSec 20
if ($r.StatusCode -eq 200) { Write-Log "site responded 200 on $t" 'OK' }
else { Write-Log "unexpected status $($r.StatusCode) at $t" 'WARN' }
} catch {
Write-Log "site did NOT respond at $t - CORS_ORIGINS names this host, so the SPA will fail its first request from other machines" 'WARN'
Write-Log " check DNS/hosts for '$hostName' and the firewall rule on TCP $SitePort" 'WARN'
}
}
# The install is proven working, so the stage-0 handoff is no longer the only
# copy of the generated password (.env has it and the app is running on it).
# Safe to shred now, and only now - see the note in stage 2.
$handoff = Join-Path $AppRoot '.dbpass'
if (Test-Path $handoff) {
try {
Set-Content -Path $handoff -Value ('0' * 256) -Encoding UTF8 -Force
Remove-Item $handoff -Force
Write-Log 'stage-0 password handoff shredded (install verified)' 'OK'
} catch { Write-Log "could not remove $handoff - delete it manually" 'WARN' }
}
Write-Host ''
Write-Host ' ShopDB-Flask is installed.' -ForegroundColor Green
Write-Host ''
# FR-151: send the operator to /login, not to /. The site root renders the
# anonymous dashboard; the first-run gate lives on the login route, so an
# operator landing on / sees an empty dashboard and no way to discover setup.
if ($MountAlias) {
Write-Host (" Open: http://{0}/{1}/login" -f $hostName, $MountAlias.Trim('/'))
} else {
Write-Host (" Open: http://{0}:{1}/login" -f $hostName, $SitePort)
}
Write-Host ''
Write-Host ' With no user in the database that page offers to create the first'
Write-Host ' administrator, then runs the setup wizard (site details, features,'
Write-Host ' floor map, common vendors).'
Write-Host ''
Write-Host ' Headless alternative:'
Write-Host (" cd {0}" -f $AppRoot)
Write-Host ' venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example'
Write-Host ' (the password is generated and printed once)'
Write-Host ''
Write-Host (" Install log: {0}" -f $script:LogPath)
Write-Host ''
}
# =============================================================================
# Rollback
# =============================================================================
function Invoke-Rollback {
Write-Log 'rolling back what this run created' 'WARN'
# Reverse order. Never touches an external database, and never removes a
# data directory without explicit confirmation.
for ($i = $script:Created.Count - 1; $i -ge 0; $i--) {
$item = $script:Created[$i]
try {
switch ($item.Kind) {
'site' { Remove-Website -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed site $($item.Id)" }
'webapp' {
$parts = $item.Id -split '/', 2
Remove-WebApplication -Site $parts[0] -Name $parts[1] -ErrorAction SilentlyContinue
Write-Log " removed application /$($parts[1]) under '$($parts[0])'"
}
'apppool' { Remove-WebAppPool -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed app pool $($item.Id)" }
'firewall' { Remove-NetFirewallRule -DisplayName $item.Id -ErrorAction SilentlyContinue; Write-Log " removed firewall rule" }
'file' { Remove-Item $item.Id -Force -ErrorAction SilentlyContinue; Write-Log " removed $($item.Id)" }
'dir' { Write-Log " left directory $($item.Id) in place (remove manually if wanted)" 'WARN' }
'python' { Write-Log " left Python install in place at $($item.Id)" 'WARN' }
'service' {
# Stop and deregister a service THIS run created. Safe because
# stage 0 refuses to run when any MySQL service already exists.
Stop-Service -Name $item.Id -Force -ErrorAction SilentlyContinue
& sc.exe delete $item.Id | Out-Null
Write-Log " removed service $($item.Id)"
}
'mysql-files' { Write-Log " left MySQL binaries/config at $($item.Id) (remove manually if wanted)" 'WARN' }
# FR-162a: never delete a database directory automatically. Even on
# a failed run the operator may have put data in it, and a wrong
# guess here is unrecoverable.
'mysql-datadir' {
Write-Log " LEFT MySQL data directory $($item.Id) IN PLACE" 'WARN'
Write-Log ' delete it manually only if you are certain it holds no data' 'WARN'
}
}
} catch { Write-Log " rollback of $($item.Kind) $($item.Id) failed: $($_.Exception.Message)" 'WARN' }
}
}
# =============================================================================
# Uninstall
# =============================================================================
# Deliberately conservative. It removes what the installer creates and NEVER
# touches data without a typed confirmation: no dropped databases, no deleted
# data directory, no uninstalled MySQL that might serve another application.
function Invoke-Uninstall {
Write-Log 'UNINSTALL' 'STEP'
Write-Host ''
Write-Host ' This removes the ShopDB-Flask IIS site, app pool, firewall rule and' -ForegroundColor Yellow
Write-Host (" the application directory {0}." -f $AppRoot) -ForegroundColor Yellow
Write-Host ' It does NOT drop any database and does NOT uninstall MySQL.' -ForegroundColor Yellow
Write-Host ''
if ($OnFailure -ne 'never') {
$answer = Read-Host 'Type UNINSTALL to proceed'
if ($answer -ne 'UNINSTALL') { Write-Log 'aborted by operator'; return }
}
Import-Module WebAdministration -ErrorAction SilentlyContinue
if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) {
Remove-Website -Name $SiteName -ErrorAction SilentlyContinue
Write-Log "removed site $SiteName" 'OK'
} else { Write-Log "site $SiteName not present" }
if (Test-Path "IIS:\AppPools\$AppPool") {
# Stop first: a running worker holds the app directory open and the
# subsequent Remove-Item then fails with "file in use".
Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
Remove-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
Write-Log "removed app pool $AppPool" 'OK'
} else { Write-Log "app pool $AppPool not present" }
$rule = "ShopDB-Flask $SitePort"
if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) {
Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue
Write-Log "removed firewall rule '$rule'" 'OK'
} else { Write-Log "firewall rule '$rule' not present" }
if (Test-Path $AppRoot) {
# .env holds the database password in plaintext by design (the app reads it
# at boot), so shred it rather than just unlinking.
$envPath = Join-Path $AppRoot '.env'
if (Test-Path $envPath) {
Set-Content -Path $envPath -Value ('0' * 1024) -ErrorAction SilentlyContinue
Write-Log 'overwrote .env before deletion (it holds the DB password)'
}
try {
Remove-Item $AppRoot -Recurse -Force -ErrorAction Stop
Write-Log "removed $AppRoot" 'OK'
} catch {
Write-Log "could not fully remove $AppRoot : $($_.Exception.Message)" 'WARN'
Write-Log ' a running worker process usually holds it; try again after iisreset' 'WARN'
}
} else { Write-Log "$AppRoot not present" }
Write-Host ''
Write-Host ' Uninstalled.' -ForegroundColor Green
Write-Host ''
Write-Host (" The database '{0}' was NOT dropped." -f $DbName)
Write-Host ' MySQL was NOT uninstalled or modified.'
Write-Host ' Remove either manually if this box is being decommissioned.'
Write-Host ''
}
# =============================================================================
# Main
# =============================================================================
Write-Log "ShopDB-Flask installer, stage=$Stage, approot=$AppRoot, bundle=$BundleRoot" 'STEP'
if ($WhatIfOnly) { Write-Log 'WhatIfOnly: no changes will be made' 'WARN' }
try {
switch ($Stage) {
'0' { Invoke-Stage0 }
'1' { & (Join-Path $PSScriptRoot 'shopdb-preflight.ps1') -SitePort $SitePort -AppRoot $AppRoot }
'2' { Invoke-Stage2 }
'3' { Invoke-Stage3 }
'4' { Invoke-Stage4 }
'5' { Invoke-Stage5 }
# Stage 0 is NOT in 'all' on purpose: most sites already run MySQL, and
# installing a second server on top of an existing one is destructive.
'all' { Invoke-Stage2; Invoke-Stage3; Invoke-Stage4; Invoke-Stage5 }
'uninstall' { Invoke-Uninstall }
}
Write-Log "done. log: $script:LogPath" 'OK'
exit 0
}
catch {
Write-Log $_.Exception.Message 'FAIL'
if ($script:Created.Count -gt 0) {
$doRollback = $false
if ($OnFailure -eq 'always') { $doRollback = $true }
elseif ($OnFailure -eq 'ask') {
Write-Host ''
$answer = Read-Host 'Roll back what this run created? (yes/no)'
$doRollback = ($answer -eq 'yes')
}
if ($doRollback) { Invoke-Rollback }
else { Write-Log 'rollback declined; partial install left in place' 'WARN' }
}
Write-Log "log: $script:LogPath" 'FAIL'
exit 1
}