Two defects in the stage 0 bootstrap, both surfacing as "Access denied" on a server where the operator was holding the correct password. CREATE USER IF NOT EXISTS is a no-op on an existing user - it does NOT change the password. Stage 0 generates a fresh password every run and overwrites .dbpass with it unconditionally, so any path that re-runs the bootstrap over an existing account left the handoff holding a password the server had never been told. ALTER USER now follows each CREATE, so the stored password and the handoff always agree. The user was also only created for 'localhost' and '127.0.0.1'. On current Windows, 'localhost' resolves to the IPv6 loopback FIRST, so an operator who types localhost rather than 127.0.0.1 arrives as '<user>'@'::1' - an account that did not exist - and MySQL answers "Access denied" naming a host they never typed. The ::1 account is now created and granted alongside the other two. Note the datadir guard means the first defect could not fire on a straightforward re-run - stage 0 refuses a non-empty data directory before reaching the bootstrap. It was still wrong, and reachable once the directory has been cleared by hand, which is what the failure message tells operators to do.
2167 lines
113 KiB
PowerShell
2167 lines
113 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'
|
|
}
|
|
}
|
|
# The Visual C++ runtime, BEFORE MySQL. MySQL's MSI carries a launch
|
|
# condition requiring it, and a bare Windows Server does not ship it -
|
|
# so on a fresh box the MSI aborts in about a second with the generic
|
|
# exit 1603 and no indication of what was missing. That is what a real
|
|
# Server 2019 install did.
|
|
$vcMarker = Join-Path $env:windir 'system32\vcruntime140.dll'
|
|
if (Test-Path $vcMarker) {
|
|
Write-Log 'Visual C++ runtime already present' 'OK'
|
|
} else {
|
|
$vc = Get-ChildItem (Join-Path $BundleRoot 'vcredist') -Filter '*.exe' -ErrorAction SilentlyContinue |
|
|
Select-Object -First 1
|
|
if (-not $vc) {
|
|
Fail 'the Visual C++ runtime is missing and no redistributable is in the bundle' @'
|
|
MySQL will not install without it - its MSI fails with a bare exit 1603.
|
|
Add vcredist\VC_redist.x64.exe to the bundle and rebuild, or install the
|
|
Microsoft Visual C++ 2015-2022 Redistributable (x64) on this server by hand.
|
|
'@
|
|
}
|
|
Write-Log "installing $($vc.Name) (MySQL requires it)"
|
|
if (-not $WhatIfOnly) {
|
|
Invoke-Native $vc.FullName @('/install','/quiet','/norestart') `
|
|
'Visual C++ runtime' -TimeoutSec 600
|
|
Write-Log 'Visual C++ runtime installed' 'OK'
|
|
}
|
|
}
|
|
|
|
# Quiet MSI. INSTALLDIR must have no trailing backslash or the MSI mis-parses it.
|
|
Write-Log "installing $($msi.Name) (125MB, takes a minute)"
|
|
# /l*v: without the MSI's own log, exit 1603 says only "something went
|
|
# wrong" - it names neither the action nor the reason, and it is the most
|
|
# common MySQL failure code. Costs nothing and turns the next failure
|
|
# into something readable.
|
|
$msiLog = Join-Path $script:LogDir ('mysql-msi-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
|
Write-Log "MSI log: $msiLog"
|
|
Invoke-Native 'msiexec.exe' @('/i', $msi.FullName, '/quiet', '/norestart',
|
|
'/l*v', $msiLog,
|
|
"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';
|
|
-- ::1 as well. On current Windows 'localhost' resolves to the IPv6 loopback
|
|
-- FIRST, so an operator who types localhost instead of 127.0.0.1 arrives as
|
|
-- '$DbUser'@'::1' - an account that did not exist, and MySQL answers "Access
|
|
-- denied" naming a host the operator never typed.
|
|
CREATE USER IF NOT EXISTS '$DbUser'@'::1' IDENTIFIED BY '$appPass';
|
|
-- ALTER after CREATE, because CREATE USER IF NOT EXISTS is a NO-OP on an
|
|
-- existing user and does NOT change its password. This stage generates a fresh
|
|
-- password every run and overwrites .dbpass with it, so on any re-run after a
|
|
-- partial failure the file held a password the server had never been told -
|
|
-- "Access denied" while the operator was looking straight at the right file.
|
|
-- ALTER makes the stored password and the handoff agree, every time.
|
|
ALTER USER '$DbUser'@'localhost' IDENTIFIED BY '$appPass';
|
|
ALTER USER '$DbUser'@'127.0.0.1' IDENTIFIED BY '$appPass';
|
|
ALTER USER '$DbUser'@'::1' IDENTIFIED BY '$appPass';
|
|
GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'localhost';
|
|
GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'127.0.0.1';
|
|
GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'::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
|
|
}
|