<# .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 { # WARN, not FAIL. This check runs before the operator has reached the # Address page, so it is testing the DEFAULT port, not necessarily # the one they intend to use. Blocking here would refuse an install # over a conflict the very next page lets them resolve. Add-Result 'Network' 'Site port' 'WARN' "TCP $SitePort is in use$owner" ` "Pick a different port on the Address page later in this wizard, or stop whatever is holding it." } } } # ============================================================================= # 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 { # NOT a blocker: the MSI is in the bundle and stage 4 installs it. This # was a FAIL, which - once the preflight page started blocking on any # failure - stopped the wizard dead over something the installer was # about to do by itself, with no way forward but to go and install it by # hand. Nothing the installer SUPPLIES may be a blocker. Add-Result 'IIS' 'HttpPlatformHandler' 'INFO' 'not installed yet' ` 'The installer installs it from the bundle. No action needed.' } } 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 ('
$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.4 LTS 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:\MySQL84', '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. It is not on this server yet - the installer stages its own copy, so this normally resolves itself during installation.' } } 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 { # WARN, not FAIL. This inspects the LOCAL MySQL, which may not be the # database the operator is about to install against - a bundled 8.4, # or a remote server. Blocking the wizard here refused an install # over a server that had nothing to do with it. Stage 3 runs # 'flask db-utils preflight' against the database actually chosen, # which is the check that can genuinely block. Add-Result 'MySQL' '5.6 index flags' 'WARN' ("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 }