Close the remaining installer review findings
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s

Eleven findings, grouped by the root cause each belongs to.

Wizard input reaching a command line unchecked (ShopDBFlask.iss). Port fields
were spliced in bare and arrive as [int] parameters, so a blank or mistyped
port shifted every argument after it; both port fields are now validated as
1-65535 digits. A path ending in a backslash, which is what a drive root looks
like, ended its argument with \" and CommandLineToArgvW read that as an escaped
quote, so paths are now quoted through a helper that doubles the trailing
backslash. A drive root is refused outright as well: uninstall deletes the
application directory recursively, so installing to D:\ would have wiped the
drive on removal. The password handoff was written with SaveStringToFile, which
writes an AnsiString, and read back as UTF-8, so a correct non-ASCII password
was reported as wrong; it now goes out as UTF-8 without a BOM.

Launching without checking the result. Plugin deregistration invoked "flask
plugin uninstall" without --yes, and the command carries a click
confirmation_option that aborts with exit 1 when nothing can answer the prompt,
so it could never once have succeeded; the bare 2>&1 under EAP Stop then turned
that into a terminating error which the catch downgraded to a warning while the
plugin directory was deleted regardless. It now passes --yes, brackets the
error preference, restores the location in a finally, and keeps the code on
disk unless deregistration actually succeeded. MarkShortcutRunAs had four
quotes where it needed three, which kept the whole command inside one Pascal
literal so LnkPath was never interpolated and no shortcut ever got the
elevation flag; its exit code is now logged too.

Comparing IIS physical paths as raw strings. IIS stores the path as typed, so
it may carry environment variables or a trailing backslash. A Test-SamePath
helper now normalises both sides. That closes a real hazard in uninstall, which
matched applications on alias alone and would remove an unrelated application
of the same name under another site, unattended, since -OnFailure never
suppresses the confirmation.

Accepting existing IIS state without reconciling it. "Site already exists" took
the site however it was, so re-running with a different port left the old
binding while CORS_ORIGINS, the firewall rule and the smoke test all used the
new one, failing a working server. It now refuses with both ports named rather
than silently re-binding, and refuses a site of that name serving a different
directory.

Preflight rows drawn past the panel. The failures loop had no cap at all and
the warnings loop capped at 6, a number unrelated to the panel, which holds
about three rows. The cap is now measured from the panel height, applies to
both loops, and the footer counts what was actually left out instead of
inferring it.

Also: a failed upgrade now says the application pool is still stopped and how
to start it, rather than only "part-configured", since stage 2 stops a pool
that was serving. It is deliberately not restarted automatically, because after
a stage 3 failure the deployed code and the schema may disagree. shopdb-admin
Restart-App starts a stopped pool or site instead of recycling, which is a
no-op on a stopped pool and then reported the application as unresponsive. A
dead Write-Log line that parsed as three arguments is gone, and a preflight
warning no longer tells the operator to add a directory to a compiled exe.
This commit is contained in:
cproudlock
2026-08-04 21:16:46 -04:00
parent ce521e84a5
commit 1c04ff28b9
4 changed files with 251 additions and 24 deletions

View File

