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