feat(installer): refuse to damage an installation it did not create
Two guards for a server deployed by hand, which the West Jefferson production box is. An existing venv is reused, which is right for a repair or an upgrade of an install this made, and wrong when the venv belongs to a different Python. The wheelhouse is tagged for one minor version, so pip finds no candidate for the compiled packages and dies partway through - after Python has been installed and the application tree replaced. The two versions are now compared up front and the run stops with both numbers and what to do about it. Switching deployment method removes the other method's IIS artifact. That is correct when this installer owns both and dangerous when it does not: a wrong -MountAlias would call Remove-WebApplication on a live mount with no prompt and no error, and the first sign would be the site returning 404. It now refuses unless a version stamp shows this installer made the install, or -AdoptExisting is passed, and the refusal lists exactly what it would have removed.
This commit is contained in:
@@ -115,6 +115,30 @@ Verifying a live server, months later and offline:
|
|||||||
shopdb-admin.ps1 verify
|
shopdb-admin.ps1 verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Servers this installer did not build
|
||||||
|
|
||||||
|
It is built for greenfield: its own Python, its own venv, its own IIS objects.
|
||||||
|
Its upgrade path assumes the thing being upgraded came out of a previous run.
|
||||||
|
Two guards keep it from damaging a server that was deployed by hand.
|
||||||
|
|
||||||
|
**Python minor version.** An existing venv is reused, which is right for a repair
|
||||||
|
or an upgrade. It is wrong when the venv belongs to a different Python - the
|
||||||
|
wheelhouse is tagged for one minor version, so pip would die at the first
|
||||||
|
compiled package, *after* Python was installed and the app tree replaced. The
|
||||||
|
installer compares the two up front and stops with both version numbers.
|
||||||
|
|
||||||
|
**IIS objects.** Switching deployment method removes the other method's artifact,
|
||||||
|
which is correct when the installer owns both and dangerous when it does not: a
|
||||||
|
wrong `-MountAlias` would delete a live mount with no prompt. It now refuses
|
||||||
|
unless there is a version stamp proving it made the install, or you pass
|
||||||
|
`-AdoptExisting`. The refusal lists exactly what it would have removed.
|
||||||
|
|
||||||
|
An existing `web.config` is never overwritten in either case.
|
||||||
|
|
||||||
|
For the West Jefferson production server specifically, this is a **migration, not
|
||||||
|
an upgrade** - prod runs Python 3.13 against a hand-built deployment, so it needs
|
||||||
|
a deliberate window, a database backup, and web.config reconciled by hand.
|
||||||
|
|
||||||
## Client IP addresses
|
## Client IP addresses
|
||||||
|
|
||||||
IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects
|
IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects
|
||||||
|
|||||||
@@ -121,6 +121,17 @@ param(
|
|||||||
# client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard
|
# 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.
|
# visitor-location lookup and per-host login rate limiting all break quietly.
|
||||||
[ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct',
|
[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,
|
[switch] $WhatIfOnly,
|
||||||
# Unattended runs cannot answer a prompt. Choose the failure behaviour up front.
|
# Unattended runs cannot answer a prompt. Choose the failure behaviour up front.
|
||||||
[ValidateSet('ask','always','never')] [string] $OnFailure = 'ask'
|
[ValidateSet('ask','always','never')] [string] $OnFailure = 'ask'
|
||||||
@@ -294,6 +305,54 @@ Obtain a bundle whose payload matches its lock, or rebuild one and re-compile.
|
|||||||
(Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'OK'
|
(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 {
|
function Get-BundleVersion {
|
||||||
# The version being installed, read from the payload itself so it can never
|
# The version being installed, read from the payload itself so it can never
|
||||||
# disagree with the code that is about to be copied.
|
# disagree with the code that is about to be copied.
|
||||||
@@ -703,6 +762,7 @@ function Invoke-Stage2 {
|
|||||||
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 $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" }
|
if (-not (Test-Path $AppSource)) { Fail "application payload not found: $AppSource" }
|
||||||
Assert-BundleIntegrity
|
Assert-BundleIntegrity
|
||||||
|
Assert-VenvMatchesWheelhouse
|
||||||
|
|
||||||
# --- Python, all users --------------------------------------------------
|
# --- Python, all users --------------------------------------------------
|
||||||
# A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity
|
# A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity
|
||||||
@@ -1425,26 +1485,60 @@ has to come from the bundle either way.
|
|||||||
# Switching method must not leave BOTH deployments in place: two entry points
|
# 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.
|
# 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.
|
# 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) {
|
if (-not $WhatIfOnly) {
|
||||||
|
$doomed = @()
|
||||||
if ($MountAlias) {
|
if ($MountAlias) {
|
||||||
if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) {
|
if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) {
|
||||||
Write-Log "removing the previous stand-alone site '$SiteName' (now published as a subpath)" 'WARN'
|
$doomed += @{ Kind = 'site'; Name = $SiteName; Site = '' }
|
||||||
Remove-Website -Name $SiteName -ErrorAction SilentlyContinue
|
|
||||||
$oldRule = "$SiteName $SitePort"
|
|
||||||
Get-NetFirewallRule -DisplayName $oldRule -ErrorAction SilentlyContinue |
|
|
||||||
Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
foreach ($site in (Get-Website)) {
|
foreach ($site in (Get-Website)) {
|
||||||
foreach ($app in (Get-WebApplication -Site $site.Name -ErrorAction SilentlyContinue)) {
|
foreach ($app in (Get-WebApplication -Site $site.Name -ErrorAction SilentlyContinue)) {
|
||||||
if ($app.PhysicalPath -eq $AppRoot) {
|
if ($app.PhysicalPath -eq $AppRoot) {
|
||||||
$name = $app.Path.Trim('/')
|
$doomed += @{ Kind = 'app'; Name = $app.Path.Trim('/'); Site = $site.Name }
|
||||||
Write-Log "removing the previous subpath application '/$name' (now its own site)" 'WARN'
|
|
||||||
Remove-WebApplication -Site $site.Name -Name $name -ErrorAction SilentlyContinue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($doomed.Count -gt 0 -and -not $weBuiltThis -and -not $AdoptExisting) {
|
||||||
|
foreach ($d in $doomed) {
|
||||||
|
if ($d.Kind -eq 'site') { Write-Log (" would remove IIS site '{0}'" -f $d.Name) 'FAIL' }
|
||||||
|
else { Write-Log (" would remove application '/{0}' under '{1}'" -f $d.Name, $d.Site) 'FAIL' }
|
||||||
|
}
|
||||||
|
Fail 'this server has an IIS deployment that this installer did not create' @'
|
||||||
|
Publishing the way you asked means removing what is listed above, and there is
|
||||||
|
no version stamp to show this installer put it there. Deleting a mount somebody
|
||||||
|
else configured is not something to do without being asked.
|
||||||
|
|
||||||
|
If the removal is what you want, re-run with -AdoptExisting.
|
||||||
|
If not, re-run matching how the application is published today: pass
|
||||||
|
-MountAlias <alias> to keep it as an application under an existing site, or omit
|
||||||
|
-MountAlias to keep it as a site of its own.
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($d in $doomed) {
|
||||||
|
if ($d.Kind -eq 'site') {
|
||||||
|
Write-Log "removing the previous stand-alone site '$($d.Name)' (now published as a subpath)" 'WARN'
|
||||||
|
Remove-Website -Name $d.Name -ErrorAction SilentlyContinue
|
||||||
|
Get-NetFirewallRule -DisplayName ("{0} {1}" -f $d.Name, $SitePort) -ErrorAction SilentlyContinue |
|
||||||
|
Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
||||||
|
} else {
|
||||||
|
Write-Log "removing the previous subpath application '/$($d.Name)' (now its own site)" 'WARN'
|
||||||
|
Remove-WebApplication -Site $d.Site -Name $d.Name -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($MountAlias) {
|
if ($MountAlias) {
|
||||||
|
|||||||
Reference in New Issue
Block a user