@@ -301,6 +301,22 @@ function New-Secret {
return [Convert]::ToBase64String($b).Replace('+','-').Replace('/','_').TrimEnd('=')
}
function Resolve-IisPath {
# IIS stores physicalPath as it was typed, so it can carry %SystemDrive%-style
# variables and a trailing backslash. A raw string compare therefore misses
# paths that ARE ours - on a hand-built deployment that means stage 4 fails to
# recognise its own application, and the uninstaller fails to recognise what
# it may remove.
param([string] $Path)
if (-not $Path) { return '' }
return [System.Environment]::ExpandEnvironmentVariables($Path).TrimEnd('\').ToLowerInvariant()
}
function Test-SamePath {
param([string] $A, [string] $B)
return (Resolve-IisPath $A) -eq (Resolve-IisPath $B)
}
# Paths derived once.
$Py = Join-Path $AppRoot 'venv\Scripts\python.exe'
$Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
@@ -960,7 +976,6 @@ FLUSH PRIVILEGES;
$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'
}
@@ -1084,16 +1099,42 @@ what is on this server, or restore a backup taken before the upgrade.
# 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.
$deregistered = $false
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
try {
$env:FLASK_APP = 'shopdb'
# --yes is REQUIRED: the command carries a click
# confirmation_option, which aborts with exit 1
# when there is no console to answer the prompt.
# Without it this deregistration could never once
# have succeeded.
#
# EAP bracket for the reason documented on
# Invoke-Native: '2>&1' under EAP 'Stop' turns any
# stderr line into a terminating error, which the
# catch below then downgraded to a WARN while the
# code was deleted regardless.
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& $Flask plugin uninstall $dir.Name --yes 2>&1 |
ForEach-Object { Write-Log " $_" }
$deregistered = ($LASTEXITCODE -eq 0)
} finally { $ErrorActionPreference = $prevEap }
} finally { Pop-Location }
}
} catch { Write-Log " could not deregister $($dir.Name): $($_.Exception.Message)" 'WARN' }
Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
# Delete the code ONLY once the registry entry is really gone.
# Deleting it anyway left the application loading a plugin
# whose directory no longer existed - the exact failure the
# uninstall-first ordering above exists to avoid.
if ($deregistered) {
Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
} else {
Write-Log " left $($dir.Name) on disk: deregistration did not succeed" 'WARN'
}
}
}
}
@@ -1832,7 +1873,7 @@ has to come from the bundle either way.
} else {
foreach ($site in (Get-Website)) {
foreach ($app in (Get-WebApplication -Site $site.Name -ErrorAction SilentlyContinue)) {
if ($app.PhysicalPath -eq $AppRoot) {
if (Test-SamePath $app.PhysicalPath $AppRoot) {
$doomed += @{ Kind = 'app'; Name = $app.Path.Trim('/'); Site = $site.Name }
}
}
@@ -1898,6 +1939,36 @@ If not, re-run matching how the application is published today: pass
# METHOD A: its own site on $SitePort.
if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) {
Write-Log "site $SiteName already exists" 'OK'
# RECONCILE, do not just accept it. "Already exists" used to take the
# site however it was, so re-running with a different port left the
# binding alone while everything downstream - CORS_ORIGINS, the
# firewall rule, the shortcut, the stage 5 smoke test - used the NEW
# port. A working server was then reported as a failed install.
#
# Refuse rather than silently re-bind: moving where people reach the
# site is not something to do because a field defaulted.
if (-not $WhatIfOnly) {
$existingPorts = @()
$existingPath = ''
try {
$siteNow = Get-Website -Name $SiteName
$existingPath = [string] $siteNow.physicalPath
foreach ($b in $siteNow.bindings.Collection) {
if ($b.protocol -eq 'http' -and ($b.bindingInformation -match '^[^:]*:(\d+):')) {
$existingPorts += [int] $Matches[1]
}
}
} catch { }
if (($existingPorts.Count -gt 0) -and ($existingPorts -notcontains [int] $SitePort)) {
Fail ("site '{0}' is bound to port {1}, but this run was told to use {2}" -f `
$SiteName, ($existingPorts -join ', '), $SitePort) `
'Re-run and enter the port the site already uses, or change the binding in IIS Manager first.'
}
if ($existingPath -and (-not (Test-SamePath $existingPath $AppRoot))) {
Fail ("site '{0}' serves {1}, not {2}" -f $SiteName, $existingPath, $AppRoot) `
'Something else owns that site name. Install into the directory it serves, or use a different site name.'
}
}
} else {
Write-Log "creating site $SiteName on port $SitePort"
if (-not $WhatIfOnly) {
@@ -2340,11 +2411,19 @@ function Invoke-Uninstall {
$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 $app) { continue }
# OWNERSHIP CHECK. Matching on the alias alone removed ANY application
# on the server that happened to share the name - a second instance,
# or an unrelated /shopdb under a different site - and -OnFailure
# never suppresses the typed confirmation, so it happened unattended.
if (-not (Test-SamePath $app.PhysicalPath $AppRoot)) {
Write-Log ("left /{0} under '{1}' alone: it serves {2}, not {3}" -f `
$mount, $site.Name, $app.PhysicalPath, $AppRoot) 'WARN'
continue
}
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" }
}
@@ -2441,6 +2520,20 @@ catch {
if ($doRollback) { Invoke-Rollback }
else { Write-Log 'rollback declined; partial install left in place' 'WARN' }
}
# Stage 2 stops the pool on an upgrade so files can be replaced. If the run
# then failed, a site that was SERVING before this started is now off, and
# saying only "part-configured" left the operator to discover that from users.
# Not restarted automatically: after a failure in or past stage 3 the deployed
# code and the schema may no longer agree, and starting it would put a broken
# application back in front of people. Say it plainly instead.
$poolStopped = $false
try { $poolStopped = [bool] $script:PoolWasStopped } catch { }
if ($poolStopped) {
Write-Log "the application pool '$AppPool' was stopped for this upgrade and is STILL STOPPED" 'FAIL'
Write-Log ' the site was serving before this run and is now offline' 'FAIL'
Write-Log " once the cause above is fixed, re-run this installer, or start it by hand with:" 'FAIL'
Write-Log " Start-WebAppPool -Name '$AppPool'" 'FAIL'
}
Write-Log "log: $script:LogPath" 'FAIL'
exit 1
}