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.
1875 lines
96 KiB
PowerShell
1875 lines
96 KiB
PowerShell
<#
|
|
.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://<server-fqdn>/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 <path>": the mysql
|
|
# client reads that path as a DATABASE NAME and fails with
|
|
# ERROR 1049 Unknown database '<path>'.
|
|
$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 <name> 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 <file>" fails with ERROR 1049 Unknown database '<path>': 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 /<alias> 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 <server>. 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 <path> 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 <path>, or rebuild the bundle with
|
|
scripts/build-site.sh <profile.json>, 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 <check>"), 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 <rewrite>, 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 '<rewrite>' -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 <rewrite> 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('<!-- SHOPDB-CLIENTIP-BEGIN', '').Replace('SHOPDB-CLIENTIP-END -->', '')
|
|
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 <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) {
|
|
# METHOD B: an IIS Application under an existing site, reached at
|
|
# http://<server-fqdn>/<alias>/ - 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
|
|
}
|