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.
743 lines
33 KiB
PowerShell
743 lines
33 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Day-to-day control of a ShopDB-Flask installation, on the server itself.
|
|
|
|
.DESCRIPTION
|
|
Installed alongside the application so an operator never has to open IIS
|
|
Manager, hunt for a log, or remember an appcmd incantation.
|
|
|
|
Run it with no arguments for a menu, or pass a command directly:
|
|
|
|
.\shopdb-admin.ps1 status
|
|
.\shopdb-admin.ps1 restart
|
|
.\shopdb-admin.ps1 backup D:\backups
|
|
|
|
Everything here is safe to run at any time EXCEPT backup/restore, which are
|
|
called out explicitly.
|
|
|
|
.NOTES
|
|
Written for stock Windows PowerShell 5.1. No modules to install.
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet('menu','status','start','stop','restart','logs','open','backup',
|
|
'check','sessions','plugins','add-plugin','verify','uninstall')]
|
|
[string] $Command = 'menu',
|
|
[string] $Path = '',
|
|
# Machine-readable output for 'check'. The people running this are expected to
|
|
# ask an AI assistant for help, and pasting a screenshot of a console into a
|
|
# chat window loses most of what matters. One structured blob they can paste
|
|
# gives the assistant real state to reason about instead of guesses.
|
|
[switch] $Json,
|
|
[string] $AppRoot = 'C:\shopdb-flask',
|
|
[string] $SiteName = 'shopdb-flask',
|
|
[string] $AppPool = 'shopdbflask',
|
|
[int] $SitePort = 8090
|
|
)
|
|
|
|
$ErrorActionPreference = 'Continue'
|
|
$AppCmd = Join-Path $env:windir 'System32\inetsrv\appcmd.exe'
|
|
|
|
# --- bitness ---------------------------------------------------------------
|
|
# IIS's management COM objects are 64-BIT ONLY. Under the 32-bit PowerShell,
|
|
# Import-Module WebAdministration SUCCEEDS but Get-Website then fails with
|
|
# Retrieving the COM class factory ... REGDB_E_CLASSNOTREG
|
|
# which this script caught and reported as "cannot read IIS" - indistinguishable
|
|
# from IIS being absent.
|
|
#
|
|
# A 32-bit launcher is easy to end up with: Inno Setup is a 32-bit process, so
|
|
# anything it starts gets SysWOW64 powershell through WOW64 redirection. Rather
|
|
# than fix every launcher, relaunch under the native PowerShell. 'Sysnative' is
|
|
# the alias that lets a 32-bit process reach the real System32, and it exists
|
|
# ONLY for 32-bit processes - hence the guard.
|
|
|
|
# Every relaunch below must carry the ORIGINAL arguments through. They used to be
|
|
# dropped, so a console started from the Start Menu with -AppRoot D:\shopdb
|
|
# relaunched itself with the C:\shopdb-flask default and reported a healthy
|
|
# install as missing - and the operator had done nothing wrong.
|
|
function Get-ForwardedArgs {
|
|
$forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass',
|
|
'-File', ('"' + $PSCommandPath + '"'), $Command)
|
|
if ($Path) { $forward += @('-Path', ('"' + $Path + '"')) }
|
|
if ($AppRoot) { $forward += @('-AppRoot', ('"' + $AppRoot + '"')) }
|
|
if ($SiteName) { $forward += @('-SiteName', ('"' + $SiteName + '"')) }
|
|
if ($AppPool) { $forward += @('-AppPool', ('"' + $AppPool + '"')) }
|
|
if ($SitePort) { $forward += @('-SitePort', $SitePort) }
|
|
if ($Json) { $forward += '-Json' }
|
|
return $forward
|
|
}
|
|
|
|
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
|
|
$native = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
|
|
if (Test-Path $native) {
|
|
Start-Process -FilePath $native -ArgumentList (Get-ForwardedArgs) -Wait -NoNewWindow
|
|
return
|
|
}
|
|
}
|
|
|
|
# --- elevation -------------------------------------------------------------
|
|
# Reading IIS state needs Administrator: the WebAdministration module and the
|
|
# IIS: drive both fail without it. Unelevated, this tool used to report
|
|
# "IIS not available / application pool: not installed", which reads as "your
|
|
# install is broken" when it actually means "I cannot see it". Relaunch elevated
|
|
# instead of reporting a false state.
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
|
# 'open' just launches a browser. Prompting for administrator to do that trains
|
|
# people to click through UAC, and the Start Menu shortcut uses this command.
|
|
if ($Command -ne 'open' -and
|
|
-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
Write-Host ''
|
|
Write-Host ' Administrator rights are needed to read IIS state.' -ForegroundColor Yellow
|
|
Write-Host ' Re-launching elevated - approve the prompt.' -ForegroundColor Yellow
|
|
$argList = @('-NoExit') + (Get-ForwardedArgs)
|
|
try {
|
|
Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs | Out-Null
|
|
} catch {
|
|
Write-Host ''
|
|
Write-Host ' Elevation was declined.' -ForegroundColor Red
|
|
Write-Host ' Right-click the shortcut and choose "Run as administrator".' -ForegroundColor Red
|
|
Read-Host ' Press Enter to close' | Out-Null
|
|
}
|
|
return
|
|
}
|
|
|
|
|
|
# --- brand header ----------------------------------------------------------
|
|
# Deliberately typographic, NOT ASCII art. The GE monogram is fine cursive
|
|
# linework; rendered as block characters at console resolution it reads as noise,
|
|
# which looks worse than no mark at all. A clean rule and correct wordmark says
|
|
# "considered"; mushy art says the opposite.
|
|
#
|
|
# Box-drawing characters used here are all in code page 437 (the Windows console
|
|
# default), and this file is saved UTF-8 WITH BOM so PowerShell 5.1 reads them
|
|
# correctly rather than assuming the ANSI code page.
|
|
function Show-Banner {
|
|
$rule = ([string][char]0x2500) * 58
|
|
Write-Host ''
|
|
Write-Host ' GE AEROSPACE' -ForegroundColor Blue
|
|
Write-Host (' ' + $rule) -ForegroundColor DarkGray
|
|
Write-Host ' ShopDB-Flask' -ForegroundColor White
|
|
Write-Host ' Asset management for the shop floor' -ForegroundColor DarkGray
|
|
Write-Host ''
|
|
}
|
|
|
|
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
|
function Head {
|
|
param($m)
|
|
Write-Host ''
|
|
Write-Host (' ' + $m) -ForegroundColor Cyan
|
|
Write-Host (' ' + (([string][char]0x2500) * $m.Length)) -ForegroundColor DarkGray
|
|
}
|
|
|
|
function Get-EnvValue {
|
|
param([string] $Key)
|
|
$envFile = Join-Path $AppRoot '.env'
|
|
if (-not (Test-Path $envFile)) { return '' }
|
|
$line = Get-Content $envFile | Where-Object { $_ -like "$Key=*" } | Select-Object -First 1
|
|
if ($line) { return $line.Substring($Key.Length + 1) }
|
|
return ''
|
|
}
|
|
|
|
function Get-Deployment {
|
|
<#
|
|
Which way was this installed?
|
|
|
|
Method A: its own IIS site on $SitePort.
|
|
Method B: an IIS Application under an existing site, at /<alias>, reached
|
|
on that site's port. MOUNT_PATH in .env is what distinguishes
|
|
them - it is the same value wsgi.py uses to mount the app.
|
|
|
|
Without this the console looked for a SITE that method B never creates and
|
|
reported "web site: not installed" on a perfectly healthy server, then
|
|
probed the wrong port and said it was not responding.
|
|
#>
|
|
$mount = (Get-EnvValue 'MOUNT_PATH').Trim()
|
|
if (-not $mount) {
|
|
return @{ Subpath = $false; Alias = ''; BaseUrl = "http://localhost:$SitePort"; Port = $SitePort }
|
|
}
|
|
$alias = $mount.Trim('/')
|
|
$port = 80
|
|
$parent = 'Default Web Site'
|
|
try {
|
|
Import-Module WebAdministration -ErrorAction Stop
|
|
foreach ($site in (Get-Website)) {
|
|
$app = Get-WebApplication -Site $site.Name -Name $alias -ErrorAction SilentlyContinue
|
|
if ($app) {
|
|
$parent = $site.Name
|
|
$b = $site.bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
|
|
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $port = [int]$Matches[1] }
|
|
break
|
|
}
|
|
}
|
|
} catch { }
|
|
$base = "http://localhost:$port/$alias"
|
|
if ($port -eq 80) { $base = "http://localhost/$alias" }
|
|
return @{ Subpath = $true; Alias = $alias; Parent = $parent; BaseUrl = $base; Port = $port }
|
|
}
|
|
|
|
function Get-DbParts {
|
|
# Pull host/port/name/user out of DATABASE_URL without printing the password.
|
|
$url = Get-EnvValue 'DATABASE_URL'
|
|
if ($url -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') {
|
|
# The password is PERCENT-ENCODED in DATABASE_URL (the installer applies
|
|
# [uri]::EscapeDataString). SQLAlchemy unescapes it; so must we. Without
|
|
# this, every command here fails to authenticate whenever the password
|
|
# contains a space, @, %, ! or /, and the console reports the database as
|
|
# unreachable on a server where the application is running perfectly.
|
|
return @{ User = [uri]::UnescapeDataString($Matches[1])
|
|
Pass = [uri]::UnescapeDataString($Matches[2])
|
|
Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Find-MysqlClient {
|
|
$candidates = @(
|
|
'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
|
|
'C:\mysql56\bin\mysql.exe'
|
|
) + @(Get-ChildItem 'C:\Program Files\MySQL' -Filter mysql.exe -Recurse -EA SilentlyContinue |
|
|
Select-Object -ExpandProperty FullName)
|
|
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } }
|
|
return ''
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
function Show-Status {
|
|
Head 'Status'
|
|
|
|
$deploy = Get-Deployment
|
|
$siteState = 'not installed'
|
|
$poolState = 'not installed'
|
|
try {
|
|
Import-Module WebAdministration -ErrorAction Stop
|
|
if ($deploy.Subpath) {
|
|
# Method B: there is no site of our own - we are an Application.
|
|
$app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
|
|
if ($app) {
|
|
$parentSite = Get-Website -Name $deploy.Parent -ErrorAction SilentlyContinue
|
|
$siteState = ("/{0} under '{1}' ({2})" -f $deploy.Alias, $deploy.Parent,
|
|
$(if ($parentSite) { $parentSite.State } else { 'unknown' }))
|
|
}
|
|
} else {
|
|
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
|
if ($site) { $siteState = $site.State }
|
|
}
|
|
if (Test-Path "IIS:\AppPools\$AppPool") { $poolState = (Get-Item "IIS:\AppPools\$AppPool").State }
|
|
} catch {
|
|
# Should be unreachable now that the script self-elevates, but if IIS is
|
|
# genuinely absent say THAT, rather than implying ShopDB is missing.
|
|
# Name the real cause instead of implying ShopDB is missing.
|
|
if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) {
|
|
$siteState = 'IIS is not installed on this server'
|
|
} else {
|
|
$siteState = 'could not read IIS - ' + $_.Exception.Message
|
|
}
|
|
$poolState = $siteState
|
|
}
|
|
|
|
$colour = if (($siteState -eq 'Started') -or ($siteState -like '*Started*')) { 'Green' } else { 'Yellow' }
|
|
Say (" published as : {0}" -f $siteState) $colour
|
|
Say (" application pool: {0}" -f $poolState) $colour
|
|
Say (" installed at : {0}" -f $(if (Test-Path $AppRoot) { $AppRoot } else { 'not found' }))
|
|
|
|
# Does it actually answer? A "Started" pool proves nothing. Under method B
|
|
# this must include the mount path, or it tests a URL that never existed.
|
|
$url = $deploy.BaseUrl + '/'
|
|
try {
|
|
$r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 20
|
|
Say (" responding : yes (HTTP {0})" -f $r.StatusCode) 'Green'
|
|
} catch {
|
|
Say ' responding : NO' 'Red'
|
|
Say (" {0}" -f $_.Exception.Message) 'DarkGray'
|
|
}
|
|
|
|
$db = Get-DbParts
|
|
if ($db) {
|
|
Say (" database : {0} on {1}:{2} as {3}" -f $db.Name, $db.Host, $db.Port, $db.User)
|
|
$mysql = Find-MysqlClient
|
|
if ($mysql) {
|
|
$q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
|
|
$n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
|
|
if ($LASTEXITCODE -eq 0) { Say (" tables : {0}" -f $n) 'Green' }
|
|
else { Say ' tables : could not connect' 'Red' }
|
|
}
|
|
} else { Say ' database : no .env found' 'Yellow' }
|
|
|
|
# Is anyone set up yet?
|
|
try {
|
|
$na = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/api/setup/needs-admin') -UseBasicParsing -TimeoutSec 15
|
|
if ($na.Content -match '"needsadmin"\s*:\s*true') {
|
|
Say ' first run : NOT SET UP - open the site to create the first administrator' 'Yellow'
|
|
} else { Say ' first run : complete' 'Green' }
|
|
} catch { }
|
|
|
|
Say ''
|
|
if ($deploy.Subpath) {
|
|
$shown = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias
|
|
if ($deploy.Port -ne 80) { $shown = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
|
|
} else {
|
|
$shown = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
|
|
}
|
|
Say (" open with : {0}" -f $shown) 'White'
|
|
}
|
|
|
|
function Start-App {
|
|
Head 'Starting'
|
|
$deploy = Get-Deployment
|
|
& $AppCmd start apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
|
if (-not $deploy.Subpath) {
|
|
& $AppCmd start site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
|
|
} else {
|
|
# Method B shares the parent site - starting or stopping THAT would take
|
|
# every other application on this server with it.
|
|
Say (" (published under '{0}' - only the application pool is ours to control)" -f $deploy.Parent) 'DarkGray'
|
|
}
|
|
Say ' started' 'Green'
|
|
}
|
|
|
|
function Stop-App {
|
|
Head 'Stopping'
|
|
$deploy = Get-Deployment
|
|
Say ' ShopDB-Flask will be unavailable until you start it again.' 'Yellow'
|
|
if (-not $deploy.Subpath) {
|
|
& $AppCmd stop site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
|
|
} else {
|
|
Say (" (leaving site '{0}' running - stopping it would take down everything else on it)" -f $deploy.Parent) 'DarkGray'
|
|
}
|
|
& $AppCmd stop apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
|
Say ' stopped' 'Yellow'
|
|
}
|
|
|
|
function Restart-App {
|
|
Head 'Restarting'
|
|
# Recycle rather than stop/start: it drains existing requests instead of
|
|
# cutting them off, and it is what a config change actually needs.
|
|
& $AppCmd recycle apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
|
Start-Sleep -Seconds 2
|
|
try {
|
|
$r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30
|
|
Say (" back up (HTTP {0})" -f $r.StatusCode) 'Green'
|
|
} catch { Say ' did not respond after restart - run: shopdb-admin.ps1 logs' 'Red' }
|
|
}
|
|
|
|
function Show-Logs {
|
|
Head 'Recent logs'
|
|
$appLog = Join-Path $AppRoot 'logs'
|
|
if (Test-Path $appLog) {
|
|
$newest = Get-ChildItem "$appLog\*.log" -EA SilentlyContinue |
|
|
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
|
if ($newest) {
|
|
Say (" application: {0}" -f $newest.FullName) 'White'
|
|
Get-Content $newest.FullName -Tail 20 | ForEach-Object { Say " $_" }
|
|
} else { Say ' no application log yet' }
|
|
}
|
|
$inst = 'C:\ProgramData\ShopDB-Flask\logs'
|
|
if (Test-Path $inst) {
|
|
$newest = Get-ChildItem "$inst\*.log" -EA SilentlyContinue |
|
|
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
|
if ($newest) {
|
|
Say ''
|
|
Say (" install: {0}" -f $newest.FullName) 'White'
|
|
Get-Content $newest.FullName -Tail 12 | ForEach-Object { Say " $_" }
|
|
}
|
|
}
|
|
}
|
|
|
|
function Open-Site {
|
|
# The installer records the address it actually published at. Prefer it: this
|
|
# command runs unelevated, and .env is ACL'd, so working the address out from
|
|
# MOUNT_PATH and IIS may not be possible from here.
|
|
$recorded = Join-Path $AppRoot '.installed-url'
|
|
if (Test-Path $recorded) {
|
|
$u = (Get-Content $recorded -TotalCount 1).Trim()
|
|
if ($u) { Start-Process $u; Say " opened $u" 'Green'; return }
|
|
}
|
|
$deploy = Get-Deployment
|
|
if ($deploy.Subpath) {
|
|
if ($deploy.Port -eq 80) { $u = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias }
|
|
else { $u = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
|
|
} else {
|
|
$u = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
|
|
}
|
|
Start-Process $u
|
|
Say " opened $u" 'Green'
|
|
}
|
|
|
|
function Backup-Db {
|
|
param([string] $Dest)
|
|
Head 'Database backup'
|
|
$db = Get-DbParts
|
|
if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return }
|
|
if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
|
|
if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }
|
|
|
|
$mysql = Find-MysqlClient
|
|
if (-not $mysql) { Say ' mysql client not found' 'Red'; return }
|
|
$dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe'
|
|
if (-not (Test-Path $dump)) { Say ' mysqldump not found' 'Red'; return }
|
|
|
|
$file = Join-Path $Dest ("shopdb_flask-{0}.sql" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
|
Say (" writing {0}" -f $file)
|
|
# Redirect rather than --result-file: keeps it working on 5.6 and 8.0 alike.
|
|
# Do NOT pipe mysqldump through the PowerShell pipeline into Out-File.
|
|
# PowerShell 5.1 writes a UTF-8 BOM and re-encodes the stream through the
|
|
# console code page, producing a dump MySQL refuses to load and mangling any
|
|
# non-ASCII data - discovered only when the backup is finally needed.
|
|
# Redirect the process's stdout straight to the file instead.
|
|
$args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)",
|
|
'--single-transaction', '--routines', '--triggers', $db.Name)
|
|
$quoted = $args | ForEach-Object {
|
|
if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
|
|
}
|
|
$err = [System.IO.Path]::GetTempFileName()
|
|
try {
|
|
$proc = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow `
|
|
-RedirectStandardOutput $file -RedirectStandardError $err
|
|
if ($proc.ExitCode -ne 0) {
|
|
Say (" mysqldump failed (exit {0})" -f $proc.ExitCode) 'Red'
|
|
Get-Content $err -Tail 3 -EA SilentlyContinue | ForEach-Object { Say (" " + $_) 'DarkGray' }
|
|
return
|
|
}
|
|
} finally { Remove-Item $err -Force -ErrorAction SilentlyContinue }
|
|
|
|
# Verify it is complete rather than merely present.
|
|
$tail = @(Get-Content $file -Tail 5 -ErrorAction SilentlyContinue)
|
|
if ((Test-Path $file) -and ((Get-Item $file).Length -gt 1024) -and ($tail -match 'Dump completed')) {
|
|
Say (" done - {0:N1} MB, verified complete" -f ((Get-Item $file).Length / 1MB)) 'Green'
|
|
Say ' Store this off the server. It contains all of your asset data.' 'Yellow'
|
|
} else {
|
|
Say ' backup is empty or truncated - do NOT rely on it' 'Red'
|
|
Remove-Item $file -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Get-CheckState {
|
|
<#
|
|
Everything an outside reader needs to reason about this server, gathered
|
|
without changing anything. Secrets are NEVER included: the database password
|
|
lives in DATABASE_URL and this reports the host, port, name and user only.
|
|
#>
|
|
$deploy = Get-Deployment
|
|
$db = Get-DbParts
|
|
$state = [ordered]@{
|
|
collected = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
computername = $env:COMPUTERNAME
|
|
approot = $AppRoot
|
|
installed = (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py'))
|
|
version = ''
|
|
publishedas = if ($deploy.Subpath) { "subpath /$($deploy.Alias) under '$($deploy.Parent)'" } else { "own site '$SiteName' on port $SitePort" }
|
|
baseurl = $deploy.BaseUrl
|
|
apppool = 'unknown'
|
|
poolstate = 'unknown'
|
|
sitestate = 'unknown'
|
|
responding = $false
|
|
httpstatus = 0
|
|
database = $null
|
|
pythonversion = ''
|
|
plugins = @()
|
|
sbomcomponents = 0
|
|
errors = @()
|
|
}
|
|
foreach ($pair in @(@('version', '.installed-version'))) {
|
|
$f = Join-Path $AppRoot $pair[1]
|
|
if (Test-Path $f) { $state[$pair[0]] = (Get-Content $f -TotalCount 1).Trim() }
|
|
}
|
|
$state.apppool = $AppPool
|
|
try {
|
|
Import-Module WebAdministration -ErrorAction Stop
|
|
if (Test-Path "IIS:\AppPools\$AppPool") { $state.poolstate = (Get-Item "IIS:\AppPools\$AppPool").State.ToString() }
|
|
if ($deploy.Subpath) {
|
|
$app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
|
|
$state.sitestate = if ($app) { 'application present' } else { 'application MISSING' }
|
|
} else {
|
|
$s = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
|
$state.sitestate = if ($s) { $s.State.ToString() } else { 'site MISSING' }
|
|
}
|
|
} catch { $state.errors += "IIS: $($_.Exception.Message)" }
|
|
|
|
try {
|
|
$r = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/') -UseBasicParsing -TimeoutSec 20
|
|
$state.responding = $true
|
|
$state.httpstatus = [int] $r.StatusCode
|
|
} catch { $state.errors += "HTTP: $($_.Exception.Message)" }
|
|
|
|
if ($db) {
|
|
$state.database = [ordered]@{ host = $db.Host; port = $db.Port; name = $db.Name; user = $db.User; reachable = $false }
|
|
$mysql = Find-MysqlClient
|
|
if ($mysql) {
|
|
$q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
|
|
$n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
|
|
if ($LASTEXITCODE -eq 0) { $state.database.reachable = $true; $state.database.tables = [int] $n }
|
|
} else { $state.errors += 'no mysql client found, database not probed' }
|
|
} else { $state.errors += 'no .env found' }
|
|
|
|
$py = Join-Path $AppRoot 'venv\Scripts\python.exe'
|
|
if (Test-Path $py) { $state.pythonversion = (& $py -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null | Select-Object -First 1) }
|
|
|
|
$dir = Join-Path $AppRoot 'plugins'
|
|
if (Test-Path $dir) {
|
|
$state.plugins = @(Get-ChildItem $dir -Directory -EA SilentlyContinue |
|
|
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
|
|
Select-Object -ExpandProperty Name)
|
|
}
|
|
$sbom = Join-Path $AppRoot 'sbom.cdx.json'
|
|
if (Test-Path $sbom) {
|
|
try { $state.sbomcomponents = (Get-Content $sbom -Raw | ConvertFrom-Json).components.Count } catch { }
|
|
}
|
|
return $state
|
|
}
|
|
|
|
function Invoke-Check {
|
|
if ($Json) {
|
|
# ONLY the JSON goes to stdout, so it can be redirected to a file or piped
|
|
# without a banner in the middle of the document.
|
|
Get-CheckState | ConvertTo-Json -Depth 6
|
|
return
|
|
}
|
|
|
|
Head 'Health check'
|
|
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
|
|
if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return }
|
|
Push-Location $AppRoot
|
|
$env:FLASK_APP = 'shopdb'
|
|
try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } }
|
|
finally { Pop-Location }
|
|
Say ''
|
|
Say ' For help from an AI assistant, paste the output of:' 'DarkGray'
|
|
Say ' shopdb-admin.ps1 check -Json' 'White'
|
|
}
|
|
|
|
function Show-Sessions {
|
|
Head 'Worker processes'
|
|
$w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
|
|
if (-not $w) { Say ' no IIS worker running (the site starts one on first request)' }
|
|
else {
|
|
foreach ($p in $w) {
|
|
Say (" pid {0} {1:N0} MB started {2}" -f $p.ProcessId,
|
|
($p.WorkingSetSize/1MB), $p.CreationDate)
|
|
}
|
|
}
|
|
$py = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -EA SilentlyContinue |
|
|
Where-Object { $_.CommandLine -like "*$AppRoot*" }
|
|
if ($py) { foreach ($p in $py) { Say (" python pid {0} {1:N0} MB" -f $p.ProcessId, ($p.WorkingSetSize/1MB)) } }
|
|
}
|
|
|
|
|
|
function Invoke-Flask {
|
|
param([string[]] $Arguments)
|
|
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
|
|
if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return $null }
|
|
Push-Location $AppRoot
|
|
$env:FLASK_APP = 'shopdb'
|
|
# The app logs plugin startup to STDERR even on success; with EAP=Stop that
|
|
# becomes a terminating error and a healthy command looks like a failure.
|
|
$prev = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
try { & $flask @Arguments 2>&1 }
|
|
finally { $ErrorActionPreference = $prev; Pop-Location }
|
|
}
|
|
|
|
function Show-Plugins {
|
|
Head 'Plugins'
|
|
Invoke-Flask @('plugin','list') | ForEach-Object { Say " $_" }
|
|
|
|
# What is on disk but NOT installed can still be added here. What is absent
|
|
# from disk cannot - see the note below.
|
|
$dir = Join-Path $AppRoot 'plugins'
|
|
if (Test-Path $dir) {
|
|
$onDisk = (Get-ChildItem $dir -Directory -EA SilentlyContinue |
|
|
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
|
|
Select-Object -ExpandProperty Name) -join ', '
|
|
Say ''
|
|
Say (" shipped in this build : {0}" -f $onDisk) 'White'
|
|
}
|
|
Say ''
|
|
Say ' Add one that is shipped : shopdb-admin.ps1 add-plugin -Path <name>' 'White'
|
|
Say ''
|
|
Say ' A plugin NOT listed above is not on this server at all. This build was' 'DarkGray'
|
|
Say ' made for your site''s chosen plugin set, so its code was never shipped.' 'DarkGray'
|
|
Say ' Adding one means a new installer built from an updated site profile.' 'DarkGray'
|
|
}
|
|
|
|
function Add-Plugin {
|
|
param([string] $Name)
|
|
Head 'Add a plugin'
|
|
if (-not $Name) { Say ' usage: shopdb-admin.ps1 add-plugin -Path <plugin-name>' 'Yellow'; return }
|
|
|
|
$dir = Join-Path $AppRoot ('plugins\' + $Name)
|
|
if (-not (Test-Path (Join-Path $dir 'manifest.json'))) {
|
|
Say (" '{0}' is not present on this server." -f $Name) 'Red'
|
|
Say ''
|
|
Say ' This build ships only the plugins your site chose. Adding a new one' 'Yellow'
|
|
Say ' requires a new installer built from an updated site profile - the' 'Yellow'
|
|
Say ' code is not here to install.' 'Yellow'
|
|
Say ''
|
|
Say ' Run "shopdb-admin.ps1 plugins" to see what IS available.' 'White'
|
|
return
|
|
}
|
|
|
|
Say (" installing {0}..." -f $Name)
|
|
Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say " $_" }
|
|
Say ' applying its database migrations...'
|
|
Invoke-Flask @('plugin','upgrade-all') | ForEach-Object { Say " $_" }
|
|
Say ' restarting so its routes register...'
|
|
Restart-App
|
|
Say (" {0} added" -f $Name) 'Green'
|
|
}
|
|
|
|
function Invoke-Verify {
|
|
<#
|
|
Prove the installed dependencies are still the ones that shipped.
|
|
|
|
Two independent records, both written at install time:
|
|
requirements.txt a sha256 for every wheel, checked by pip on install
|
|
bundle-lock.json the exact third-party payload of the bundle
|
|
|
|
This re-checks what can still be checked on a live server. It reports; it
|
|
changes nothing.
|
|
#>
|
|
Head 'Verify'
|
|
|
|
$lock = Join-Path $AppRoot 'bundle-lock.json'
|
|
if (Test-Path $lock) {
|
|
try {
|
|
$l = Get-Content $lock -Raw | ConvertFrom-Json
|
|
Say (" installed from : {0} bundle ({1}, {2})" -f $l.generated, $l.pythontag, $l.platform)
|
|
} catch { Say ' bundle-lock.json is present but unreadable' 'Yellow' }
|
|
} else {
|
|
Say ' no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow'
|
|
}
|
|
|
|
# The SBOM travels with the application, because a server on a vaulted
|
|
# network cannot be scanned from anywhere else. When a CVE lands, this is
|
|
# what answers "is that component here, and at what version" without needing
|
|
# the build box, the internet, or anyone's memory.
|
|
$sbom = Join-Path $AppRoot 'sbom.cdx.json'
|
|
if (Test-Path $sbom) {
|
|
try {
|
|
$b = Get-Content $sbom -Raw | ConvertFrom-Json
|
|
$shipped = @($b.components | Where-Object { $_.scope -eq 'required' }).Count
|
|
Say (" components : {0} ({1} shipped), CycloneDX {2}" -f `
|
|
$b.components.Count, $shipped, $b.specVersion)
|
|
Say (" bill of materials: {0}" -f $sbom) 'DarkGray'
|
|
Say ' search it with : shopdb-admin.ps1 verify -Path <name>' 'DarkGray'
|
|
} catch { Say ' sbom.cdx.json is present but unreadable' 'Yellow' }
|
|
|
|
# A named component turns this into the actual question being asked.
|
|
# Guarded on $b: an unreadable SBOM leaves it unset, and querying it then
|
|
# would report 'none', which reads as "you are not affected".
|
|
if ($Path -and $b) {
|
|
Say ''
|
|
Say (" matches for '{0}':" -f $Path) 'Cyan'
|
|
$hits = @($b.components | Where-Object { $_.name -like ('*' + $Path + '*') })
|
|
if (-not $hits) { Say ' none - this server does not carry it' 'Green' }
|
|
foreach ($h in $hits) {
|
|
$tag = if ($h.scope -eq 'required') { 'SHIPPED' } else { 'build only' }
|
|
Say (" {0,-40} {1,-14} {2}" -f $h.name, $h.version, $tag) `
|
|
$(if ($h.scope -eq 'required') { 'Yellow' } else { 'DarkGray' })
|
|
}
|
|
}
|
|
} else {
|
|
Say ' no SBOM recorded (installed before SBOMs shipped, or by hand)' 'Yellow'
|
|
}
|
|
|
|
# pip's own audit. It re-reads the metadata of what is actually installed and
|
|
# reports anything missing or version-inconsistent, which is the part that
|
|
# can still drift after install - a hand-run `pip install` on the server.
|
|
$py = Join-Path $AppRoot 'venv\Scripts\python.exe'
|
|
if (-not (Test-Path $py)) { Say ' no venv - application not installed' 'Red'; return }
|
|
|
|
Say ''
|
|
Say ' checking installed packages against requirements.txt...'
|
|
$req = Join-Path $AppRoot 'requirements.txt'
|
|
if (-not (Test-Path $req)) { Say ' requirements.txt is missing from the install' 'Red'; return }
|
|
|
|
$wanted = @{}
|
|
foreach ($line in (Get-Content $req)) {
|
|
if ($line -match '^([A-Za-z0-9._-]+)==([^ \\]+)') { $wanted[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
|
|
}
|
|
$frozen = @{}
|
|
foreach ($line in (& $py -m pip freeze 2>$null)) {
|
|
if ($line -match '^([A-Za-z0-9._-]+)==(.+)$') { $frozen[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
|
|
}
|
|
|
|
$bad = 0
|
|
foreach ($name in ($wanted.Keys | Sort-Object)) {
|
|
if (-not $frozen.ContainsKey($name)) {
|
|
Say (" MISSING {0} {1}" -f $name, $wanted[$name]) 'Red'; $bad++
|
|
} elseif ($frozen[$name] -ne $wanted[$name]) {
|
|
Say (" DIFFERS {0} {1} installed, {2} expected" -f $name, $frozen[$name], $wanted[$name]) 'Red'; $bad++
|
|
}
|
|
}
|
|
if ($bad -eq 0) {
|
|
Say (" all {0} packages match" -f $wanted.Count) 'Green'
|
|
} else {
|
|
Say ''
|
|
Say (" {0} package(s) do not match what shipped." -f $bad) 'Red'
|
|
Say ' Something was installed or upgraded on this server by hand. Re-run the' 'Yellow'
|
|
Say ' installer to put the shipped set back.' 'Yellow'
|
|
}
|
|
|
|
& $py -m pip check 2>&1 | ForEach-Object { Say " $_" 'DarkGray' }
|
|
}
|
|
|
|
function Show-Uninstall {
|
|
Head 'Uninstall'
|
|
Say ' Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.'
|
|
Say ''
|
|
Say ' That removes the web site, application pool, firewall rule and files.'
|
|
Say ' It does NOT drop the database and does NOT uninstall MySQL.' 'Yellow'
|
|
Say ' Take a backup first: shopdb-admin.ps1 backup' 'Yellow'
|
|
}
|
|
|
|
function Show-Menu {
|
|
$first = $true
|
|
while ($true) {
|
|
if ($first) { Show-Banner; $first = $false }
|
|
Show-Status
|
|
Write-Host ''
|
|
Write-Host ' 1 Restart the application 6 Back up the database' -ForegroundColor White
|
|
Write-Host ' 2 Stop the application 7 Worker processes' -ForegroundColor White
|
|
Write-Host ' 3 Start the application 8 Open in browser' -ForegroundColor White
|
|
Write-Host ' 4 View recent logs 9 Plugins' -ForegroundColor White
|
|
Write-Host ' 5 Health check V Verify this install' -ForegroundColor White
|
|
Write-Host ' 0 Exit' -ForegroundColor White
|
|
Write-Host ''
|
|
$c = Read-Host ' Choose'
|
|
switch ($c) {
|
|
'1' { Restart-App } '2' { Stop-App } '3' { Start-App }
|
|
'4' { Show-Logs } '5' { Invoke-Check } '6' { Backup-Db $Path }
|
|
'7' { Show-Sessions } '8' { Open-Site }
|
|
'v' { Invoke-Verify } 'V' { Invoke-Verify }
|
|
'9' { Show-Plugins
|
|
$add = Read-Host ' Name of a shipped plugin to add (Enter to skip)'
|
|
if ($add) { Add-Plugin $add } }
|
|
'0' { return }
|
|
default { Say ' not a choice' 'Yellow' }
|
|
}
|
|
Write-Host ''
|
|
Read-Host ' Press Enter to continue' | Out-Null
|
|
Clear-Host
|
|
Show-Banner
|
|
}
|
|
}
|
|
|
|
switch ($Command) {
|
|
'status' { Show-Banner; Show-Status }
|
|
'start' { Start-App }
|
|
'stop' { Stop-App }
|
|
'restart' { Restart-App }
|
|
'logs' { Show-Logs }
|
|
'open' { Open-Site }
|
|
'backup' { Backup-Db $Path }
|
|
'check' { Invoke-Check }
|
|
'sessions' { Show-Sessions }
|
|
'plugins' { Show-Plugins }
|
|
'add-plugin'{ Add-Plugin $Path }
|
|
'verify' { Invoke-Verify }
|
|
'uninstall' { Show-Uninstall }
|
|
default { Show-Menu }
|
|
}
|