<# .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.4 LTS (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. # SPACE-FREE, deliberately - same reason as the Python target below. msiexec # takes public properties as PROPERTY=value on the command line, and a value # containing spaces has to be quoted as INSTALLDIR="C:\..." - quoting the # whole PROPERTY=value token instead makes msiexec reject the command line # and pop its usage dialog, which is what 'the bundled MySQL database could # not be installed' looked like from the outside. [string] $MysqlRoot = 'C:\MySQL84', [string] $MysqlDataDir = 'C:\ProgramData\MySQL\data', [string] $MysqlIni = 'C:\ProgramData\MySQL\my.ini', [string] $MysqlService = 'MySQL84', [Parameter(Mandatory=$true)] [string] $BundleRoot, [string] $AppRoot = 'C:\shopdb-flask', [int] $SitePort = 8090, [string] $SiteName = 'shopdb-flask', [string] $AppPool = 'shopdbflask', [string] $SiteHost = '', # for CORS_ORIGINS; defaults to this machine # Deliberately NOT a password parameter. Command lines are readable by any # user via Win32_Process and are captured in PowerShell transcripts. # Supply host/user/db here; the password is prompted for. [string] $DbHost = '', [int] $DbPort = 3306, [string] $DbName = 'shopdb_flask', [string] $DbUser = 'shopdb', # Unattended installs only. Path to a file whose FIRST LINE is the DB password. # The installer reads it, overwrites it and deletes it. ACL the file to # SYSTEM + Administrators before writing it. Omit this for an interactive run # and the password is prompted for instead. It is never a parameter value: # command lines are world-readable via Win32_Process and are captured in # PowerShell transcripts and ConsoleHost_history.txt. [string] $DbPasswordFile = '', # The site's plugin set is DECLARED in a profile (ADR-013), not listed here. # The bundle is built lean for that same profile by scripts/build-site.sh, so # a plugin the site did not choose is absent from the backend tree entirely. # That absence is what makes core's `try: from plugins.X.models import ... # except ImportError` guards correct: the import genuinely fails, no # relationship backref is created, and no query joins the missing table. # # Do NOT go back to installing a fixed list against a full tree. That was the # cause of the 500s on /api/assets and /api/dashboard/summary # ("Table 'shopdb_flask.measuringtools' doesn't exist"): the plugin's CODE # shipped regardless, so the import succeeded, SQLAlchemy created the # Asset.measuringtool backref and LEFT OUTER JOINed a table that only exists # if the plugin was installed. [string] $SiteProfile = '', # Comma-separated plugin names chosen at INSTALL time (the wizard's plugin # page). When supplied this REWRITES site-profile.json before apply-profile # runs, so the operator's choice wins over whatever the bundle was built with. # Omit it and the profile shipped in the bundle is used unchanged. [string] $SitePlugins = '', # Subpath deployment (docs/INSTALL-WINDOWS-IIS.md method B). Empty = the app # gets its own IIS site on -SitePort (method A, the default). Set to an alias # such as 'shopdb' and it becomes an IIS Application under -ParentSite, i.e. # http:///shopdb/ - no new DNS record, no port in the URL. # The alias MUST match the one the bundle's subpath SPA was built with; the # installer checks that and refuses if they disagree. [string] $MountAlias = '', [string] $ParentSite = 'Default Web Site', # How this server learns a request's real client IP. # # direct IIS is exposed to clients. Install URL Rewrite from the bundle # and set X-Forwarded-For from REMOTE_ADDR. Overwriting the header # is what stops a client spoofing it. # proxy A reverse proxy (ARR, load balancer) sits in front and already # sets X-Forwarded-For. Leave the rule off - REMOTE_ADDR would be # the proxy, so applying it would DESTROY the real client IP. # # Without one or the other, IIS sends no X-Forwarded-For at all and every # client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard # visitor-location lookup and per-host login rate limiting all break quietly. [ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct', # Required before this installer will alter an installation it did not # create. Everything here is built for a greenfield server: it makes its own # Python, its own venv and its own IIS objects, and its upgrade path assumes # the thing it is upgrading came out of a previous run. # # Pointed at a server that was deployed by hand, the IIS reconciliation would # DELETE the existing application or site as part of switching deployment # method - silently, because removing the artifact of the method you are not # using is correct behaviour when the installer owns both. It is not correct # when someone else built it. So it asks first. [switch] $AdoptExisting, [switch] $WhatIfOnly, # Unattended runs cannot answer a prompt. Choose the failure behaviour up front. [ValidateSet('ask','always','never')] [string] $OnFailure = 'ask' ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' # NOT $env:TEMP. When this runs under the Inno wrapper, TEMP points at Setup's # own extraction directory, which is deleted when Setup exits - so the log the # operator is told to send to support disappears with it, and a failed install # leaves nothing to diagnose. ProgramData persists and is admin-writable. $script:LogDir = Join-Path $env:ProgramData 'ShopDB-Flask\logs' try { if (-not (Test-Path $script:LogDir)) { New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null } } catch { $script:LogDir = $env:TEMP } $script:LogPath = Join-Path $script:LogDir ("shopdb-install-{0}.log" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) $script:Created = New-Object System.Collections.ArrayList # for rollback $script:RebootPending = $false $script:MysqlAlreadyOurs = $false # 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. # 4. Some installers succeed with a NON-ZERO code. 3010 and 1641 both mean # "done, reboot required" - the VC++ redistributable returns 3010 on a # server with a pending file rename, which is an ordinary state on a # freshly patched box. Treating it as failure aborts a correct install. param([string] $Exe, [string[]] $Arguments, [string] $What, [string] $StdinFile = '', [int] $TimeoutSec = 0, [int[]] $OkExit = @(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 ($_ -notmatch '\s' -or $_ -match '^".*"$') { $_ } # PROPERTY=value (msiexec, msbuild): the VALUE is quoted, never the whole # token. "INSTALLDIR=C:\Program Files\..." is rejected outright, whereas # INSTALLDIR="C:\Program Files\..." is what the tool expects. elseif ($_ -match '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { '{0}="{1}"' -f $Matches[1], $Matches[2] } else { '"' + $_ + '"' } } $so = [System.IO.Path]::GetTempFileName() $se = [System.IO.Path]::GetTempFileName() try { # NO -Wait when a timeout is wanted. -Wait blocks inside Start-Process # until the child exits, so by the time the timeout check below ran the # process had ALWAYS exited and the whole block was dead code - every # -TimeoutSec on every MSI call was doing nothing. Start without -Wait # and do the waiting here, where it can be bounded. $common = @{ FilePath = $Exe; ArgumentList = $quoted; PassThru = $true NoNewWindow = $true RedirectStandardOutput = $so; RedirectStandardError = $se } if ($StdinFile) { # Feeding SQL as a file on stdin, not as -e "source ": the mysql # client reads that path as a DATABASE NAME and fails with # ERROR 1049 Unknown database ''. $common['RedirectStandardInput'] = $StdinFile } # -Wait ALWAYS. Dropping it to make -TimeoutSec enforceable left # $p.ExitCode unreadable: a Python install that had actually worked # reported "Python install failed (exit )" with an empty code, because # PowerShell only reliably populates ExitCode on a waited process. # # So the trade is deliberate. Exit codes are load-bearing here - 1639 vs # 1603 vs 3010 is the whole diagnosis - and a bounded wait is not worth # losing them for. -TimeoutSec is therefore ADVISORY: it is recorded in # the log so a hang is identifiable, but the wait is unbounded. $common['Wait'] = $true if ($TimeoutSec -gt 0) { Write-Log (" (expected to finish within {0}s)" -f $TimeoutSec) } $p = Start-Process @common # NOTE: msiexec can deadlock when another Windows Installer transaction # holds the global mutex - two msiexec processes once sat for 40 minutes # with no output and no error. That is why the expected duration is # logged above: an operator seeing no progress well past it should check # for stray msiexec processes (Get-Process msiexec) and end them. $out = @() if (Test-Path $so) { $out += Get-Content $so -ErrorAction SilentlyContinue } if (Test-Path $se) { $out += Get-Content $se -ErrorAction SilentlyContinue } $code = $null try { $code = $p.ExitCode } catch { } if ($null -eq $code) { # Never silently treat "cannot tell" as success. $out | Select-Object -Last 8 | ForEach-Object { Write-Log " $_" 'FAIL' } Fail "$What finished but its exit code could not be read" ` 'Check the log above and the tool''s own log before re-running.' } if ($OkExit -notcontains $code) { $out | Select-Object -Last 8 | ForEach-Object { Write-Log " $_" 'FAIL' } Fail "$What failed (exit $code)" } if ($code -ne 0) { # Accepted, but say so: 3010/1641 mean the server needs a reboot to # finish, and an operator who never sees that wonders later why # something only half works. Write-Log ("$What returned {0} - completed, but this server needs a reboot to finish" -f $code) 'WARN' $script:RebootPending = $true } 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' # Dot-sourced HERE, at script scope, not inside Assert-BundleIntegrity. Dot- # sourcing inside a function loads into that FUNCTION's scope, so every helper # it defines - Get-JsonProperty, Read-BundleLock, Test-BundleLock - disappears # the moment the function returns. Assert-BundleIntegrity itself worked; the # next caller, Get-WheelhousePythonTag, died with "The term 'Get-JsonProperty' # is not recognized" on any run where a venv already existed, which is every # retry after a part-completed install. # # Absent on a stage that runs without the bundle (uninstall), so this is a # guarded load and Assert-BundleIntegrity still fails loudly if it is missing. $BundleLockLib = Join-Path $BundleRoot 'bundle-lock.ps1' if (Test-Path $BundleLockLib) { . $BundleLockLib } # ============================================================================= # 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.' } $lock = Read-BundleLock $lockFile # NO @() here. Test-BundleLock returns ,$problems, which already hands the # array back intact - wrapping it again nests it, and .Count becomes 1 no # matter how many problems there are. That turns a byte-perfect bundle into # "1 problem(s)" on every install. Applying the same guard at both ends was # worse than applying it at neither. $problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock if ($problems.Count -gt 0) { foreach ($p in $problems) { Write-Log " $p" 'FAIL' } Fail ("the bundle does not match bundle-lock.json ({0} problem(s))" -f $problems.Count) @' The payload was changed after this installer was built. Do not install from it. Obtain a bundle whose payload matches its lock, or rebuild one and re-compile. '@ } Write-Log ("bundle payload verified against bundle-lock.json ({0}, {1})" -f ` (Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'OK' } function Get-WheelhousePythonTag { # Which Python the wheelhouse was built for. The lock is authoritative; a # bundle predating it is read from the wheel filenames instead. $lockFile = Join-Path $BundleRoot 'bundle-lock.json' if (Test-Path $lockFile) { $tag = Get-JsonProperty (Get-Content $lockFile -Raw | ConvertFrom-Json) 'pythontag' if ($tag -and ($tag -match '^cp(\d)(\d+)$')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) } } $wheel = Get-ChildItem $WheelDir -Filter '*.whl' -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '-cp(\d)(\d+)-' } | Select-Object -First 1 if ($wheel -and ($wheel.Name -match '-cp(\d)(\d+)-')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) } return '' } function Assert-VenvMatchesWheelhouse { <# An existing venv is REUSED rather than rebuilt, which is right for a repair or an upgrade of an install this made. It is wrong when the venv belongs to a different Python: the wheelhouse is tagged for one minor version, so pip finds no candidate for cffi, cryptography, greenlet or mysql-connector and dies partway through - after Python has already been installed and the app tree already replaced. Fail before any of that, and say which two versions disagree. #> if (-not (Test-Path $Py)) { return } # greenfield: nothing to disagree with $wanted = Get-WheelhousePythonTag if (-not $wanted) { return } # unknowable; the pip failure will have to do $found = '' try { $found = (& $Py -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>$null | Select-Object -First 1) if ($found) { $found = $found.Trim() } } catch { return } if (-not $found -or ($found -eq $wanted)) { return } Fail ("the existing venv is Python {0}, but this bundle's wheelhouse is for {1}" -f $found, $wanted) @' The wheelhouse is locked to one Python minor version. Installing it into a venv built by a different one fails at the first compiled package, halfway through. On a server this installer built: delete APP_ROOT\venv and re-run. The venv is rebuilt from the bundle and holds nothing of yours. On a server someone else built: this is not an upgrade, it is a runtime change. Take a database backup first, and expect to reconcile web.config by hand. '@ } function Get-BundleVersion { # The version being installed, read from the payload itself so it can never # disagree with the code that is about to be copied. $init = Join-Path $AppSource 'shopdb\__init__.py' if (-not (Test-Path $init)) { return '' } $m = Select-String -Path $init -Pattern "^__version__\s*=\s*'([^']+)'" | Select-Object -First 1 if ($m) { return $m.Matches[0].Groups[1].Value } return '' } function Get-InstalledVersion { $f = Join-Path $AppRoot '.installed-version' if (Test-Path $f) { return (Get-Content $f -TotalCount 1).Trim() } # An install predating version stamping still has an app tree. if (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py')) { return 'unknown' } return '' } function Compare-Version { # -1 / 0 / 1, comparing dotted numeric versions. 'unknown' sorts as older so # a stamped bundle over an unstamped install is treated as an upgrade. param([string] $A, [string] $B) if ($A -eq $B) { return 0 } if ($A -eq 'unknown' -or $A -eq '') { return -1 } if ($B -eq 'unknown' -or $B -eq '') { return 1 } $x = @($A -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') }) $y = @($B -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') }) for ($i = 0; $i -lt [Math]::Max($x.Count, $y.Count); $i++) { $xi = if ($i -lt $x.Count) { $x[$i] } else { 0 } $yi = if ($i -lt $y.Count) { $y[$i] } else { 0 } if ($xi -gt $yi) { return 1 } if ($xi -lt $yi) { return -1 } } return 0 } function Find-MysqlTool { param([string] $Name) # mysql.exe or mysqldump.exe # The BUNDLE first, then the install directory, then the local server. # # A site whose MySQL lives on another host has no client installed here at # all, so mysqldump was never found - which meant every upgrade skipped the # pre-upgrade backup, and it did so AFTER stage 2 had stopped the pool and # replaced the application tree. Shipping the client makes the backup work # on a remote-database site, which is the case that needs it most. $bundled = @( (Join-Path $BundleRoot ('mysqlclient\' + $Name)), (Join-Path $AppRoot ('mysqlclient\' + $Name)) ) foreach ($b in $bundled) { if (Test-Path $b) { return $b } } # C:\MySQL84 first: that is where the bundled server installs (space-free, # see $MysqlRoot). The Program Files paths cover a server MySQL put there # by hand or by an older build. $roots = @('C:\MySQL84', 'C:\Program Files\MySQL', 'C:\mysql56\bin', 'C:\Program Files (x86)\MySQL') foreach ($r in $roots) { if (Test-Path $r) { $hit = Get-ChildItem $r -Filter $Name -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if ($hit) { return $hit.FullName } } } return '' } function Get-DbFromEnv { $line = Get-Content $EnvFile -ErrorAction SilentlyContinue | Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1 if ($line -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') { return @{ User = $Matches[1]; Pass = [uri]::UnescapeDataString($Matches[2]) Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] } } return $null } function Test-DatabaseEmpty { <# Is the TARGET DATABASE empty right now? This - not the presence of C:\shopdb-flask - is what decides whether it is safe to force-drop tables. A rebuilt server pointed at a site's existing, populated database has no app directory, so filesystem-based detection called it a "fresh install" and ran prune-schema --yes --force, destroying the rows of every unselected plugin with no backup taken. Returns $true ONLY when we positively confirm zero tables. Anything we cannot determine returns $false, because the failure mode of guessing "empty" is irreversible data loss and the failure mode of guessing "populated" is merely a redundant backup. #> $probe = Join-Path $env:TEMP ("shopdb-dbprobe-{0}.py" -f (Get-Date -Format 'HHmmssfff')) @' import os, sys try: from sqlalchemy import create_engine, inspect url = None for line in open(".env", encoding="utf-8", errors="replace"): if line.startswith("DATABASE_URL="): url = line.split("=", 1)[1].strip() if not url: print("UNKNOWN"); sys.exit(0) print("EMPTY" if len(inspect(create_engine(url)).get_table_names()) == 0 else "POPULATED") except Exception: print("UNKNOWN") '@ | Set-Content -Path $probe -Encoding ASCII try { Push-Location $AppRoot $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $out = & $Py $probe 2>&1 } finally { $ErrorActionPreference = $prev; Pop-Location } $verdict = ($out | Where-Object { $_ -match '^(EMPTY|POPULATED|UNKNOWN)$' } | Select-Object -Last 1) Write-Log "database probe: $verdict" return ($verdict -eq 'EMPTY') } finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue } } function Backup-Database { # Returns the backup path, or '' if it could not be taken. param([string] $Reason = 'pre-upgrade') $db = Get-DbFromEnv if (-not $db) { Write-Log 'cannot read DATABASE_URL; skipping backup' 'WARN'; return '' } $dump = Find-MysqlTool 'mysqldump.exe' if (-not $dump) { Write-Log 'mysqldump not found; skipping backup' 'WARN'; return '' } # A dump contains every row, including the users table and its password # hashes. ProgramData is readable by every user on the box by default, so the # directory is locked to Administrators and SYSTEM the moment it is created. $dir = Join-Path $env:ProgramData 'ShopDB-Flask\backups' if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null Protect-File $dir } $file = Join-Path $dir ("{0}-{1}-{2}.sql" -f $db.Name, $Reason, (Get-Date -Format 'yyyyMMdd-HHmmss')) Write-Log "backing up $($db.Name) before migrating" # --single-transaction keeps it consistent without locking the whole server. # Password goes in the argument array, never interpolated into a log line. $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", '--single-transaction', '--routines', '--triggers', $db.Name) $so = [System.IO.Path]::GetTempFileName() $se = [System.IO.Path]::GetTempFileName() try { # Start-Process -ArgumentList does NOT quote, so a password containing a # space splits into several arguments and the dump fails. Quote anything # with whitespace, exactly as Invoke-Native does. $quoted = $args | ForEach-Object { if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ } } Write-Log (" exec mysqldump.exe ({0} args)" -f $args.Count) $p = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow ` -RedirectStandardOutput $so -RedirectStandardError $se if ($p.ExitCode -ne 0) { Write-Log "mysqldump exited $($p.ExitCode); no usable backup" 'WARN' return '' } # VALIDATE before anything relies on this file. mysqldump can exit 0 and # still leave a truncated dump - a full disk, or a table it could not # read part-way through. A backup that is trusted but unusable is worse # than no backup at all, because migrations then proceed on its strength. if (-not (Test-Path $so)) { Write-Log 'mysqldump produced no file' 'WARN'; return '' } $len = (Get-Item $so).Length if ($len -lt 512) { Write-Log "backup is only $len bytes; refusing to treat it as valid" 'WARN'; return '' } $tail = @(Get-Content $so -Tail 5 -ErrorAction SilentlyContinue) if (-not ($tail -match 'Dump completed')) { Write-Log 'backup lacks the mysqldump completion marker - it is truncated' 'WARN' return '' } Move-Item $so $file -Force $mb = [math]::Round((Get-Item $file).Length / 1MB, 1) Write-Log "backup written and verified complete: $file ($mb MB)" 'OK' return $file } finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue } } function Restore-Database { param([string] $BackupFile) if (-not $BackupFile -or -not (Test-Path $BackupFile)) { Write-Log 'no backup available to restore from' 'FAIL'; return $false } $db = Get-DbFromEnv $mysql = Find-MysqlTool 'mysql.exe' if (-not $db -or -not $mysql) { Write-Log 'cannot restore: client or .env missing' 'FAIL'; return $false } Write-Log "restoring $($db.Name) from $BackupFile" 'WARN' $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", $db.Name) try { # mysqldump emits DROP TABLE IF EXISTS before each CREATE, so replaying # genuinely restores every table the dump contains. Invoke-Native $mysql $args 'database restore' -StdinFile $BackupFile | Out-Null } catch { Write-Log "restore failed: $($_.Exception.Message)" 'FAIL'; return $false } # What the replay CANNOT undo: tables the failed migration created that the # dump knows nothing about. MySQL DDL is not transactional, so those CREATEs # are already committed and no dump-replay removes them. # # We report them rather than dropping them. Dropping would mean deciding, on # a machine holding a site's only copy of its data, that a table is safe to # destroy - and the installer deliberately does not claim that authority. # (Restoring into a side database instead is not available either: the app # user has rights on this database only, not CREATE DATABASE.) $dumped = @() foreach ($line in (Get-Content $BackupFile -ErrorAction SilentlyContinue)) { if ($line -match '^CREATE TABLE `([^`]+)`') { $dumped += $Matches[1] } } $probe = Join-Path $env:TEMP ("shopdb-tables-{0}.py" -f (Get-Date -Format 'HHmmssfff')) @' import sys try: from sqlalchemy import create_engine, inspect url = None for line in open(".env", encoding="utf-8", errors="replace"): if line.startswith("DATABASE_URL="): url = line.split("=", 1)[1].strip() for t in sorted(inspect(create_engine(url)).get_table_names()): print("TABLE " + t) except Exception: pass '@ | Set-Content -Path $probe -Encoding ASCII $current = @() try { Push-Location $AppRoot $prev = $ErrorActionPreference; $ErrorActionPreference = 'Continue' try { $current = @(& $Py $probe 2>&1 | Where-Object { $_ -match '^TABLE ' } | ForEach-Object { $_.Substring(6).Trim() }) } finally { $ErrorActionPreference = $prev; Pop-Location } } finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue } $residue = @($current | Where-Object { $dumped -notcontains $_ }) if ($residue.Count -eq 0) { Write-Log 'data restored from backup; no leftover tables from the failed migration' 'OK' } else { # Say exactly what is true. The previous wording claimed the database was # "restored to its previous state", which is a false assurance when the # schema still carries changes the migration committed before it failed. Write-Log 'data restored from backup, but the schema is NOT identical to before' 'WARN' Write-Log (" {0} table(s) created by the failed migration remain:" -f $residue.Count) 'WARN' $residue | ForEach-Object { Write-Log " $_" 'WARN' } Write-Log ' they hold no data and are harmless to the running application,' 'WARN' Write-Log ' but a DBA should drop them before the next upgrade attempt.' 'WARN' } Write-Log " backup used: $BackupFile" return $true } # ============================================================================= # STAGE 0 - bundled MySQL 8.4 LTS (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.4 LTS' '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.4.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. # OUR service is not "someone else's MySQL". This refused to run when the # only service present was the MySQL84 that a previous stage 0 registered - # so every retry of a part-completed bundled install dead-ended, while the # wizard told the operator that re-running was safe. $existing = @(Get-Service -Name 'MySQL*' -ErrorAction SilentlyContinue) $foreign = @($existing | Where-Object { $_.Name -ne $MysqlService }) if ($foreign.Count -gt 0) { Fail ("MySQL service already present: {0}" -f (($foreign | 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. '@ } $ours = @($existing | Where-Object { $_.Name -eq $MysqlService }) if ($ours.Count -gt 0) { # Ours, from an earlier run. Bring it up if it is down and let the rest of # the stage run: the bootstrap SQL is idempotent (CREATE ... IF NOT EXISTS # plus ALTER USER), so it re-asserts the password and the grants and # leaves .dbpass agreeing with the server. Write-Log "$MysqlService is already registered by a previous run" 'OK' if ($ours[0].Status -ne 'Running') { Write-Log "starting $MysqlService" Start-Service -Name $MysqlService -ErrorAction SilentlyContinue Start-Sleep -Seconds 3 } $script:MysqlAlreadyOurs = $true } $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' } } # The Visual C++ runtime, BEFORE MySQL. MySQL's MSI carries a launch # condition requiring it, and a bare Windows Server does not ship it - # so on a fresh box the MSI aborts in about a second with the generic # exit 1603 and no indication of what was missing. That is what a real # Server 2019 install did. $vcMarker = Join-Path $env:windir 'system32\vcruntime140.dll' if (Test-Path $vcMarker) { Write-Log 'Visual C++ runtime already present' 'OK' } else { $vc = Get-ChildItem (Join-Path $BundleRoot 'vcredist') -Filter '*.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $vc) { Fail 'the Visual C++ runtime is missing and no redistributable is in the bundle' @' MySQL will not install without it - its MSI fails with a bare exit 1603. Add vcredist\VC_redist.x64.exe to the bundle and rebuild, or install the Microsoft Visual C++ 2015-2022 Redistributable (x64) on this server by hand. '@ } Write-Log "installing $($vc.Name) (MySQL requires it)" if (-not $WhatIfOnly) { Invoke-Native $vc.FullName @('/install','/quiet','/norestart') ` 'Visual C++ runtime' -TimeoutSec 600 -OkExit 0,3010,1641 Write-Log 'Visual C++ runtime installed' 'OK' } } # Quiet MSI. INSTALLDIR must have no trailing backslash or the MSI mis-parses it. Write-Log "installing $($msi.Name) (125MB, takes a minute)" # /l*v: without the MSI's own log, exit 1603 says only "something went # wrong" - it names neither the action nor the reason, and it is the most # common MySQL failure code. Costs nothing and turns the next failure # into something readable. $msiLog = Join-Path $script:LogDir ('mysql-msi-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) Write-Log "MSI log: $msiLog" Invoke-Native 'msiexec.exe' @('/i', $msi.FullName, '/quiet', '/norestart', '/l*v', $msiLog, "INSTALLDIR=$MysqlRoot") 'MySQL MSI' -TimeoutSec 900 -OkExit 0,3010 } # 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 # Everything from here to the bootstrap creates the server. Skip it wholesale # when the service is already ours: the datadir check would refuse a # populated directory, --initialize-insecure would refuse it too, and # --install would fail on an existing service name. if ($script:MysqlAlreadyOurs) { Write-Log 'server already exists from a previous run; skipping create' 'OK' } else { # --- data directory ----------------------------------------------------- # --initialize-insecure REFUSES a non-empty datadir, so a previous failed # attempt must be cleared before retrying. if (Test-Path $MysqlDataDir) { $items = Get-ChildItem $MysqlDataDir -Force -ErrorAction SilentlyContinue if ($items) { Fail "$MysqlDataDir already exists and is not empty" @' mysqld --initialize-insecure refuses a non-empty data directory. If this is a failed previous run, remove the directory and re-run stage 0. If it holds real data, STOP: you are about to destroy a database. '@ } } Write-Log 'initializing data directory' Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize' Track 'mysql-datadir' $MysqlDataDir # --- service ------------------------------------------------------------ # Argument ORDER MATTERS: --install BEFORE --defaults-file. Verified # empirically - reversed, mysqld exits 1 and the service is not registered. Write-Log "registering service $MysqlService" Invoke-Native $mysqld @('--install', $MysqlService, "--defaults-file=$MysqlIni") 'MySQL service register' Track 'service' $MysqlService Start-Service -Name $MysqlService $svc = Get-Service -Name $MysqlService if ($svc.Status -ne 'Running') { Fail "service $MysqlService did not start" "Check $MysqlDataDir\mysql-error.log" } Set-Service -Name $MysqlService -StartupType Automatic Write-Log "service $MysqlService running" 'OK' } # end of the create-the-server block # --- bootstrap ---------------------------------------------------------- # --initialize-insecure leaves root with an EMPTY password, so this must run # immediately. Generated, never supplied; root is shown once and not stored. # # On a server this stage already built, root HAS a password, so the # --skip-password connection below cannot authenticate. The database, the # user and the handoff all exist from that run, so there is nothing to do - # skip rather than fail. Without the handoff there is no safe automatic # recovery, so say what to do instead of guessing. if ($script:MysqlAlreadyOurs) { if (Test-Path (Join-Path $AppRoot '.dbpass')) { Write-Log 'database, user and password handoff already exist; skipping bootstrap' 'OK' Write-Log 'stage 0 complete' 'OK' return } Fail 'MySQL is installed by a previous run but its password handoff is gone' @' The bundled server exists and root already has a generated password, so this stage cannot re-create the application user, and .dbpass is not there to reuse. Either point the installer at this server with -DbHost 127.0.0.1 and the password from C:\shopdb-flask\.env, or remove the MySQL84 service and its data directory to install cleanly. '@ } $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'; -- ::1 as well. On current Windows 'localhost' resolves to the IPv6 loopback -- FIRST, so an operator who types localhost instead of 127.0.0.1 arrives as -- '$DbUser'@'::1' - an account that did not exist, and MySQL answers "Access -- denied" naming a host the operator never typed. CREATE USER IF NOT EXISTS '$DbUser'@'::1' IDENTIFIED BY '$appPass'; -- ALTER after CREATE, because CREATE USER IF NOT EXISTS is a NO-OP on an -- existing user and does NOT change its password. This stage generates a fresh -- password every run and overwrites .dbpass with it, so on any re-run after a -- partial failure the file held a password the server had never been told - -- "Access denied" while the operator was looking straight at the right file. -- ALTER makes the stored password and the handoff agree, every time. ALTER USER '$DbUser'@'localhost' IDENTIFIED BY '$appPass'; ALTER USER '$DbUser'@'127.0.0.1' IDENTIFIED BY '$appPass'; ALTER USER '$DbUser'@'::1' IDENTIFIED BY '$appPass'; GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'localhost'; GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'127.0.0.1'; GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'::1'; ALTER USER 'root'@'localhost' IDENTIFIED BY '$rootPass'; FLUSH PRIVILEGES; "@ Set-Content -Path $sqlPath -Value $sql -Encoding ASCII try { $mysqlExe = Join-Path $MysqlRoot 'bin\mysql.exe' # -e "source " fails with ERROR 1049 Unknown database '': the # client reads the path as a database name. Redirect stdin instead. That # also stops PowerShell eating the backticks MySQL uses to quote identifiers. Invoke-Native $mysqlExe @('-uroot', '--skip-password', '--protocol=TCP', "--port=$DbPort") 'MySQL bootstrap' -StdinFile $sqlPath } finally { # Unconditional: this file holds both passwords. Set-Content -Path $sqlPath -Value ('0' * 512) -Encoding ASCII -ErrorAction SilentlyContinue Remove-Item $sqlPath -Force -ErrorAction SilentlyContinue } # Hand the app password to stage 2 through an ACL'd FILE, never stdout - # stdout lands in the log that FR-161 invites operators to send to support. $handoff = Join-Path $AppRoot '.dbpass' New-Item -ItemType File -Force -Path $handoff | Out-Null Protect-File $handoff Set-Content -Path $handoff -Value $appPass -Encoding UTF8 Write-Log "database $DbName and user $DbUser created" 'OK' Write-Log "app password written to $handoff (stage 2 consumes and deletes it)" # NOT Write-Host. Under the wizard this process runs hidden, so nobody ever # saw this - and every line of stdout is forwarded into the setup log that # operators are told to send to support. The generated root password was # therefore invisible to the person who needed it and permanently recorded # for everyone who did not. # # Write it to a file only Administrators and SYSTEM can read, and let the # wizard tell the operator where it is. $rootFile = Join-Path $env:ProgramData 'ShopDB-Flask\mysql-root-password.txt' $rootDir = Split-Path $rootFile -Parent if (-not (Test-Path $rootDir)) { New-Item -ItemType Directory -Path $rootDir -Force | Out-Null } New-Item -ItemType File -Force -Path $rootFile | Out-Null Protect-File $rootFile Set-Content -Path $rootFile -Value @( 'MySQL root password for this server, generated during installation.', 'It is not recorded anywhere else and cannot be recovered.', 'Move it into your password manager, then delete this file.', '', $rootPass ) -Encoding UTF8 Write-Log "MySQL root password written to $rootFile (Administrators and SYSTEM only)" 'OK' Write-Log 'MYSQLROOTFILE:' + $rootFile Write-Log 'stage 0 complete' 'OK' } # ============================================================================= # STAGE 2 - runtime: Python, venv, wheels, .env # ============================================================================= function Invoke-Stage2 { Write-Log 'STAGE 2: runtime and configuration' 'STEP' if (-not (Test-Path $BundleRoot)) { Fail "bundle not found: $BundleRoot" } if (-not (Test-Path $WheelDir)) { Fail "wheelhouse not found: $WheelDir" 'Rebuild the bundle with deploy\windows\installer\build-installer.ps1 (or .sh); it refuses to produce a bundle without one.' } if (-not (Test-Path $AppSource)) { Fail "application payload not found: $AppSource" } Assert-BundleIntegrity Assert-VenvMatchesWheelhouse # Captured HERE, before the stamp below is written. Stage 4's guard against # touching an installation this did not create tested the stamp directly, and # stage 2 always runs first in a '-Stage all' install - so the stamp it had # just written made every server look like one of ours and the guard never # fired on the case it exists for. $script:PreexistingInstallStamp = Test-Path (Join-Path $AppRoot '.installed-version') # --- Python, all users -------------------------------------------------- # A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity # cannot read. That produces a 500 with an empty HttpPlatform log. $pyInstaller = Get-ChildItem (Join-Path $BundleRoot 'python') -Filter '*.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 # SPACE-FREE deliberately. `TargetDir=C:\Program Files\Python314` splits at # the space when passed to the bootstrapper and installs into C:\Program\. # Same class of failure as MySQL's --defaults-file. Never put a space in a # path this installer controls. $pyTarget = 'C:\Python314' if (Test-Path (Join-Path $pyTarget 'python.exe')) { Write-Log "Python already present at $pyTarget" 'OK' } elseif ($null -eq $pyInstaller) { Fail "no Python installer in $BundleRoot\python" } else { Write-Log "installing Python (all users) from $($pyInstaller.Name)" if (-not $WhatIfOnly) { # /norestart matters: without it the bootstrapper is free to reboot # the server on its own, mid-install, with the wizard still running. Invoke-Native $pyInstaller.FullName @( '/quiet','/norestart','InstallAllUsers=1','PrependPath=0','Include_launcher=1', 'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget" ) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641 Track 'python' $pyTarget } } $sysPy = Join-Path $pyTarget 'python.exe' if (-not $WhatIfOnly -and -not (Test-Path $sysPy)) { Fail "Python not at $sysPy after install" } # --- application payload ------------------------------------------------ if (-not (Test-Path $AppRoot)) { New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null Track 'dir' $AppRoot } # --- upgrade detection --------------------------------------------------- $script:BundleVersion = Get-BundleVersion $script:InstalledVersion = Get-InstalledVersion $script:IsUpgrade = ($script:InstalledVersion -ne '') $script:DbWasEmpty = $false # established for real in stage 3 if ($script:IsUpgrade) { Write-Log ("upgrading: installed {0} -> bundle {1}" -f $script:InstalledVersion, $script:BundleVersion) 'STEP' $cmp = Compare-Version $script:BundleVersion $script:InstalledVersion if ($cmp -lt 0) { # Migrations only go forwards. Running older code against a newer # schema fails in ways that are hard to unpick, so refuse outright. Fail ("this bundle ({0}) is OLDER than what is installed ({1})" -f $script:BundleVersion, $script:InstalledVersion) @' Downgrading is not supported: the database schema has already been migrated forwards, and older code cannot read it. Install a build at least as new as what is on this server, or restore a backup taken before the upgrade. '@ } if ($cmp -eq 0) { Write-Log 'same version already installed; re-running is harmless (repair)' 'WARN' } } else { Write-Log ("installing version {0}" -f $script:BundleVersion) } # UPGRADE PATH: on an existing install the app pool holds python.exe and the # site files open, so copying over them fails with "file in use" partway # through - leaving a half-replaced application. Stop the pool first and note # that we did, so stage 4 knows to start it again. $script:PoolWasStopped = $false if (-not $WhatIfOnly) { try { Import-Module WebAdministration -ErrorAction Stop if ((Test-Path "IIS:\AppPools\$AppPool") -and ((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started')) { Write-Log "stopping app pool $AppPool so its files can be replaced" 'WARN' Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue # The worker does not exit instantly; copying too soon still fails. for ($i = 0; $i -lt 15; $i++) { Start-Sleep -Seconds 1 if (-not (Get-Process w3wp -ErrorAction SilentlyContinue)) { break } } $script:PoolWasStopped = $true } } catch { } } # Plugin directories present on the server but NOT in the new bundle are # removed BEFORE the copy. The copy merges rather than replaces, so a plugin # dropped from a site's profile kept its code on disk forever - which defeats # the point of a lean build (ADR-013) and, worse, leaves the core's # `try: from plugins.X.models import ...` guards succeeding for a plugin this # site no longer has, so SQLAlchemy still builds the relationship and queries # still join a table that may have been pruned. if (-not $WhatIfOnly) { $newPlugins = @() $srcPlugins = Join-Path $AppSource 'plugins' if (Test-Path $srcPlugins) { $newPlugins = @(Get-ChildItem $srcPlugins -Directory | Select-Object -ExpandProperty Name) } $livePlugins = Join-Path $AppRoot 'plugins' if ($newPlugins.Count -gt 0 -and (Test-Path $livePlugins)) { foreach ($dir in (Get-ChildItem $livePlugins -Directory)) { if ($newPlugins -notcontains $dir.Name) { Write-Log "removing '$($dir.Name)': not in this build" 'WARN' # Uninstall FIRST, while the code is still importable - the # registry entry outlives the directory otherwise, and the app # then fails to load a plugin it still believes is installed. try { if (Test-Path $Flask) { Push-Location $AppRoot $env:FLASK_APP = 'shopdb' & $Flask plugin uninstall $dir.Name 2>&1 | ForEach-Object { Write-Log " $_" } Pop-Location } } catch { Write-Log " could not deregister $($dir.Name): $($_.Exception.Message)" 'WARN' } Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue } } } } Write-Log "copying application payload to $AppRoot" if (-not $WhatIfOnly) { Copy-Item (Join-Path $AppSource '*') -Destination $AppRoot -Recurse -Force } # Pick the SPA build that matches the deployment method. Vite compiles the # base path in, so the wrong one loads a page that then fetches its assets # from the wrong prefix and renders nothing. if (-not $WhatIfOnly) { $rootDist = Join-Path $AppRoot 'frontend\dist' $subDist = Join-Path $AppRoot 'frontend\dist-subpath' if ($MountAlias) { if (-not (Test-Path $subDist)) { Fail 'this bundle has no subpath frontend build' @' The bundle was built without the / SPA, so a subpath install would serve a page that cannot load its own assets. Rebuild with scripts/build-site.sh (which produces both), or install without -MountAlias. '@ } # The alias is fixed at BUILD time; refuse rather than mis-serve. $built = '' $aliasFile = Join-Path $subDist '.alias' if (Test-Path $aliasFile) { $built = (Get-Content $aliasFile -TotalCount 1).Trim() } if ($built -and ($built -ne $MountAlias.Trim('/'))) { Fail ("this bundle's subpath build is for '/{0}', not '/{1}'" -f $built, $MountAlias.Trim('/')) ` 'Rebuild the bundle with SUBPATH_ALIAS set to the alias you want.' } Remove-Item $rootDist -Recurse -Force -ErrorAction SilentlyContinue Move-Item $subDist $rootDist -Force Write-Log ("using the /{0} frontend build" -f $MountAlias.Trim('/')) 'OK' } else { # Not needed for a root install; leave nothing confusing behind. Remove-Item $subDist -Recurse -Force -ErrorAction SilentlyContinue } } if (-not $WhatIfOnly -and $script:BundleVersion) { # Stamped AFTER the copy: if the copy dies, the stamp still reflects what # is actually on disk rather than what we hoped to put there. Set-Content -Path (Join-Path $AppRoot '.installed-version') ` -Value $script:BundleVersion -Encoding ASCII } # Keep the lock with the install so `shopdb-admin.ps1 verify` can say WHICH # bundle this server was built from, months later and offline. if (-not $WhatIfOnly) { $bundledLock = Join-Path $BundleRoot 'bundle-lock.json' if (Test-Path $bundledLock) { Copy-Item $bundledLock $AppRoot -Force } # The MySQL client stays on the server. The bundle is extracted to a temp # directory and deleted when Setup exits, so a copy that lived only there # would leave `shopdb-admin backup` with nothing to run on a site whose # database is on another host. $bundledClient = Join-Path $BundleRoot 'mysqlclient' if (Test-Path $bundledClient) { $target = Join-Path $AppRoot 'mysqlclient' if (-not (Test-Path $target)) { New-Item -ItemType Directory -Path $target -Force | Out-Null } Copy-Item (Join-Path $bundledClient '*') $target -Recurse -Force Write-Log 'MySQL client staged for backups' 'OK' } } foreach ($sub in @('logs','instance')) { $p = Join-Path $AppRoot $sub if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } } # --- venv --------------------------------------------------------------- if (Test-Path $Py) { Write-Log 'venv already exists' 'OK' } else { Write-Log 'creating venv' if (-not $WhatIfOnly) { Invoke-Native $sysPy @('-m','venv',(Join-Path $AppRoot 'venv')) 'venv creation' Track 'dir' (Join-Path $AppRoot 'venv') } } # --- offline dependency install ---------------------------------------- # PIP_NO_INDEX makes a network attempt impossible rather than merely # unnecessary. On an air-gapped box pip otherwise hangs on DNS timeouts. # # --require-hashes puts pip in hash-checking mode: every wheel must match a # sha256 listed in requirements.txt or the install ABORTS. Without it pip # took whatever file in the wheelhouse satisfied the version pin, so a # swapped or hand-dropped wheel installed silently. The flag is passed # explicitly rather than relying on pip inferring it from the presence of # hashes, so shipping an unhashed requirements.txt fails loudly here instead # of quietly dropping the check. # # --only-binary=:all: refuses to fall back to building from an sdist. On a # server with no compiler and no network that fallback cannot succeed; it # just turns a clear "no wheel for this platform" into a confusing build # error deep in someone's setup.py. Write-Log 'installing dependencies from the wheelhouse (offline, hash-checked)' if (-not $WhatIfOnly) { $env:PIP_NO_INDEX = '1' $env:PIP_FIND_LINKS = $WheelDir $env:PIP_DISABLE_PIP_VERSION_CHECK = '1' try { Invoke-Native $Py @('-m','pip','install','--no-index',"--find-links=$WheelDir", '--require-hashes','--only-binary=:all:', '-r',(Join-Path $AppRoot 'requirements.txt')) 'dependency install' } finally { Remove-Item Env:\PIP_NO_INDEX, Env:\PIP_FIND_LINKS -ErrorAction SilentlyContinue } # waitress and tzdata are load-bearing on Windows; verify rather than assume. foreach ($mod in @('waitress','tzdata')) { & $Py -c "import $mod" 2>$null if ($LASTEXITCODE -ne 0) { Fail "$mod did not install" "The wheelhouse is incomplete. $mod is required (waitress is the WSGI server; tzdata provides the IANA timezone database Windows lacks)." } } Write-Log 'waitress and tzdata present' 'OK' } # --- .env --------------------------------------------------------------- # The stage-0 handoff is checked BEFORE the .env branch, not inside its else. # It used to sit in the else, so it was consulted only when .env was absent - # and on a part-finished install .env DOES exist, holding whatever password # stage 2 last wrote. If stage 0 has since regenerated the credential, .env # is stale, .dbpass is correct, and the installer preferred the stale one: # "Access denied", with the right password sitting unread on disk. # # .dbpass only exists between stage 0 and stage 5 - stage 5 shreds it once # the install is proven - so its presence means "this install is unfinished # and THIS is the current password". Applied only when .env points at the # local server, because the handoff describes the bundled database and must # never redirect a site whose database lives elsewhere. $handoff = Join-Path $AppRoot '.dbpass' if (-not $DbPasswordFile -and (Test-Path $handoff)) { $envIsLocal = $true if (Test-Path $EnvFile) { $envIsLocal = $false $urlLine = Get-Content $EnvFile -ErrorAction SilentlyContinue | Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1 if ($urlLine -and ($urlLine -match '@(127\.0\.0\.1|localhost|\[::1\]):')) { $envIsLocal = $true } } if ($envIsLocal) { $DbPasswordFile = $handoff if (-not $DbHost) { $DbHost = '127.0.0.1' } Write-Log 'using the stage-0 password handoff (an unfinished install; .env may be stale)' } } 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 } # The stage-0 handoff was already applied above, for both branches. if (-not $DbHost) { Fail 'no database host supplied and no existing .env' @' Pass -DbHost . The password is prompted for, never passed on the command line. For a bundled-database install run stage 0 first: it creates the database and leaves an ACL'd handoff file, after which no password is needed. '@ } # Two ways in, both keeping the password off the command line (which any # user can read via Win32_Process, and which lands in transcripts and # ConsoleHost_history.txt). if ($DbPasswordFile) { # Unattended path. The caller writes an ACL'd file; we consume and # shred it so it does not outlive the install. if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" } $dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8) if ($dbPass) { $dbPass = $dbPass.Trim() } Write-Log "read DB password from $DbPasswordFile" if ($DbPasswordFile -eq $handoff) { # Do NOT shred the stage-0 handoff here. It holds the ONLY copy of a # GENERATED password: .env is the sole other copy, and rollback # deletes .env. Shredding both on a failed install leaves an # unrecoverable box - the database exists, the app user exists, and # nobody alive knows the password. Stage 5 shreds it once the # install is proven working. Write-Log 'keeping the stage-0 handoff until stage 5 confirms the install' } else { try { # Operator-supplied file: they know the password, so consume it # immediately. Overwrite before delete - a plain Remove-Item # leaves the bytes on disk. Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force Remove-Item $DbPasswordFile -Force Write-Log 'password file overwritten and removed' } catch { Write-Log "could not remove $DbPasswordFile - delete it manually" 'WARN' } } } else { # Interactive path. Read-Host -AsSecureString reads the CONSOLE, not # stdin: piping into it does not work, it just blocks forever with no # error and no timeout. So refuse up front rather than hang a remote or # scheduled run. if (-not [Environment]::UserInteractive) { Fail 'no interactive console for the password prompt' @' This session has no console, so the password prompt would hang indefinitely (Read-Host -AsSecureString reads the console directly and ignores piped stdin). Re-run with -DbPasswordFile pointing at a file whose first line is the password. The installer reads it, overwrites it and deletes it. '@ } $sec = Read-Host -Prompt ("Password for MySQL user '{0}' on {1}" -f $DbUser, $DbHost) -AsSecureString $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) try { $dbPass = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } if (-not $dbPass) { Fail 'no password entered' } # Percent-encode: passwords routinely contain characters that break a URL. $escUser = [uri]::EscapeDataString($DbUser) $escPass = [uri]::EscapeDataString($dbPass) $DatabaseUrl = "mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4" # CORS_ORIGINS must be the ORIGIN PEOPLE ACTUALLY USE, and an explicit one - # a wildcard makes the app refuse to boot. Under a subpath install the app # is reached on the PARENT site's port (usually 80), so pinning the # stand-alone port here would make every browser request fail CORS on a # correctly installed server. Origin is scheme+host+port only - never the path. if ($MountAlias) { $parentPort = 80 try { Import-Module WebAdministration -ErrorAction SilentlyContinue $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } } catch { } if ($parentPort -eq 80) { $origin = "http://{0}" -f $SiteHost } else { $origin = "http://{0}:{1}" -f $SiteHost, $parentPort } } else { $origin = "http://{0}:{1}" -f $SiteHost, $SitePort } $content = @( 'FLASK_ENV=production' ("SECRET_KEY={0}" -f (New-Secret)) ("JWT_SECRET_KEY={0}" -f (New-Secret)) ("DATABASE_URL={0}" -f $DatabaseUrl) ("CORS_ORIGINS={0}" -f $origin) 'LOG_LEVEL=INFO' ) if ($MountAlias) { # wsgi.py reads MOUNT_PATH and moves the prefix from PATH_INFO to # SCRIPT_NAME, so Flask both routes and GENERATES urls under it. # Added to the ARRAY, before the join - appending to the joined string # glues it onto the previous line ("LOG_LEVEL=INFOMOUNT_PATH=/shopdb"). $content += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/')) } $content = $content -join "`r`n" Write-Log "writing .env (CORS_ORIGINS=$origin)" if (-not $WhatIfOnly) { Set-Content -Path $EnvFile -Value $content -Encoding ASCII Track 'file' $EnvFile # .env holds the database password in plaintext BY DESIGN - the app # reads it at boot. Filesystem permissions are the only control. # Administrators and SYSTEM only; the app-pool identity gets Read in stage 4. Protect-File $EnvFile # Clear the plaintext copy from this session's memory. $dbPass = $null; $content = $null [GC]::Collect() } } Write-Log 'stage 2 complete' 'OK' } # ============================================================================= # STAGE 3 - data: schema, seeds, plugins # ============================================================================= function Invoke-Stage3 { Write-Log 'STAGE 3: schema, seed data, plugins' 'STEP' if (-not (Test-Path $Flask)) { Fail 'venv not found; run stage 2 first' } # Resolve the site profile. An explicit -SiteProfile wins; otherwise use the # one the bundle was built from, which scripts/build-site.sh stages alongside # the app tree so the profile and the lean tree can never drift apart. $ProfilePath = $SiteProfile if (-not $ProfilePath) { $ProfilePath = Join-Path $AppRoot 'site-profile.json' } if (-not (Test-Path $ProfilePath)) { Fail "site profile not found: $ProfilePath" @' The installer no longer carries a hardcoded plugin list - the site's set is declared in a profile and the bundle is built lean for it. Either pass -SiteProfile , or rebuild the bundle with scripts/build-site.sh , which stages the profile into the app tree. See deploy/site-profile.example.json for the format. '@ } # Report the chosen set up front: it decides both what gets installed and what # prune-schema drops, so it belongs in the log FR-161 sends to support. try { $declared = (Get-Content $ProfilePath -Raw | ConvertFrom-Json).plugins -join ', ' Write-Log "site profile: $ProfilePath" Write-Log "declared plugins: $declared" } catch { Fail "site profile is not valid JSON: $ProfilePath" } $env:FLASK_APP = 'shopdb' Push-Location $AppRoot try { # The app ships its own preflight. Use it rather than reimplementing: # it checks Python, required env, DB connectivity and the MySQL 5.6 flags. Write-Log 'running flask db-utils preflight' # TRAP (documented in Invoke-Native, and hit again here): the app logs # plugin initialisation to STDERR even on success. With 2>&1 and # $ErrorActionPreference='Stop' PowerShell turns that into a terminating # NativeCommandError, so a healthy preflight aborts the install with a # plugin INFO line as the "error". Judge by the OUTPUT, not by stderr. $prevEAP = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $pf = & $Flask db-utils preflight 2>&1 } finally { $ErrorActionPreference = $prevEAP } $pf | ForEach-Object { Write-Log " $_" } # Match the preflight's own status column (" FAIL "), not any line # that merely contains the word - plugin log lines can too. $pfFailed = @($pf | Where-Object { $_ -match '^\s*FAIL\s' }).Count -gt 0 if ($LASTEXITCODE -ne 0 -or $pfFailed) { Fail 'application preflight reported failures' 'Fix the FAIL items above, then re-run stage 3.' } if ($WhatIfOnly) { Write-Log 'WhatIf: skipping migrations and seeds' 'WARN'; return } # Decide fresh-vs-existing from the DATABASE, and do it HERE rather than # relying on a variable set in stage 2 - stage 3 is documented as # separately runnable ("re-run stage 3"), and under Set-StrictMode # reading an unset $script:IsUpgrade is a terminating error. $script:DbWasEmpty = Test-DatabaseEmpty if (-not $script:DbWasEmpty) { $script:IsUpgrade = $true Write-Log 'existing data found in the target database - treating this as an upgrade' 'WARN' } # Back up BEFORE Alembic touches anything. Skipped only when the database # is provably empty; on anything else it is the only thing standing # between a failed migration and a half-migrated database. $script:PreUpgradeBackup = '' if (-not $script:DbWasEmpty) { $script:PreUpgradeBackup = Backup-Database 'pre-upgrade' if (-not $script:PreUpgradeBackup) { Fail 'could not back up the database before upgrading' @' Refusing to migrate without a backup. Fix mysqldump access (or take a backup manually with shopdb-admin.ps1 backup) and run this again. '@ } } # Alembic owns the schema. Nothing else may create tables. Write-Log 'flask db upgrade' try { Invoke-Native $Flask @('db','upgrade') 'schema upgrade' } catch { if ($script:PreUpgradeBackup) { Write-Log 'migration failed - putting the database back' 'FAIL' if (Restore-Database $script:PreUpgradeBackup) { Fail 'the upgrade failed; your data was restored from the pre-upgrade backup' @' Your DATA is intact. The schema may still carry tables the failed migration created before it stopped - the log lists any of them, and they are empty. The application files on disk are the NEW version, so re-install the PREVIOUS build to get a matching pair, then send the install log to support. '@ } } throw } foreach ($seed in @('reference-data','permissions','settings')) { Write-Log "flask seed $seed" Invoke-Native $Flask @('seed',$seed) "seed $seed" } # Plugin registry starts empty on a fresh box. apply-profile resolves the # declared set's hard-dependency closure and installs + enables it in # dependency order, idempotently (ADR-013). It fails loudly if the profile # names a plugin that is not on disk - which on a lean bundle correctly # catches a profile/bundle mismatch. # An install-time selection overrides the bundled profile. Written here, # after the payload copy, so it survives stage 2 replacing the app tree. if ($SitePlugins) { $chosen = @($SitePlugins -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $onDisk = @(Get-ChildItem (Join-Path $AppRoot 'plugins') -Directory -ErrorAction SilentlyContinue | Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } | Select-Object -ExpandProperty Name) $missing = @($chosen | Where-Object { $onDisk -notcontains $_ }) if ($missing.Count -gt 0) { Fail ("these plugins are not in this bundle: {0}" -f ($missing -join ', ')) ` 'Rebuild the installer from a profile that includes them.' } $profileJson = @{ site = $SiteHost; plugins = $chosen; locked = @() } | ConvertTo-Json -Depth 3 Set-Content -Path $ProfilePath -Value $profileJson -Encoding ASCII Write-Log ("site profile rewritten from the installer selection: {0}" -f ($chosen -join ', ')) 'OK' } Write-Log "flask plugin apply-profile $ProfilePath" Invoke-Native $Flask @('plugin','apply-profile',$ProfilePath) 'apply site profile' # Per-plugin Alembic chains (ADR-008) run after the plugins are registered. Write-Log 'flask plugin upgrade-all' Invoke-Native $Flask @('plugin','upgrade-all') 'plugin migrations' # ADR-014 Phase 2. The shared core Alembic baseline creates EVERY plugin's # tables regardless of this site's choice, so a lean site still carries the # omitted plugins' (empty) tables. Drop them, leaving core + chosen. # # Both flags are required and they mean different things: --yes executes # (the command is a dry-run preview otherwise), --force permits dropping a # table that holds rows. --force is needed even on a fresh install because # core migrations seed a few plugin reference tables (e.g. 7d05 inserts # default access protocols). Safe here and ONLY here: this runs during # initial provisioning, before any site data exists. # NOTE: ADR-014's prose says lean provisioning "uses --force"; that alone # would silently do nothing but print a preview. It needs both. # --force drops tables that CONTAIN ROWS. That is required on a fresh # install, because core migrations seed a few plugin reference tables # (7d05 inserts default access protocols) and prune would otherwise # refuse. On an UPGRADE the same flag would silently destroy a site's # data for any plugin that is not installed - so never force there. # Without --force, prune-schema refuses non-empty tables and says so, # which is the behaviour we want when data exists. if (-not $script:DbWasEmpty) { Write-Log 'flask plugin prune-schema --yes (no --force: refuses to drop tables holding data)' # -OkExit 0,1: refusing is the DESIGNED outcome here, and the CLI # signals it with SystemExit(1). Without this, Invoke-Native failed # the stage the moment prune declined to drop a table holding rows - # and the reporting below, which exists precisely to explain that, # could never run. Core migration 7d05 seeds access protocols owned # by the computers plugin, so any profile omitting computers hit it # on every retry. $pruneOut = Invoke-Native $Flask @('plugin','prune-schema','--yes') ` 'prune not-installed plugin tables' -OkExit 0,1 $skipped = @($pruneOut | Where-Object { $_ -match 'REFUSING|rows$|not empty' }) 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' } elseif ($pruneOut -match 'not initialized') { # The other reason this CLI exits 1, and it is a genuine failure. Fail 'the plugin manager did not initialise' 'Check the application log; the app could not start.' } } 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 -OkExit 0,3010 if (-not (Test-Path $hphDll)) { Fail 'the HttpPlatformHandler MSI reported success but the module is missing' ` 'An installer exit code of 0 does not prove it did what was asked.' } Write-Log 'HttpPlatformHandler installed' 'OK' } } # --- client IP ----------------------------------------------------------- # URL Rewrite is what lets the XFF rule below exist. Install it BEFORE # writing a web.config that references , or IIS answers every # request with 500.19 until someone works out which module is missing. if ($ClientIpSource -eq 'direct') { $rewriteDll = Join-Path $env:windir 'system32\inetsrv\rewrite.dll' if (Test-Path $rewriteDll) { Write-Log 'URL Rewrite already installed' 'OK' } else { $rwMsi = Get-ChildItem (Join-Path $BundleRoot 'urlrewrite') -Filter '*.msi' -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $rwMsi) { Fail 'URL Rewrite is not installed and no MSI is in the bundle' @' -ClientIpSource direct needs the IIS URL Rewrite module to set X-Forwarded-For. Add urlrewrite\rewrite_amd64.msi to the bundle and rebuild, or re-run with -ClientIpSource proxy if something in front of IIS already sets the header. Installing it later by hand also works: this server has no network, so the MSI has to come from the bundle either way. '@ } Write-Log "installing $($rwMsi.Name)" if (-not $WhatIfOnly) { Invoke-Native 'msiexec.exe' @('/i', $rwMsi.FullName, '/quiet', '/norestart') ` 'URL Rewrite MSI' -TimeoutSec 600 -OkExit 0,3010 if (-not (Test-Path $rewriteDll)) { Fail 'the URL Rewrite MSI reported success but the module is missing' ` 'An installer exit code of 0 does not prove it did what was asked.' } Write-Log 'URL Rewrite installed' 'OK' } } } else { Write-Log 'client IP comes from an upstream proxy; not installing URL Rewrite' 'OK' } # web.config ships in the repo; its paths need correcting and the client-IP # rule needs enabling or leaving off. # # An EXISTING web.config is never overwritten. It is the one file on the # server that legitimately carries hand-edits - a nested application for # /installers, bindings, a proxy-specific rule - and overwriting it reverts # them silently. On a server where the XFF rule was enabled by hand, that # alone would turn the GE-Enforce IP allowlist off without a word in any log. $srcCfg = Join-Path $AppRoot 'deploy\windows\web.config' $dstCfg = Join-Path $AppRoot 'web.config' if (Test-Path $dstCfg) { Write-Log 'web.config already exists; leaving it alone' 'OK' $existing = Get-Content $dstCfg -Raw $hasRule = $existing -match '' -and $existing -notmatch 'SHOPDB-CLIENTIP-BEGIN' if ($ClientIpSource -eq 'direct' -and -not $hasRule) { Write-Log 'this web.config does NOT set X-Forwarded-For; client IPs will read as 127.0.0.1' 'WARN' Write-Log ' enable the SHOPDB-CLIENTIP block by hand, or delete web.config and re-run stage 4' 'WARN' } if ($ClientIpSource -eq 'proxy' -and $hasRule) { Write-Log 'this web.config OVERWRITES X-Forwarded-For, which discards the real client IP behind a proxy' 'WARN' Write-Log ' remove the block by hand if a proxy in front already sets the header' 'WARN' } } elseif (Test-Path $srcCfg) { Write-Log "installing web.config (client IP: $ClientIpSource)" if (-not $WhatIfOnly) { $cfg = Get-Content $srcCfg -Raw $cfg = $cfg.Replace('C:\shopdb-flask', $AppRoot) if ($ClientIpSource -eq 'direct') { # Uncomment by deleting the two marker lines. A string operation # on explicit markers, not a regex over the surrounding prose: # editing that prose must never silently disable the rule. if ($cfg -notmatch 'SHOPDB-CLIENTIP-BEGIN') { Fail 'the shipped web.config has no SHOPDB-CLIENTIP block' ` 'It was edited. Restore deploy\windows\web.config from the repository.' } $cfg = $cfg.Replace('', '') Write-Log 'X-Forwarded-For rule enabled' 'OK' } Set-Content -Path $dstCfg -Value $cfg -Encoding UTF8 Track 'file' $dstCfg } } else { Fail "web.config not found at $srcCfg or $dstCfg" } # App pool: No Managed Code. This is a Python app; the CLR must not load. if (Test-Path "IIS:\AppPools\$AppPool") { Write-Log "app pool $AppPool already exists" 'OK' } else { Write-Log "creating app pool $AppPool" if (-not $WhatIfOnly) { New-WebAppPool -Name $AppPool | Out-Null Set-ItemProperty "IIS:\AppPools\$AppPool" -Name managedRuntimeVersion -Value '' Track 'apppool' $AppPool } } # ACLs. RX on the tree; Modify on logs\ and instance\ or plugin toggles and # uploads fail with "internal error". Read on .env for the pool identity. if (-not $WhatIfOnly) { $ident = "IIS AppPool\$AppPool" Write-Log "granting $ident access" Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot' foreach ($sub in @('logs','instance')) { Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub" } Invoke-Native 'icacls.exe' @($EnvFile,'/grant',"${ident}:(R)") 'ACL on .env' } # Handler sections are locked server-wide by default. Without unlocking, # IIS returns 500.19 the moment it reads the app's web.config. # # `appcmd unlock config /section:X` unlocks it for EVERY site on the machine, # which on a shared server hands every other application the ability to # define its own handlers. Delegate to our own location instead, and fall # back to the server-wide unlock only if that is refused - a 500.19 the # operator cannot diagnose is worse than a wider delegation, but it should be # the second choice, not the first. $ourLocation = if ($MountAlias) { "$ParentSite/$($MountAlias.Trim('/'))" } else { $SiteName } foreach ($section in @('system.webServer/handlers','system.webServer/httpPlatform')) { if ($WhatIfOnly) { Write-Log "would unlock $section for $ourLocation"; continue } Write-Log "unlocking $section for $ourLocation" # EAP must drop to Continue around these. appcmd writes to stderr on # ordinary refusals, and '2>&1' with $ErrorActionPreference = 'Stop' # turns that into a TERMINATING NativeCommandError - so the exit-code # test and the server-wide fallback below, which is the entire point of # the block, were unreachable, and stage 4 aborted after Python, the # venv, the schema and the ACLs had all been changed. $prevEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $scoped = & $appcmd unlock config "$ourLocation" /section:$section 2>&1 $scoped | ForEach-Object { Write-Log " $_" } if ($LASTEXITCODE -ne 0) { Write-Log " scoped unlock refused; unlocking $section server-wide" 'WARN' & $appcmd unlock config /section:$section 2>&1 | ForEach-Object { Write-Log " $_" } if ($LASTEXITCODE -ne 0) { Write-Log " server-wide unlock also refused - IIS may return 500.19" 'WARN' } } } finally { $ErrorActionPreference = $prevEap } } # Switching method must not leave BOTH deployments in place: two entry points # to one directory, one of them serving an SPA built for the wrong base path. # Remove whichever artifact belongs to the method we are NOT using. # # GUARDED. These removals are correct when this installer owns both artifacts # - it is how switching method avoids leaving two entry points to one # directory, one of them serving an SPA built for the wrong base path. They # are NOT correct against an installation someone else built: a wrong # -MountAlias would delete a live mount with no prompt and no error, and the # first sign would be the site 404ing. # # 'Ours' means there was a version stamp BEFORE this run started - only this # installer writes one. Stage 2 records that observation; testing the file # here instead would read the stamp stage 2 has already written. $weBuiltThis = if ($null -ne $script:PreexistingInstallStamp) { $script:PreexistingInstallStamp } else { # Stage 4 run on its own, so stage 2 has not written anything this run. Test-Path (Join-Path $AppRoot '.installed-version') } if (-not $WhatIfOnly) { $doomed = @() if ($MountAlias) { if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { $doomed += @{ Kind = 'site'; Name = $SiteName; Site = '' } } } else { foreach ($site in (Get-Website)) { foreach ($app in (Get-WebApplication -Site $site.Name -ErrorAction SilentlyContinue)) { if ($app.PhysicalPath -eq $AppRoot) { $doomed += @{ Kind = 'app'; Name = $app.Path.Trim('/'); Site = $site.Name } } } } } if ($doomed.Count -gt 0 -and -not $weBuiltThis -and -not $AdoptExisting) { foreach ($d in $doomed) { if ($d.Kind -eq 'site') { Write-Log (" would remove IIS site '{0}'" -f $d.Name) 'FAIL' } else { Write-Log (" would remove application '/{0}' under '{1}'" -f $d.Name, $d.Site) 'FAIL' } } Fail 'this server has an IIS deployment that this installer did not create' @' Publishing the way you asked means removing what is listed above, and there is no version stamp to show this installer put it there. Deleting a mount somebody else configured is not something to do without being asked. If the removal is what you want, re-run with -AdoptExisting. If not, re-run matching how the application is published today: pass -MountAlias to keep it as an application under an existing site, or omit -MountAlias to keep it as a site of its own. '@ } foreach ($d in $doomed) { if ($d.Kind -eq 'site') { Write-Log "removing the previous stand-alone site '$($d.Name)' (now published as a subpath)" 'WARN' Remove-Website -Name $d.Name -ErrorAction SilentlyContinue Get-NetFirewallRule -DisplayName ("{0} {1}" -f $d.Name, $SitePort) -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue } else { Write-Log "removing the previous subpath application '/$($d.Name)' (now its own site)" 'WARN' Remove-WebApplication -Site $d.Site -Name $d.Name -ErrorAction SilentlyContinue } } } if ($MountAlias) { # METHOD B: an IIS Application under an existing site, reached at # http://// - no new DNS name and no port in the URL. # The app's own handler mappings apply only inside the Application, so the # parent site's handlers (classic ASP, static files) are untouched. $alias = $MountAlias.Trim('/') if (-not (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue)) { Fail "parent site '$ParentSite' does not exist" ` 'Pass -ParentSite with the name of an existing IIS site, or install without -MountAlias.' } $existing = Get-WebApplication -Site $ParentSite -Name $alias -ErrorAction SilentlyContinue if ($existing) { Write-Log "application /$alias already exists under '$ParentSite'" 'OK' } else { Write-Log "creating application /$alias under '$ParentSite'" if (-not $WhatIfOnly) { New-WebApplication -Site $ParentSite -Name $alias -PhysicalPath $AppRoot ` -ApplicationPool $AppPool | Out-Null Track 'webapp' ("{0}/{1}" -f $ParentSite, $alias) } } # No firewall rule: the parent site's port is already reachable, which is # the whole point of this method. Write-Log "no firewall change needed - served on '$ParentSite' existing bindings" 'OK' } else { # METHOD A: its own site on $SitePort. if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { Write-Log "site $SiteName already exists" 'OK' } else { Write-Log "creating site $SiteName on port $SitePort" if (-not $WhatIfOnly) { New-Website -Name $SiteName -Port $SitePort -PhysicalPath $AppRoot -ApplicationPool $AppPool | Out-Null Track 'site' $SiteName } } $ruleName = "$SiteName $SitePort" if (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue) { Write-Log 'firewall rule already exists' 'OK' } else { Write-Log "adding firewall rule for TCP $SitePort" if (-not $WhatIfOnly) { New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP ` -LocalPort $SitePort -Action Allow | Out-Null Track 'firewall' $ruleName } } if (-not $WhatIfOnly) { Start-Website -Name $SiteName -ErrorAction SilentlyContinue } } # Stage 2 stops the pool on an upgrade so its files can be replaced. Nothing # else starts it again, so the smoke test would fail against a stopped site # and report it as a broken install. try { Import-Module WebAdministration -ErrorAction SilentlyContinue if (Test-Path "IIS:\AppPools\$AppPool") { if ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started') { Write-Log "starting app pool $AppPool" Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 } } $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue if ($site -and $site.State -ne 'Started') { Write-Log "starting site $SiteName" Start-Website -Name $SiteName -ErrorAction SilentlyContinue } } catch { Write-Log "could not start the site: $($_.Exception.Message)" 'WARN' } Write-Log 'stage 4 complete' 'OK' } # ============================================================================= # STAGE 5 - verify and hand off # ============================================================================= function Invoke-Stage5 { Write-Log 'STAGE 5: smoke test' 'STEP' if ($WhatIfOnly) { Write-Log 'WhatIf: skipping smoke test' 'WARN'; return } # Stages can be run one at a time, so do not assume stage 4 just ran. try { Import-Module WebAdministration -ErrorAction SilentlyContinue if ((Test-Path "IIS:\AppPools\$AppPool") -and ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started')) { Write-Log "app pool was stopped; starting it before the smoke test" 'WARN' Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue Start-Sleep -Seconds 3 } } catch { } # FR-150: test the host operators will actually use, not just the loopback. # localhost alone passes even when the app is unreachable by name and when # CORS_ORIGINS names a host that does not resolve - so the SPA would fail its # first XHR on a site the installer just called healthy. $hostName = if ($SiteHost) { $SiteHost } else { $env:COMPUTERNAME } # Under method B the app answers on the PARENT site's port, at the alias - # requesting http://localhost:8090/ would test a site that does not exist. if ($MountAlias) { $alias = $MountAlias.Trim('/') # Prefer an http binding, but fall back to https. A parent site published # ONLY over https - which is normal, and which the operator did nothing # wrong to have - left this defaulting to port 80, requesting a URL that # answers nothing, and failing a working install with a red dialog. $parentScheme = 'http' $parentPort = 0 try { $bindings = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection $b = $bindings | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 if (-not $b) { $b = $bindings | Where-Object { $_.protocol -eq 'https' } | Select-Object -First 1 if ($b) { $parentScheme = 'https' } } if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } } catch { } if ($parentPort -eq 0) { $parentPort = if ($parentScheme -eq 'https') { 443 } else { 80 } } $targets = @("{0}://localhost:{1}/{2}/" -f $parentScheme, $parentPort, $alias) if ($hostName -and $hostName -ne 'localhost') { $targets += "{0}://{1}:{2}/{3}/" -f $parentScheme, $hostName, $parentPort, $alias } # An https parent almost certainly has a certificate for its real name, # not for 'localhost', and a certificate complaint is not an application # fault. Accept any certificate for the duration of the smoke test only. if ($parentScheme -eq 'https') { Write-Log 'parent site is https-only; certificate validation is skipped for this check' 'WARN' try { Add-Type -TypeDefinition @' using System.Net; using System.Security.Cryptography.X509Certificates; public class ShopdbSmokeTestCertPolicy : ICertificatePolicy { public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; } } '@ -ErrorAction SilentlyContinue [System.Net.ServicePointManager]::CertificatePolicy = New-Object ShopdbSmokeTestCertPolicy } catch { } } } else { $targets = @("http://localhost:$SitePort/") if ($hostName -and $hostName -ne 'localhost') { $targets += "http://{0}:{1}/" -f $hostName, $SitePort } } # First request boots the app and connects to MySQL: the runbook records ~15s. Write-Log "requesting $($targets[0]) (first request takes ~15s while the app boots)" $ok = $false for ($i = 1; $i -le 12; $i++) { try { $r = Invoke-WebRequest -Uri $targets[0] -UseBasicParsing -TimeoutSec 20 if ($r.StatusCode -eq 200) { $ok = $true; break } } catch { Start-Sleep -Seconds 5 } } if (-not $ok) { Fail "site did not return 200 at $($targets[0])" ` "Check $AppRoot\logs. Empty HttpPlatform log usually means the app-pool identity cannot read $AppRoot or run the venv, or .env is missing/invalid." } Write-Log 'site responded 200 on localhost' 'OK' # The app is up by now, so the configured-host check needs no boot retry. A # failure here is a name-resolution or firewall problem, not a broken install, # so warn rather than Fail: rolling back a working install would be worse. foreach ($t in $targets[1..($targets.Count - 1)]) { try { $r = Invoke-WebRequest -Uri $t -UseBasicParsing -TimeoutSec 20 if ($r.StatusCode -eq 200) { Write-Log "site responded 200 on $t" 'OK' } else { Write-Log "unexpected status $($r.StatusCode) at $t" 'WARN' } } catch { Write-Log "site did NOT respond at $t - CORS_ORIGINS names this host, so the SPA will fail its first request from other machines" 'WARN' Write-Log " check DNS/hosts for '$hostName' and the firewall rule on TCP $SitePort" 'WARN' } } # Is the client-IP rule actually in effect? It is the control that makes # remote_addr trustworthy: without it IIS forwards whatever X-Forwarded-For # the CALLER sent, waitress trusts it because it arrives from loopback, and # the GE-Enforce IP allowlist becomes bypassable from anywhere on the network. # It does not fail closed. Verified here rather than assumed, because the # failure is silent and looks exactly like a working server. if ($ClientIpSource -eq 'direct') { $liveCfg = Join-Path $AppRoot 'web.config' $cfgText = if (Test-Path $liveCfg) { Get-Content $liveCfg -Raw } else { '' } $ruleLive = ($cfgText -match '') -and ($cfgText -notmatch 'SHOPDB-CLIENTIP-BEGIN') $moduleThere = Test-Path (Join-Path $env:windir 'system32\inetsrv\rewrite.dll') if ($ruleLive -and $moduleThere) { Write-Log 'client-IP rule is live; remote_addr is trustworthy' 'OK' } else { Write-Log 'CLIENT-IP RULE IS NOT IN EFFECT' 'WARN' if (-not $moduleThere) { Write-Log ' URL Rewrite module is not installed' 'WARN' } if (-not $ruleLive) { Write-Log ' web.config does not have an active block' 'WARN' } Write-Log ' every client will be recorded as 127.0.0.1, and the GE-Enforce IP' 'WARN' Write-Log ' allowlist is BYPASSABLE - a caller can send its own X-Forwarded-For' 'WARN' Write-Log ' re-run with -ClientIpSource direct, or use token authentication' 'WARN' } } # The self-hosted API documentation. It is the thing an operator - or the # assistant an operator is asking - reaches for on a server with no internet, # and it is served from a file that has to have been staged into the build. # A missing file makes /api/docs render an empty page, which nobody notices # until it is needed. Warn, never fail: the application itself is fine. $base = $targets[0].TrimEnd('/') try { $d = Invoke-WebRequest -Uri "$base/api/docs/openapi.json" -UseBasicParsing -TimeoutSec 20 if ($d.StatusCode -eq 200) { Write-Log 'API documentation is being served at /api/docs' 'OK' } else { Write-Log "/api/docs/openapi.json returned $($d.StatusCode)" 'WARN' } } catch { Write-Log '/api/docs is NOT working - docs/openapi.json was not staged into this build' 'WARN' Write-Log ' the site runs fine; offline API reference and LLM assistance will not' 'WARN' } # The install is proven working, so the stage-0 handoff is no longer the only # copy of the generated password (.env has it and the app is running on it). # Safe to shred now, and only now - see the note in stage 2. $handoff = Join-Path $AppRoot '.dbpass' if (Test-Path $handoff) { try { Set-Content -Path $handoff -Value ('0' * 256) -Encoding UTF8 -Force Remove-Item $handoff -Force Write-Log 'stage-0 password handoff shredded (install verified)' 'OK' } catch { Write-Log "could not remove $handoff - delete it manually" 'WARN' } } Write-Host '' Write-Host ' ShopDB-Flask is installed.' -ForegroundColor Green Write-Host '' # FR-151: send the operator to /login, not to /. The site root renders the # anonymous dashboard; the first-run gate lives on the login route, so an # operator landing on / sees an empty dashboard and no way to discover setup. if ($MountAlias) { Write-Host (" Open: http://{0}/{1}/login" -f $hostName, $MountAlias.Trim('/')) } else { Write-Host (" Open: http://{0}:{1}/login" -f $hostName, $SitePort) } Write-Host '' Write-Host ' With no user in the database that page offers to create the first' Write-Host ' administrator, then runs the setup wizard (site details, features,' Write-Host ' floor map, common vendors).' Write-Host '' Write-Host ' Headless alternative:' Write-Host (" cd {0}" -f $AppRoot) Write-Host ' venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example' Write-Host ' (the password is generated and printed once)' Write-Host '' Write-Host (" Install log: {0}" -f $script:LogPath) Write-Host '' } # ============================================================================= # Rollback # ============================================================================= function Invoke-Rollback { Write-Log 'rolling back what this run created' 'WARN' # Reverse order. Never touches an external database, and never removes a # data directory without explicit confirmation. for ($i = $script:Created.Count - 1; $i -ge 0; $i--) { $item = $script:Created[$i] try { switch ($item.Kind) { 'site' { Remove-Website -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed site $($item.Id)" } 'webapp' { $parts = $item.Id -split '/', 2 Remove-WebApplication -Site $parts[0] -Name $parts[1] -ErrorAction SilentlyContinue Write-Log " removed application /$($parts[1]) under '$($parts[0])'" } 'apppool' { Remove-WebAppPool -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed app pool $($item.Id)" } 'firewall' { Remove-NetFirewallRule -DisplayName $item.Id -ErrorAction SilentlyContinue; Write-Log " removed firewall rule" } 'file' { # Overwrite before deleting, same as uninstall does. A rolled # back install left .env - which holds the database password # in plaintext by design - recoverable on disk. try { if ((Split-Path $item.Id -Leaf) -eq '.env') { Set-Content -Path $item.Id -Value ('0' * 512) -Encoding UTF8 -Force } } catch { } Remove-Item $item.Id -Force -ErrorAction SilentlyContinue Write-Log " removed $($item.Id)" } 'dir' { Write-Log " left directory $($item.Id) in place (remove manually if wanted)" 'WARN' } 'python' { Write-Log " left Python install in place at $($item.Id)" 'WARN' } 'service' { # Stop and deregister a service THIS run created. Safe because # stage 0 refuses to run when any MySQL service already exists. Stop-Service -Name $item.Id -Force -ErrorAction SilentlyContinue & sc.exe delete $item.Id | Out-Null Write-Log " removed service $($item.Id)" } 'mysql-files' { Write-Log " left MySQL binaries/config at $($item.Id) (remove manually if wanted)" 'WARN' } # FR-162a: never delete a database directory automatically. Even on # a failed run the operator may have put data in it, and a wrong # guess here is unrecoverable. 'mysql-datadir' { Write-Log " LEFT MySQL data directory $($item.Id) IN PLACE" 'WARN' Write-Log ' delete it manually only if you are certain it holds no data' 'WARN' } } } catch { Write-Log " rollback of $($item.Kind) $($item.Id) failed: $($_.Exception.Message)" 'WARN' } } } # ============================================================================= # Uninstall # ============================================================================= # Deliberately conservative. It removes what the installer creates and NEVER # touches data without a typed confirmation: no dropped databases, no deleted # data directory, no uninstalled MySQL that might serve another application. function Invoke-Uninstall { Write-Log 'UNINSTALL' 'STEP' Write-Host '' Write-Host ' This removes the ShopDB-Flask IIS site, app pool, firewall rule and' -ForegroundColor Yellow Write-Host (" the application directory {0}." -f $AppRoot) -ForegroundColor Yellow Write-Host ' It does NOT drop any database and does NOT uninstall MySQL.' -ForegroundColor Yellow Write-Host '' if ($OnFailure -ne 'never') { $answer = Read-Host 'Type UNINSTALL to proceed' if ($answer -ne 'UNINSTALL') { Write-Log 'aborted by operator'; return } } Import-Module WebAdministration -ErrorAction SilentlyContinue # A SUBPATH install has no site of its own - it is an Application under an # existing one. Removing only the site left the application in place, pointed # at a directory this is about to delete, so the parent site (at West # Jefferson, the live classic ASP) served 503 on that path forever and # Add/Remove Programs reported success. # # The alias is whatever MOUNT_PATH says, which is the same value wsgi.py # mounts on, so it cannot disagree with how the app was actually published. $mount = '' $envFile = Join-Path $AppRoot '.env' if (Test-Path $envFile) { $line = Get-Content $envFile -ErrorAction SilentlyContinue | Where-Object { $_ -like 'MOUNT_PATH=*' } | Select-Object -First 1 if ($line) { $mount = $line.Substring('MOUNT_PATH='.Length).Trim().Trim('/') } } if ($mount) { $removed = $false foreach ($site in (Get-Website -ErrorAction SilentlyContinue)) { $app = Get-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue if ($app) { Remove-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue Write-Log "removed application /$mount from site '$($site.Name)'" 'OK' $removed = $true } } if (-not $removed) { Write-Log "application /$mount not present" } } if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { Remove-Website -Name $SiteName -ErrorAction SilentlyContinue Write-Log "removed site $SiteName" 'OK' } else { Write-Log "site $SiteName not present" } if (Test-Path "IIS:\AppPools\$AppPool") { # Stop first: a running worker holds the app directory open and the # subsequent Remove-Item then fails with "file in use". Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue Start-Sleep -Seconds 3 Remove-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue Write-Log "removed app pool $AppPool" 'OK' } else { Write-Log "app pool $AppPool not present" } # Stage 4 creates the rule as "$SiteName $SitePort". This looked for the # literal "ShopDB-Flask 8090", which is not the same string as the default # "shopdb-flask 8090" and matches nothing at all on a non-default port, so # the rule outlived the uninstall. Build the name the same way stage 4 does, # and sweep any rule left by a differently-ported install of the same site. $rule = "$SiteName $SitePort" if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) { Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue Write-Log "removed firewall rule '$rule'" 'OK' } else { Write-Log "firewall rule '$rule' not present" } Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "$SiteName *" -and $_.DisplayName -ne $rule } | ForEach-Object { Remove-NetFirewallRule -DisplayName $_.DisplayName -ErrorAction SilentlyContinue Write-Log "removed leftover firewall rule '$($_.DisplayName)'" 'OK' } if (Test-Path $AppRoot) { # .env holds the database password in plaintext by design (the app reads it # at boot), so shred it rather than just unlinking. $envPath = Join-Path $AppRoot '.env' if (Test-Path $envPath) { Set-Content -Path $envPath -Value ('0' * 1024) -ErrorAction SilentlyContinue Write-Log 'overwrote .env before deletion (it holds the DB password)' } try { Remove-Item $AppRoot -Recurse -Force -ErrorAction Stop Write-Log "removed $AppRoot" 'OK' } catch { Write-Log "could not fully remove $AppRoot : $($_.Exception.Message)" 'WARN' Write-Log ' a running worker process usually holds it; try again after iisreset' 'WARN' } } else { Write-Log "$AppRoot not present" } Write-Host '' Write-Host ' Uninstalled.' -ForegroundColor Green Write-Host '' Write-Host (" The database '{0}' was NOT dropped." -f $DbName) Write-Host ' MySQL was NOT uninstalled or modified.' Write-Host ' Remove either manually if this box is being decommissioned.' Write-Host '' } # ============================================================================= # Main # ============================================================================= Write-Log "ShopDB-Flask installer, stage=$Stage, approot=$AppRoot, bundle=$BundleRoot" 'STEP' if ($WhatIfOnly) { Write-Log 'WhatIfOnly: no changes will be made' 'WARN' } try { switch ($Stage) { '0' { Invoke-Stage0 } '1' { & (Join-Path $PSScriptRoot 'shopdb-preflight.ps1') -SitePort $SitePort -AppRoot $AppRoot } '2' { Invoke-Stage2 } '3' { Invoke-Stage3 } '4' { Invoke-Stage4 } '5' { Invoke-Stage5 } # Stage 0 is NOT in 'all' on purpose: most sites already run MySQL, and # installing a second server on top of an existing one is destructive. 'all' { Invoke-Stage2; Invoke-Stage3; Invoke-Stage4; Invoke-Stage5 } 'uninstall' { Invoke-Uninstall } } Write-Log "done. log: $script:LogPath" 'OK' exit 0 } catch { Write-Log $_.Exception.Message 'FAIL' if ($script:Created.Count -gt 0) { $doRollback = $false if ($OnFailure -eq 'always') { $doRollback = $true } elseif ($OnFailure -eq 'ask') { Write-Host '' $answer = Read-Host 'Roll back what this run created? (yes/no)' $doRollback = ($answer -eq 'yes') } if ($doRollback) { Invoke-Rollback } else { Write-Log 'rollback declined; partial install left in place' 'WARN' } } Write-Log "log: $script:LogPath" 'FAIL' exit 1 }