diff --git a/deploy/windows/installer/README.md b/deploy/windows/installer/README.md index a48a80c..0f29974 100644 --- a/deploy/windows/installer/README.md +++ b/deploy/windows/installer/README.md @@ -115,6 +115,30 @@ Verifying a live server, months later and offline: 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 IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects diff --git a/deploy/windows/installer/shopdb-install.ps1 b/deploy/windows/installer/shopdb-install.ps1 index da04902..c9d216b 100644 --- a/deploy/windows/installer/shopdb-install.ps1 +++ b/deploy/windows/installer/shopdb-install.ps1 @@ -121,6 +121,17 @@ param( # 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' @@ -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' } +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. @@ -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 $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 @@ -1425,26 +1485,60 @@ has to come from the bundle either way. # 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) { - Write-Log "removing the previous stand-alone site '$SiteName' (now published as a subpath)" 'WARN' - Remove-Website -Name $SiteName -ErrorAction SilentlyContinue - $oldRule = "$SiteName $SitePort" - Get-NetFirewallRule -DisplayName $oldRule -ErrorAction SilentlyContinue | - Remove-NetFirewallRule -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) { - $name = $app.Path.Trim('/') - Write-Log "removing the previous subpath application '/$name' (now its own site)" 'WARN' - Remove-WebApplication -Site $site.Name -Name $name -ErrorAction SilentlyContinue + $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) {