Reported from a Windows Server 2019 test: a "Windows Installer" dialog listing every msiexec /Option appeared, then the wizard reported that the bundled MySQL database could not be installed. That dialog is msiexec's usage help - it prints it when the command line does not parse - so the install never started. Cause: $MysqlRoot defaulted to 'C:\Program Files\MySQL\MySQL Server 8.4', which contains spaces. Invoke-Native wraps any argument containing whitespace in quotes, producing "INSTALLDIR=C:\Program Files\...". msiexec takes public properties as PROPERTY=value and expects the VALUE quoted - INSTALLDIR="C:\Program Files\..." - so it rejected the line, printed usage, and exited non-zero. This file already carried the rule, next to the Python target: "Never put a space in a path this installer controls." I broke it setting the 8.4 path. Two fixes, because one of them alone leaves the trap in place: - $MysqlRoot is now C:\MySQL84, space-free like C:\Python314. The MySQL client search paths in the installer, the preflight and the operator console all look there first, keeping backups working against the bundled server. - Invoke-Native now quotes PROPERTY=value correctly, so passing a spaced path explicitly no longer produces an unparseable command line. tests/test_installer_defaults.py fails if an installer-controlled path default ever contains a space again.
2119 lines
110 KiB
PowerShell
2119 lines
110 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.4 LTS (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.
|
|
# SPACE-FREE, deliberately - same reason as the Python target below. msiexec
|
|
# takes public properties as PROPERTY=value on the command line, and a value
|
|
# containing spaces has to be quoted as INSTALLDIR="C:\..." - quoting the
|
|
# whole PROPERTY=value token instead makes msiexec reject the command line
|
|
# and pop its usage dialog, which is what 'the bundled MySQL database could
|
|
# not be installed' looked like from the outside.
|
|
[string] $MysqlRoot = 'C:\MySQL84',
|
|
[string] $MysqlDataDir = 'C:\ProgramData\MySQL\data',
|
|
[string] $MysqlIni = 'C:\ProgramData\MySQL\my.ini',
|
|
[string] $MysqlService = 'MySQL84',
|
|
[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
|
|
# Was this server already stamped by a previous run of THIS installer, as observed
|
|
# BEFORE stage 2 writes its own stamp? Stage 4 needs the answer to know whether the
|
|
# IIS objects it is about to reconcile are its own. $null means nothing has looked
|
|
# yet, which is the case when stage 4 is run on its own.
|
|
$script:PreexistingInstallStamp = $null
|
|
|
|
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 ($_ -notmatch '\s' -or $_ -match '^".*"$') { $_ }
|
|
# PROPERTY=value (msiexec, msbuild): the VALUE is quoted, never the whole
|
|
# token. "INSTALLDIR=C:\Program Files\..." is rejected outright, whereas
|
|
# INSTALLDIR="C:\Program Files\..." is what the tool expects.
|
|
elseif ($_ -match '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { '{0}="{1}"' -f $Matches[1], $Matches[2] }
|
|
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
|
|
# The BUNDLE first, then the install directory, then the local server.
|
|
#
|
|
# A site whose MySQL lives on another host has no client installed here at
|
|
# all, so mysqldump was never found - which meant every upgrade skipped the
|
|
# pre-upgrade backup, and it did so AFTER stage 2 had stopped the pool and
|
|
# replaced the application tree. Shipping the client makes the backup work
|
|
# on a remote-database site, which is the case that needs it most.
|
|
$bundled = @(
|
|
(Join-Path $BundleRoot ('mysqlclient\' + $Name)),
|
|
(Join-Path $AppRoot ('mysqlclient\' + $Name))
|
|
)
|
|
foreach ($b in $bundled) { if (Test-Path $b) { return $b } }
|
|
|
|
# C:\MySQL84 first: that is where the bundled server installs (space-free,
|
|
# see $MysqlRoot). The Program Files paths cover a server MySQL put there
|
|
# by hand or by an older build.
|
|
$roots = @('C:\MySQL84', '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 '' }
|
|
|
|
# A dump contains every row, including the users table and its password
|
|
# hashes. ProgramData is readable by every user on the box by default, so the
|
|
# directory is locked to Administrators and SYSTEM the moment it is created.
|
|
$dir = Join-Path $env:ProgramData 'ShopDB-Flask\backups'
|
|
if (-not (Test-Path $dir)) {
|
|
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
|
Protect-File $dir
|
|
}
|
|
$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.4 LTS (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.4 LTS' '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.4.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)"
|
|
|
|
# NOT Write-Host. Under the wizard this process runs hidden, so nobody ever
|
|
# saw this - and every line of stdout is forwarded into the setup log that
|
|
# operators are told to send to support. The generated root password was
|
|
# therefore invisible to the person who needed it and permanently recorded
|
|
# for everyone who did not.
|
|
#
|
|
# Write it to a file only Administrators and SYSTEM can read, and let the
|
|
# wizard tell the operator where it is.
|
|
$rootFile = Join-Path $env:ProgramData 'ShopDB-Flask\mysql-root-password.txt'
|
|
$rootDir = Split-Path $rootFile -Parent
|
|
if (-not (Test-Path $rootDir)) { New-Item -ItemType Directory -Path $rootDir -Force | Out-Null }
|
|
New-Item -ItemType File -Force -Path $rootFile | Out-Null
|
|
Protect-File $rootFile
|
|
Set-Content -Path $rootFile -Value @(
|
|
'MySQL root password for this server, generated during installation.',
|
|
'It is not recorded anywhere else and cannot be recovered.',
|
|
'Move it into your password manager, then delete this file.',
|
|
'',
|
|
$rootPass
|
|
) -Encoding UTF8
|
|
Write-Log "MySQL root password written to $rootFile (Administrators and SYSTEM only)" 'OK'
|
|
Write-Log 'MYSQLROOTFILE:' + $rootFile
|
|
Write-Log 'stage 0 complete' 'OK'
|
|
}
|
|
|
|
# =============================================================================
|
|
# 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
|
|
# Captured HERE, before the stamp below is written. Stage 4's guard against
|
|
# touching an installation this did not create tested the stamp directly, and
|
|
# stage 2 always runs first in a '-Stage all' install - so the stamp it had
|
|
# just written made every server look like one of ours and the guard never
|
|
# fired on the case it exists for.
|
|
$script:PreexistingInstallStamp = Test-Path (Join-Path $AppRoot '.installed-version')
|
|
|
|
# --- 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 { }
|
|
}
|
|
|
|
# Plugin directories present on the server but NOT in the new bundle are
|
|
# removed BEFORE the copy. The copy merges rather than replaces, so a plugin
|
|
# dropped from a site's profile kept its code on disk forever - which defeats
|
|
# the point of a lean build (ADR-013) and, worse, leaves the core's
|
|
# `try: from plugins.X.models import ...` guards succeeding for a plugin this
|
|
# site no longer has, so SQLAlchemy still builds the relationship and queries
|
|
# still join a table that may have been pruned.
|
|
if (-not $WhatIfOnly) {
|
|
$newPlugins = @()
|
|
$srcPlugins = Join-Path $AppSource 'plugins'
|
|
if (Test-Path $srcPlugins) {
|
|
$newPlugins = @(Get-ChildItem $srcPlugins -Directory | Select-Object -ExpandProperty Name)
|
|
}
|
|
$livePlugins = Join-Path $AppRoot 'plugins'
|
|
if ($newPlugins.Count -gt 0 -and (Test-Path $livePlugins)) {
|
|
foreach ($dir in (Get-ChildItem $livePlugins -Directory)) {
|
|
if ($newPlugins -notcontains $dir.Name) {
|
|
Write-Log "removing '$($dir.Name)': not in this build" 'WARN'
|
|
# Uninstall FIRST, while the code is still importable - the
|
|
# registry entry outlives the directory otherwise, and the app
|
|
# then fails to load a plugin it still believes is installed.
|
|
try {
|
|
if (Test-Path $Flask) {
|
|
Push-Location $AppRoot
|
|
$env:FLASK_APP = 'shopdb'
|
|
& $Flask plugin uninstall $dir.Name 2>&1 |
|
|
ForEach-Object { Write-Log " $_" }
|
|
Pop-Location
|
|
}
|
|
} catch { Write-Log " could not deregister $($dir.Name): $($_.Exception.Message)" 'WARN' }
|
|
Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|
|
# The MySQL client stays on the server. The bundle is extracted to a temp
|
|
# directory and deleted when Setup exits, so a copy that lived only there
|
|
# would leave `shopdb-admin backup` with nothing to run on a site whose
|
|
# database is on another host.
|
|
$bundledClient = Join-Path $BundleRoot 'mysqlclient'
|
|
if (Test-Path $bundledClient) {
|
|
$target = Join-Path $AppRoot 'mysqlclient'
|
|
if (-not (Test-Path $target)) { New-Item -ItemType Directory -Path $target -Force | Out-Null }
|
|
Copy-Item (Join-Path $bundledClient '*') $target -Recurse -Force
|
|
Write-Log 'MySQL client staged for backups' 'OK'
|
|
}
|
|
}
|
|
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.
|
|
#
|
|
# `appcmd unlock config /section:X` unlocks it for EVERY site on the machine,
|
|
# which on a shared server hands every other application the ability to
|
|
# define its own handlers. Delegate to our own location instead, and fall
|
|
# back to the server-wide unlock only if that is refused - a 500.19 the
|
|
# operator cannot diagnose is worse than a wider delegation, but it should be
|
|
# the second choice, not the first.
|
|
$ourLocation = if ($MountAlias) { "$ParentSite/$($MountAlias.Trim('/'))" } else { $SiteName }
|
|
foreach ($section in @('system.webServer/handlers','system.webServer/httpPlatform')) {
|
|
if ($WhatIfOnly) { Write-Log "would unlock $section for $ourLocation"; continue }
|
|
Write-Log "unlocking $section for $ourLocation"
|
|
$scoped = & $appcmd unlock config "$ourLocation" /section:$section 2>&1
|
|
$scoped | ForEach-Object { Write-Log " $_" }
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Log " scoped unlock refused; unlocking $section server-wide" 'WARN'
|
|
& $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 was a version stamp BEFORE this run started - only this
|
|
# installer writes one. Stage 2 records that observation; testing the file
|
|
# here instead would read the stamp stage 2 has already written.
|
|
$weBuiltThis = if ($null -ne $script:PreexistingInstallStamp) {
|
|
$script:PreexistingInstallStamp
|
|
} else {
|
|
# Stage 4 run on its own, so stage 2 has not written anything this run.
|
|
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('/')
|
|
# Prefer an http binding, but fall back to https. A parent site published
|
|
# ONLY over https - which is normal, and which the operator did nothing
|
|
# wrong to have - left this defaulting to port 80, requesting a URL that
|
|
# answers nothing, and failing a working install with a red dialog.
|
|
$parentScheme = 'http'
|
|
$parentPort = 0
|
|
try {
|
|
$bindings = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection
|
|
$b = $bindings | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
|
|
if (-not $b) {
|
|
$b = $bindings | Where-Object { $_.protocol -eq 'https' } | Select-Object -First 1
|
|
if ($b) { $parentScheme = 'https' }
|
|
}
|
|
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] }
|
|
} catch { }
|
|
if ($parentPort -eq 0) { $parentPort = if ($parentScheme -eq 'https') { 443 } else { 80 } }
|
|
|
|
$targets = @("{0}://localhost:{1}/{2}/" -f $parentScheme, $parentPort, $alias)
|
|
if ($hostName -and $hostName -ne 'localhost') {
|
|
$targets += "{0}://{1}:{2}/{3}/" -f $parentScheme, $hostName, $parentPort, $alias
|
|
}
|
|
# An https parent almost certainly has a certificate for its real name,
|
|
# not for 'localhost', and a certificate complaint is not an application
|
|
# fault. Accept any certificate for the duration of the smoke test only.
|
|
if ($parentScheme -eq 'https') {
|
|
Write-Log 'parent site is https-only; certificate validation is skipped for this check' 'WARN'
|
|
try {
|
|
Add-Type -TypeDefinition @'
|
|
using System.Net;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
public class ShopdbSmokeTestCertPolicy : ICertificatePolicy {
|
|
public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) {
|
|
return true;
|
|
}
|
|
}
|
|
'@ -ErrorAction SilentlyContinue
|
|
[System.Net.ServicePointManager]::CertificatePolicy = New-Object ShopdbSmokeTestCertPolicy
|
|
} catch { }
|
|
}
|
|
} 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'
|
|
}
|
|
}
|
|
|
|
# Is the client-IP rule actually in effect? It is the control that makes
|
|
# remote_addr trustworthy: without it IIS forwards whatever X-Forwarded-For
|
|
# the CALLER sent, waitress trusts it because it arrives from loopback, and
|
|
# the GE-Enforce IP allowlist becomes bypassable from anywhere on the network.
|
|
# It does not fail closed. Verified here rather than assumed, because the
|
|
# failure is silent and looks exactly like a working server.
|
|
if ($ClientIpSource -eq 'direct') {
|
|
$liveCfg = Join-Path $AppRoot 'web.config'
|
|
$cfgText = if (Test-Path $liveCfg) { Get-Content $liveCfg -Raw } else { '' }
|
|
$ruleLive = ($cfgText -match '<rewrite>') -and ($cfgText -notmatch 'SHOPDB-CLIENTIP-BEGIN')
|
|
$moduleThere = Test-Path (Join-Path $env:windir 'system32\inetsrv\rewrite.dll')
|
|
if ($ruleLive -and $moduleThere) {
|
|
Write-Log 'client-IP rule is live; remote_addr is trustworthy' 'OK'
|
|
} else {
|
|
Write-Log 'CLIENT-IP RULE IS NOT IN EFFECT' 'WARN'
|
|
if (-not $moduleThere) { Write-Log ' URL Rewrite module is not installed' 'WARN' }
|
|
if (-not $ruleLive) { Write-Log ' web.config does not have an active <rewrite> block' 'WARN' }
|
|
Write-Log ' every client will be recorded as 127.0.0.1, and the GE-Enforce IP' 'WARN'
|
|
Write-Log ' allowlist is BYPASSABLE - a caller can send its own X-Forwarded-For' 'WARN'
|
|
Write-Log ' re-run with -ClientIpSource direct, or use token authentication' 'WARN'
|
|
}
|
|
}
|
|
|
|
# The self-hosted API documentation. It is the thing an operator - or the
|
|
# assistant an operator is asking - reaches for on a server with no internet,
|
|
# and it is served from a file that has to have been staged into the build.
|
|
# A missing file makes /api/docs render an empty page, which nobody notices
|
|
# until it is needed. Warn, never fail: the application itself is fine.
|
|
$base = $targets[0].TrimEnd('/')
|
|
try {
|
|
$d = Invoke-WebRequest -Uri "$base/api/docs/openapi.json" -UseBasicParsing -TimeoutSec 20
|
|
if ($d.StatusCode -eq 200) { Write-Log 'API documentation is being served at /api/docs' 'OK' }
|
|
else { Write-Log "/api/docs/openapi.json returned $($d.StatusCode)" 'WARN' }
|
|
} catch {
|
|
Write-Log '/api/docs is NOT working - docs/openapi.json was not staged into this build' 'WARN'
|
|
Write-Log ' the site runs fine; offline API reference and LLM assistance will not' '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' {
|
|
# Overwrite before deleting, same as uninstall does. A rolled
|
|
# back install left .env - which holds the database password
|
|
# in plaintext by design - recoverable on disk.
|
|
try {
|
|
if ((Split-Path $item.Id -Leaf) -eq '.env') {
|
|
Set-Content -Path $item.Id -Value ('0' * 512) -Encoding UTF8 -Force
|
|
}
|
|
} catch { }
|
|
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
|
|
|
|
# A SUBPATH install has no site of its own - it is an Application under an
|
|
# existing one. Removing only the site left the application in place, pointed
|
|
# at a directory this is about to delete, so the parent site (at West
|
|
# Jefferson, the live classic ASP) served 503 on that path forever and
|
|
# Add/Remove Programs reported success.
|
|
#
|
|
# The alias is whatever MOUNT_PATH says, which is the same value wsgi.py
|
|
# mounts on, so it cannot disagree with how the app was actually published.
|
|
$mount = ''
|
|
$envFile = Join-Path $AppRoot '.env'
|
|
if (Test-Path $envFile) {
|
|
$line = Get-Content $envFile -ErrorAction SilentlyContinue |
|
|
Where-Object { $_ -like 'MOUNT_PATH=*' } | Select-Object -First 1
|
|
if ($line) { $mount = $line.Substring('MOUNT_PATH='.Length).Trim().Trim('/') }
|
|
}
|
|
if ($mount) {
|
|
$removed = $false
|
|
foreach ($site in (Get-Website -ErrorAction SilentlyContinue)) {
|
|
$app = Get-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue
|
|
if ($app) {
|
|
Remove-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue
|
|
Write-Log "removed application /$mount from site '$($site.Name)'" 'OK'
|
|
$removed = $true
|
|
}
|
|
}
|
|
if (-not $removed) { Write-Log "application /$mount not present" }
|
|
}
|
|
|
|
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" }
|
|
|
|
# Stage 4 creates the rule as "$SiteName $SitePort". This looked for the
|
|
# literal "ShopDB-Flask 8090", which is not the same string as the default
|
|
# "shopdb-flask 8090" and matches nothing at all on a non-default port, so
|
|
# the rule outlived the uninstall. Build the name the same way stage 4 does,
|
|
# and sweep any rule left by a differently-ported install of the same site.
|
|
$rule = "$SiteName $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" }
|
|
Get-NetFirewallRule -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.DisplayName -like "$SiteName *" -and $_.DisplayName -ne $rule } |
|
|
ForEach-Object {
|
|
Remove-NetFirewallRule -DisplayName $_.DisplayName -ErrorAction SilentlyContinue
|
|
Write-Log "removed leftover firewall rule '$($_.DisplayName)'" 'OK'
|
|
}
|
|
|
|
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
|
|
}
|