Nine fixes from a review of the installer against its actual audience: DT leads at sister sites who are not Windows, IIS or Python specialists and who will lean on an AI assistant to get through it. TRUTHFULNESS. The preflight was advisory - an operator read 'IIS is not installed', pressed Next, answered five more pages and the install died partway through with Python already on the box. The results page now blocks while anything is failing, repaints on every run instead of latching after the first, and offers 'Check again' so a fixed problem does not mean starting over. On failure the wizard said 'Nothing was left running', which is false in every path because the stages run with -OnFailure never: it now says the server is part-configured, that re-running is safe, and how to remove it. The final page no longer reads 'ShopDB-Flask is ready' after a failed install. SECRETS. The generated MySQL root password went to Write-Host in a process the wizard runs hidden - so nobody saw it - and stdout is forwarded into the setup log operators are told to send to support, so it was permanently recorded for everyone who did not need it. It now goes to an ACL'd file. Database dumps, which contain every user password hash, landed in a ProgramData directory readable by every user on the box; the directory is now locked at creation. UPGRADES ON REMOTE-DATABASE SITES. mysqldump was looked for only under local MySQL install paths, so a site whose database is on another host silently skipped every pre-upgrade backup - after stage 2 had already stopped the pool and replaced the tree. Find-MysqlTool now prefers a client shipped in the bundle, stage 2 stages it onto the server, preflight reports when it is missing, and mysqlclient\ is an optional locked payload. UNINSTALL. A subpath install is an IIS Application, not a site; removing only the site left the application pointing at a deleted directory, so the parent site - at West Jefferson, the live classic ASP - served 503 on that path forever while Add/Remove Programs reported success. Uninstall now reads MOUNT_PATH and removes the application. The firewall rule was created as "$SiteName $SitePort" and removed as the literal 'ShopDB-Flask 8090', which matches nothing. DAY-2 TOOLING. Every shortcut now passes -AppRoot and -SitePort, and the console forwards them through its own elevation and 32-bit relaunches instead of discarding them - a non-default directory or port made it report a healthy site as broken, from a shortcut the installer wrote. 'Open ShopDB-Flask' resolved to a hardcoded localhost:8090 that was wrong for every subpath install; it now asks the console, which reads the address the installer recorded, and no longer demands administrator to open a browser. SMOKE TEST. The parent-site port lookup filtered for an http binding and defaulted to 80, so an https-only parent site failed a working install with a red dialog. DOCS AND /api/docs. The installer was invisible: nothing in docs/, README.md or CLAUDE.md mentioned it, so a DT lead or their assistant landed on the manual IIS runbook and hand-built the very server the installer then refuses to upgrade. docs/INSTALL-WINDOWS.md and docs/OPERATE-WINDOWS.md are now the canonical route, the two manual runbooks are bannered as reference-only, README and CLAUDE.md route by target, and llms.txt tells an assistant which document to follow and to ask for 'check -Json' before diagnosing. Both ship on the server, along with openapi.json and llms.txt - without those the self-hosted /api/docs was broken on every installed box, which matters most to the sites least able to debug it. Stage 5 now checks it actually serves. shopdb-admin.ps1 gains 'check -Json': one structured, secret-free block covering version, publishing method, IIS state, HTTP reachability, database, Python version, plugins and errors. That is the cheapest useful answer to 'the operator will ask an LLM' - it works with no infrastructure, which a install-time MCP server could not.
548 lines
24 KiB
PowerShell
548 lines
24 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
ShopDB-Flask installer - Stage 1: read-only preflight.
|
|
|
|
.DESCRIPTION
|
|
Discovers everything the installer needs to know about this box and reports
|
|
it. Makes NO changes: no installs, no config edits, no service restarts.
|
|
Safe to run on a production server.
|
|
|
|
Written for stock Windows PowerShell 5.1 (Windows Server ships it). No
|
|
pwsh-only syntax, no external modules, no network access.
|
|
|
|
.PARAMETER SitePort
|
|
The port the ShopDB site will listen on. Default 8090 (the runbook's example;
|
|
the classic ASP site keeps 8080).
|
|
|
|
.PARAMETER AppRoot
|
|
Intended install directory. Default C:\shopdb-flask.
|
|
|
|
.PARAMETER Json
|
|
Emit machine-readable JSON instead of the human report. Later installer
|
|
stages consume this.
|
|
|
|
.EXAMPLE
|
|
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1
|
|
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1 -Json > preflight.json
|
|
|
|
.NOTES
|
|
Exit 0 = no blocking problems. Exit 1 = at least one FAIL.
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[int] $SitePort = 8090,
|
|
[string] $AppRoot = 'C:\shopdb-flask',
|
|
# Needed so the port check can tell OUR site apart from a stranger's.
|
|
[string] $SiteName = 'shopdb-flask',
|
|
[switch] $Json,
|
|
# Machine-readable output for a GUI caller: one record per line,
|
|
# STATUS|AREA|CHECK|DETAIL|FIX
|
|
# The console rendering below aligns columns with padding spaces, which only
|
|
# works in a fixed-width font at console width. A GUI must do its own layout,
|
|
# so give it DATA and let it decide - do not make it parse a formatted table.
|
|
# (-Json exists too, but Inno's Pascal Script has no JSON parser.)
|
|
[switch] $Delimited
|
|
)
|
|
|
|
Set-StrictMode -Version 2.0
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# --- result collection -------------------------------------------------------
|
|
# Every check appends one record. Status is PASS / WARN / FAIL / INFO.
|
|
$script:Results = New-Object System.Collections.ArrayList
|
|
$script:IisPresent = $false
|
|
|
|
function Add-Result {
|
|
param(
|
|
[string] $Area,
|
|
[string] $Check,
|
|
[ValidateSet('PASS','WARN','FAIL','INFO','SKIP')] [string] $Status,
|
|
[string] $Detail,
|
|
[string] $Fix = ''
|
|
)
|
|
$null = $script:Results.Add([PSCustomObject]@{
|
|
Area = $Area
|
|
Check = $Check
|
|
Status = $Status
|
|
Detail = $Detail
|
|
Fix = $Fix
|
|
})
|
|
}
|
|
|
|
# Wrap a check so one failure cannot abort the whole run. On an unfamiliar box
|
|
# an unexpected exception is itself a finding, not a crash.
|
|
function Invoke-Check {
|
|
param([string] $Area, [string] $Check, [scriptblock] $Body)
|
|
try { & $Body }
|
|
catch {
|
|
Add-Result $Area $Check 'WARN' "check could not run: $($_.Exception.Message)" `
|
|
'Report this output; the installer needs to handle this box shape.'
|
|
}
|
|
}
|
|
|
|
# =============================================================================
|
|
# 1. Operator context
|
|
# =============================================================================
|
|
|
|
Invoke-Check 'System' 'Elevation' {
|
|
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$adm = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole(
|
|
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
if ($adm) { Add-Result 'System' 'Elevation' 'PASS' 'running as Administrator' }
|
|
else {
|
|
Add-Result 'System' 'Elevation' 'FAIL' 'not elevated' `
|
|
'Re-run PowerShell as Administrator. IIS and service changes require it.'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'System' 'Windows version' {
|
|
$os = Get-CimInstance Win32_OperatingSystem
|
|
$name = $os.Caption
|
|
$ver = $os.Version
|
|
# ProductType: 1 = workstation, 2 = domain controller, 3 = server
|
|
$isServer = ($os.ProductType -ne 1)
|
|
$detail = "$name (build $ver), $(if ($isServer) {'Server'} else {'Client'})"
|
|
|
|
$supported = $false
|
|
if ($isServer -and [version]$ver -ge [version]'10.0.17763') { $supported = $true } # 2019+
|
|
if (-not $isServer -and [version]$ver -ge [version]'10.0.19045') { $supported = $true } # Win10 22H2+
|
|
|
|
if ($supported) { Add-Result 'System' 'Windows version' 'PASS' $detail }
|
|
else {
|
|
Add-Result 'System' 'Windows version' 'FAIL' $detail `
|
|
'Supported: Windows Server 2019/2022+, or Windows 10 22H2 / 11 Pro+.'
|
|
}
|
|
|
|
# Client SKUs must be Pro/Enterprise/Education for IIS.
|
|
if (-not $isServer -and $name -match 'Home') {
|
|
Add-Result 'System' 'Windows edition' 'FAIL' 'Windows Home edition' `
|
|
'IIS is not available on Home editions. Pro or higher is required.'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'System' 'Architecture' {
|
|
if ([Environment]::Is64BitOperatingSystem) {
|
|
Add-Result 'System' 'Architecture' 'PASS' '64-bit'
|
|
} else {
|
|
Add-Result 'System' 'Architecture' 'FAIL' '32-bit' `
|
|
'The bundled Python and wheels are 64-bit (win_amd64) only.'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'System' 'PowerShell version' {
|
|
$v = $PSVersionTable.PSVersion
|
|
Add-Result 'System' 'PowerShell version' 'INFO' "$v"
|
|
if ($v.Major -lt 5) {
|
|
Add-Result 'System' 'PowerShell version' 'FAIL' "$v" `
|
|
'PowerShell 5.1 or later is required.'
|
|
}
|
|
}
|
|
|
|
# =============================================================================
|
|
# 2. Disk and ports
|
|
# =============================================================================
|
|
|
|
Invoke-Check 'Disk' 'Free space' {
|
|
$drive = (Split-Path -Qualifier $AppRoot)
|
|
$d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$drive'"
|
|
if ($null -eq $d) {
|
|
Add-Result 'Disk' 'Free space' 'FAIL' "drive $drive not found" `
|
|
"Choose an -AppRoot on an existing volume."
|
|
return
|
|
}
|
|
$freeGB = [math]::Round($d.FreeSpace / 1GB, 1)
|
|
$detail = "$freeGB GB free on $drive"
|
|
if ($freeGB -ge 5) { Add-Result 'Disk' 'Free space' 'PASS' $detail }
|
|
else { Add-Result 'Disk' 'Free space' 'FAIL' $detail 'At least 5 GB is required.' }
|
|
}
|
|
|
|
Invoke-Check 'Disk' 'AppRoot' {
|
|
if (Test-Path $AppRoot) {
|
|
$existing = @(Get-ChildItem $AppRoot -Force -ErrorAction SilentlyContinue)
|
|
if ($existing.Count -gt 0) {
|
|
$hasEnv = Test-Path (Join-Path $AppRoot '.env')
|
|
if ($hasEnv) {
|
|
# An existing install is the NORMAL state for an upgrade. Reporting
|
|
# it as a warning makes a routine update look like a problem.
|
|
$ver = ''
|
|
$vf = Join-Path $AppRoot '.installed-version'
|
|
if (Test-Path $vf) { $ver = ' version ' + (Get-Content $vf -TotalCount 1).Trim() }
|
|
Add-Result 'Disk' 'AppRoot' 'INFO' `
|
|
"existing ShopDB-Flask install found$ver - it will be upgraded in place, and your settings and database are kept"
|
|
} else {
|
|
Add-Result 'Disk' 'AppRoot' 'WARN' "$AppRoot exists and is not empty" `
|
|
'Confirm this directory is safe to install into.'
|
|
}
|
|
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot exists and is empty" }
|
|
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot does not exist yet" }
|
|
}
|
|
|
|
function Test-PortFree {
|
|
param([int] $Port)
|
|
# Get-NetTCPConnection is the reliable listener check on Server 2012R2+.
|
|
try {
|
|
$listening = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)
|
|
return ($listening.Count -eq 0)
|
|
} catch {
|
|
# Fall back to a bind attempt if the cmdlet is unavailable.
|
|
try {
|
|
$l = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Any, $Port)
|
|
$l.Start(); $l.Stop(); return $true
|
|
} catch { return $false }
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'Network' 'Site port' {
|
|
if (Test-PortFree $SitePort) {
|
|
Add-Result 'Network' 'Site port' 'PASS' "TCP $SitePort is free"
|
|
} else {
|
|
$owner = ''
|
|
try {
|
|
$c = Get-NetTCPConnection -State Listen -LocalPort $SitePort -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if ($c) { $owner = " (pid $($c.OwningProcess): $((Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).ProcessName))" }
|
|
} catch { }
|
|
# Is the listener OUR OWN site? On a reinstall or upgrade the port is held
|
|
# by the very application being upgraded, and blocking on that makes the
|
|
# installer refuse to update anything it previously installed.
|
|
$ours = $false
|
|
try {
|
|
Import-Module WebAdministration -ErrorAction SilentlyContinue
|
|
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
|
if ($site) {
|
|
foreach ($b in $site.bindings.Collection) {
|
|
# ${} is required: "$SitePort:" parses as a DRIVE-qualified variable.
|
|
if ($b.bindingInformation -match ":${SitePort}:") { $ours = $true }
|
|
}
|
|
}
|
|
} catch { }
|
|
|
|
if ($ours) {
|
|
Add-Result 'Network' 'Site port' 'INFO' `
|
|
"TCP $SitePort is used by the existing $SiteName site - this will be upgraded in place"
|
|
} else {
|
|
Add-Result 'Network' 'Site port' 'FAIL' "TCP $SitePort is in use$owner" `
|
|
"Choose a different port with -SitePort, or stop the listener."
|
|
}
|
|
}
|
|
}
|
|
|
|
# =============================================================================
|
|
# 3. IIS
|
|
# =============================================================================
|
|
|
|
Invoke-Check 'IIS' 'Installed' {
|
|
$svc = Get-Service -Name W3SVC -ErrorAction SilentlyContinue
|
|
$script:IisPresent = ($null -ne $svc)
|
|
if ($null -eq $svc) {
|
|
Add-Result 'IIS' 'Installed' 'FAIL' 'W3SVC service not found' `
|
|
'Install IIS. Server: Install-WindowsFeature Web-Server -IncludeManagementTools. Client: enable Internet Information Services in Windows Features.'
|
|
return
|
|
}
|
|
Add-Result 'IIS' 'Installed' 'PASS' "W3SVC present, status $($svc.Status)"
|
|
if ($svc.Status -ne 'Running') {
|
|
Add-Result 'IIS' 'Running' 'WARN' "W3SVC is $($svc.Status)" 'Start-Service W3SVC'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'IIS' 'WebAdministration module' {
|
|
$m = Get-Module -ListAvailable -Name WebAdministration
|
|
if ($m) { Add-Result 'IIS' 'WebAdministration module' 'PASS' 'available' }
|
|
else {
|
|
Add-Result 'IIS' 'WebAdministration module' 'FAIL' 'not available' `
|
|
'Install the IIS management tools (Web-Mgmt-Console / IIS Management Scripts and Tools).'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'IIS' 'HttpPlatformHandler' {
|
|
# The handler registers itself as a global module. Check the module list.
|
|
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
|
|
if (-not (Test-Path $appcmd)) {
|
|
Add-Result 'IIS' 'HttpPlatformHandler' 'SKIP' 'appcmd.exe not present (IIS not installed)' `
|
|
'Re-run this preflight after installing IIS.'
|
|
return
|
|
}
|
|
$modules = & $appcmd list module 2>$null
|
|
if ($modules -match 'httpPlatformHandler') {
|
|
Add-Result 'IIS' 'HttpPlatformHandler' 'PASS' 'installed'
|
|
} else {
|
|
Add-Result 'IIS' 'HttpPlatformHandler' 'FAIL' 'not installed' `
|
|
'Install httpPlatformHandler_amd64.msi from the bundle. IIS cannot launch waitress without it.'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'IIS' 'Locked config sections' {
|
|
# The authoritative source is applicationHost.config. `appcmd list config
|
|
# /section:X` prints the section CONTENTS, not its lock state, so grepping
|
|
# that output silently reports every section as unlocked.
|
|
$cfg = Join-Path $env:windir 'system32\inetsrv\config\applicationHost.config'
|
|
if (-not (Test-Path $cfg)) {
|
|
Add-Result 'IIS' 'Locked config sections' 'SKIP' 'applicationHost.config not found'
|
|
return
|
|
}
|
|
foreach ($name in @('handlers','httpPlatform')) {
|
|
$line = Select-String -Path $cfg -Pattern ('<section name="' + $name + '"') |
|
|
Select-Object -First 1
|
|
if ($null -eq $line) {
|
|
# httpPlatform is registered by the HttpPlatformHandler MSI. Before
|
|
# that, `appcmd unlock config /section:system.webServer/httpPlatform`
|
|
# fails with "Unknown config section".
|
|
Add-Result 'IIS' "Section $name" 'SKIP' 'section not registered yet' `
|
|
'Install HttpPlatformHandler FIRST; only then can this section be unlocked.'
|
|
} elseif ($line.Line -match 'overrideModeDefault="Deny"') {
|
|
Add-Result 'IIS' "Section $name" 'WARN' 'locked (overrideModeDefault="Deny")' `
|
|
"Installer must run: appcmd unlock config /section:system.webServer/$name (else IIS 500.19)"
|
|
} else {
|
|
Add-Result 'IIS' "Section $name" 'PASS' 'not locked'
|
|
}
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'IIS' 'URL Rewrite module' {
|
|
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
|
|
if (-not (Test-Path $appcmd)) {
|
|
Add-Result 'IIS' 'URL Rewrite module' 'SKIP' 'IIS not installed; cannot check'
|
|
return
|
|
}
|
|
$modules = & $appcmd list module 2>$null
|
|
if ($modules -match 'RewriteModule') {
|
|
Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (the X-Forwarded-For rule can be enabled)'
|
|
} else {
|
|
# Not a FAIL: it is only needed for -ClientIpSource direct, and the
|
|
# installer carries the MSI and installs it itself. Worth reporting
|
|
# because without the rule IIS sends no X-Forwarded-For at all, so every
|
|
# client reads as 127.0.0.1 and the GE-Enforce IP allowlist, the
|
|
# visitor-location lookup and per-host login rate limiting go quiet.
|
|
Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' `
|
|
'The installer installs it from the bundle when -ClientIpSource is direct. Behind a reverse proxy that already sets X-Forwarded-For, use -ClientIpSource proxy and leave it out.'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'IIS' 'Existing sites' {
|
|
if (-not $script:IisPresent) {
|
|
Add-Result 'IIS' 'Existing sites' 'SKIP' 'IIS not installed'
|
|
return
|
|
}
|
|
try {
|
|
Import-Module WebAdministration -ErrorAction Stop
|
|
$sites = @(Get-Website)
|
|
if ($sites.Count -eq 0) { Add-Result 'IIS' 'Existing sites' 'INFO' 'none' ; return }
|
|
$desc = ($sites | ForEach-Object {
|
|
$b = ($_.bindings.Collection | ForEach-Object { $_.bindingInformation }) -join ','
|
|
"$($_.Name) [$($_.State)] $b"
|
|
}) -join '; '
|
|
Add-Result 'IIS' 'Existing sites' 'INFO' $desc
|
|
# Adoption sites typically run the classic ASP shopdb here already.
|
|
if ($desc -match '8080') {
|
|
Add-Result 'IIS' 'Classic ASP site' 'INFO' 'a site is bound on 8080 (likely the classic ASP shopdb)' `
|
|
'Install ShopDB as a separate site on its own port; do not disturb this one.'
|
|
}
|
|
} catch {
|
|
Add-Result 'IIS' 'Existing sites' 'WARN' "could not enumerate: $($_.Exception.Message)" ''
|
|
}
|
|
}
|
|
|
|
# =============================================================================
|
|
# 4. MySQL (detect BEFORE offering bundled vs existing)
|
|
# =============================================================================
|
|
|
|
Invoke-Check 'MySQL' 'Service' {
|
|
$svcs = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^MySQL' -or $_.DisplayName -match 'MySQL' })
|
|
if ($svcs.Count -eq 0) {
|
|
Add-Result 'MySQL' 'Service' 'INFO' 'no MySQL service found' `
|
|
'Bundled MySQL 8.0 is the appropriate choice on this box.'
|
|
return
|
|
}
|
|
foreach ($s in $svcs) {
|
|
Add-Result 'MySQL' 'Service' 'WARN' "$($s.Name) ($($s.DisplayName)) is $($s.Status)" `
|
|
'MySQL already present. Default to the EXISTING-server option; installing bundled MySQL will collide on port 3306.'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'MySQL' 'Port 3306' {
|
|
if (Test-PortFree 3306) {
|
|
Add-Result 'MySQL' 'Port 3306' 'INFO' 'nothing listening on 3306'
|
|
} else {
|
|
Add-Result 'MySQL' 'Port 3306' 'WARN' 'something is listening on 3306' `
|
|
'Bundled MySQL cannot use the default port. Use the existing server, or pick another port.'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'MySQL' 'Backup client' {
|
|
# mysqldump is what takes the mandatory pre-upgrade backup. Without it every
|
|
# upgrade skips the backup - and skips it AFTER the application pool has been
|
|
# stopped and the tree replaced, so the site is down and there is nothing to
|
|
# restore from. A site whose database is on another server typically has no
|
|
# MySQL client installed here at all, which is exactly the case that needs it.
|
|
$names = @('mysqldump.exe')
|
|
$found = ''
|
|
foreach ($root in @((Join-Path $PSScriptRoot 'mysqlclient'),
|
|
(Join-Path $AppRoot 'mysqlclient'),
|
|
'C:\Program Files\MySQL', 'C:\mysql56\bin',
|
|
'C:\Program Files (x86)\MySQL')) {
|
|
if (-not (Test-Path $root)) { continue }
|
|
$hit = Get-ChildItem $root -Filter $names[0] -Recurse -ErrorAction SilentlyContinue |
|
|
Select-Object -First 1
|
|
if ($hit) { $found = $hit.FullName; break }
|
|
}
|
|
if ($found) {
|
|
Add-Result 'MySQL' 'Backup client' 'PASS' "mysqldump found ($found)"
|
|
} else {
|
|
Add-Result 'MySQL' 'Backup client' 'WARN' 'mysqldump not found on this server' `
|
|
'Needed for the automatic pre-upgrade backup and for "shopdb-admin.ps1 backup". A first install works without it; upgrades will not be protected. Add mysqlclient\ to the installer bundle, or install the MySQL client on this server.'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'MySQL' 'Version and config' {
|
|
# Find mysqld.exe via the service binary path; read the version and locate my.ini.
|
|
$svc = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.PathName -match 'mysqld' } | Select-Object -First 1
|
|
if ($null -eq $svc) { return }
|
|
|
|
$path = $svc.PathName
|
|
$exe = ''
|
|
if ($path -match '"([^"]+mysqld[^"]*)"') { $exe = $matches[1] }
|
|
elseif ($path -match '(\S+mysqld\S*)') { $exe = $matches[1] }
|
|
|
|
$ver = ''
|
|
if ($exe -and (Test-Path $exe)) {
|
|
try { $ver = (& $exe --version 2>$null | Out-String).Trim() } catch { }
|
|
}
|
|
if ($ver) { Add-Result 'MySQL' 'Version' 'INFO' $ver }
|
|
|
|
# my.ini path is passed as --defaults-file in the service command line.
|
|
$ini = ''
|
|
if ($path -match '--defaults-file="?([^"]+\.ini)"?') { $ini = $matches[1] }
|
|
if ($ini -and (Test-Path $ini)) {
|
|
Add-Result 'MySQL' 'Config file' 'INFO' $ini
|
|
|
|
# MySQL 5.6 needs three flags or `flask db upgrade` dies with error 1071.
|
|
$is56 = ($ver -match '\b5\.6\.')
|
|
if ($is56) {
|
|
$content = Get-Content $ini -Raw
|
|
$need = @('innodb_file_per_table','innodb_file_format','innodb_large_prefix')
|
|
$missing = @()
|
|
foreach ($k in $need) { if ($content -notmatch $k) { $missing += $k } }
|
|
if ($missing.Count -eq 0) {
|
|
# Present in the FILE is not the same as ACTIVE. MySQL must be
|
|
# restarted for them to take effect, and the app's own
|
|
# `flask db-utils preflight` queries the live server - trust that.
|
|
Add-Result 'MySQL' '5.6 index flags' 'WARN' 'all three present in my.ini' `
|
|
'Present in the file only. They take effect after a MySQL RESTART, which interrupts the classic ASP app. Confirm with SHOW VARIABLES or flask db-utils preflight.'
|
|
} else {
|
|
Add-Result 'MySQL' '5.6 index flags' 'FAIL' ("missing: " + ($missing -join ', ')) `
|
|
"Add to [mysqld] in $ini and restart MySQL, or 'flask db upgrade' fails with error 1071. NOTE: restarting interrupts the classic ASP app."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# =============================================================================
|
|
# 5. Python (detect, but the installer uses its OWN bundled interpreter)
|
|
# =============================================================================
|
|
|
|
Invoke-Check 'Python' 'On PATH' {
|
|
$cmd = Get-Command python -ErrorAction SilentlyContinue
|
|
if ($null -eq $cmd) {
|
|
Add-Result 'Python' 'On PATH' 'INFO' 'no python on PATH' `
|
|
'Expected. The installer supplies its own interpreter.'
|
|
return
|
|
}
|
|
$v = ''
|
|
try { $v = (& $cmd.Source --version 2>&1 | Out-String).Trim() } catch { }
|
|
$detail = "$v at $($cmd.Source)"
|
|
|
|
# A per-user install under %LOCALAPPDATA% is unreadable by the IIS app-pool
|
|
# identity. That produces a 500 with an empty HttpPlatform log.
|
|
if ($cmd.Source -like "$env:LOCALAPPDATA*") {
|
|
Add-Result 'Python' 'On PATH' 'WARN' "$detail (PER-USER install)" `
|
|
'The IIS app-pool identity cannot read %LOCALAPPDATA%. The installer must install Python for ALL USERS and use absolute paths.'
|
|
} elseif ($cmd.Source -like '*WindowsApps*') {
|
|
Add-Result 'Python' 'On PATH' 'WARN' "$detail (Microsoft Store)" `
|
|
'Store Python misbehaves under service identities. The installer will not use it.'
|
|
} else {
|
|
Add-Result 'Python' 'On PATH' 'INFO' $detail `
|
|
'Not used by the installer, but a manual `flask` command later would resolve to this interpreter.'
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'Python' 'Registered installs' {
|
|
$found = @()
|
|
foreach ($hive in @('HKLM:\SOFTWARE\Python\PythonCore','HKCU:\SOFTWARE\Python\PythonCore')) {
|
|
if (Test-Path $hive) {
|
|
foreach ($k in Get-ChildItem $hive -ErrorAction SilentlyContinue) {
|
|
$ip = Join-Path $k.PSPath 'InstallPath'
|
|
if (Test-Path $ip) {
|
|
$loc = (Get-ItemProperty $ip -ErrorAction SilentlyContinue).'(default)'
|
|
$scope = if ($hive -like 'HKLM*') { 'all-users' } else { 'per-user' }
|
|
$found += "$($k.PSChildName) ($scope) $loc"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ($found.Count -eq 0) { Add-Result 'Python' 'Registered installs' 'INFO' 'none' }
|
|
else { Add-Result 'Python' 'Registered installs' 'INFO' ($found -join '; ') }
|
|
}
|
|
|
|
# =============================================================================
|
|
# Report
|
|
# =============================================================================
|
|
|
|
$fails = @($script:Results | Where-Object { $_.Status -eq 'FAIL' })
|
|
$warns = @($script:Results | Where-Object { $_.Status -eq 'WARN' })
|
|
$skips = @($script:Results | Where-Object { $_.Status -eq 'SKIP' })
|
|
|
|
if ($Delimited) {
|
|
# Data only. No padding, no colour, no alignment - the caller lays it out.
|
|
# Pipes are stripped from field values so the record can be split naively.
|
|
foreach ($r in $script:Results) {
|
|
$fix = ''
|
|
if ($r.Fix) { $fix = $r.Fix }
|
|
$fields = @($r.Status, $r.Area, $r.Check, $r.Detail, $fix) | ForEach-Object {
|
|
([string]$_) -replace '\|', '/' -replace '\s*\r?\n\s*', ' '
|
|
}
|
|
Write-Output ($fields -join '|')
|
|
}
|
|
} elseif ($Json) {
|
|
[PSCustomObject]@{
|
|
Timestamp = (Get-Date).ToString('s')
|
|
Computer = $env:COMPUTERNAME
|
|
SitePort = $SitePort
|
|
AppRoot = $AppRoot
|
|
Failures = $fails.Count
|
|
Warnings = $warns.Count
|
|
Skipped = $skips.Count
|
|
Results = $script:Results
|
|
} | ConvertTo-Json -Depth 5
|
|
} else {
|
|
Write-Host ''
|
|
Write-Host 'ShopDB-Flask preflight' -ForegroundColor Cyan
|
|
Write-Host (" host {0} site port {1} approot {2}" -f $env:COMPUTERNAME, $SitePort, $AppRoot)
|
|
Write-Host ''
|
|
$area = ''
|
|
foreach ($r in $script:Results) {
|
|
if ($r.Area -ne $area) { $area = $r.Area; Write-Host "[$area]" -ForegroundColor White }
|
|
$colour = 'Gray'
|
|
if ($r.Status -eq 'PASS') { $colour = 'Green' }
|
|
if ($r.Status -eq 'WARN') { $colour = 'Yellow' }
|
|
if ($r.Status -eq 'FAIL') { $colour = 'Red' }
|
|
if ($r.Status -eq 'SKIP') { $colour = 'DarkGray' }
|
|
Write-Host (" {0,-5} {1,-28} {2}" -f $r.Status, $r.Check, $r.Detail) -ForegroundColor $colour
|
|
if ($r.Fix -and $r.Status -ne 'PASS' -and $r.Status -ne 'INFO' -and $r.Status -ne 'SKIP') {
|
|
Write-Host (" -> {0}" -f $r.Fix) -ForegroundColor DarkGray
|
|
}
|
|
}
|
|
Write-Host ''
|
|
if ($fails.Count -eq 0) {
|
|
Write-Host "No blocking problems. $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Green
|
|
} else {
|
|
Write-Host "$($fails.Count) blocking problem(s), $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Red
|
|
}
|
|
if ($skips.Count -gt 0) {
|
|
Write-Host " Skipped checks were NOT verified. Re-run once their prerequisite is installed." -ForegroundColor DarkGray
|
|
}
|
|
Write-Host ''
|
|
}
|
|
|
|
if ($fails.Count -gt 0) { exit 1 } else { exit 0 }
|