Files
shopdb-flask/deploy/windows/installer/shopdb-admin.ps1
cproudlock 23b1dfff41
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Load the equipment catalog from the console
The installer offers the catalog as a tick-box, which covers new sites and
nobody else. A site that installed before it existed, or unticked the box and
later changed its mind, had to be talked through an RDP session and a flask
command - which is exactly the sort of thing the console exists to avoid.

`shopdb-admin.ps1 catalog`, and C on the menu. It runs the dry run FIRST and
prints what would be added, then asks before writing: somebody running this on a
site that has been live for years deserves to see what it would touch before it
touches anything. Answering anything but yes leaves it alone.

An older build without the seed-catalog command is reported as such and told to
update, rather than the failure being read as an empty catalog.

Repair is now on the menu too. It was reachable only by typing the verb, which
is little use to the operator most likely to need it.
2026-08-05 13:30:33 -04:00

1005 lines
45 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','repair','catalog','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:\MySQL84\bin\mysql.exe',
'C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe',
'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'
# A recycle CANNOT start something that is stopped - it is a no-op on a
# stopped pool. Restarting after a failed upgrade, which is exactly when the
# pool is stopped, therefore did nothing and then reported "did not respond"
# in red, as though the application were broken. Start it first when needed.
Import-Module WebAdministration -ErrorAction SilentlyContinue
$started = $true
try {
if (Test-Path "IIS:\AppPools\$AppPool") {
$started = ((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started')
}
} catch { }
if (-not $started) {
Say ' the application pool is stopped; starting it' 'Yellow'
Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
} else {
# 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 " $_" }
if ($LASTEXITCODE -ne 0) { Say " recycle reported exit $LASTEXITCODE" 'Yellow' }
Start-Sleep -Seconds 2
}
# A stopped SITE answers nothing however healthy the pool is.
try {
$siteNow = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
if ($siteNow -and $siteNow.State -ne 'Started') {
Say ' the site is stopped; starting it' 'Yellow'
Start-Website -Name $SiteName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
}
} catch { }
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 }
$usingDefault = -not $Dest
if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }
# A dump holds every row, including the users table and its password hashes.
# A directory created under ProgramData INHERITS Users:RX, so those hashes
# were readable by every authenticated user on the server whenever this
# command created the directory rather than the installer.
#
# Re-applied on every backup, not only on creation, because this may be
# repairing a directory made by an earlier version.
#
# Only for the default location. A path the operator named is theirs, and
# silently rewriting its ACL is not this command's business - say so instead.
if ($usingDefault) {
& icacls.exe $Dest '/inheritance:r' `
'/grant' 'BUILTIN\Administrators:(OI)(CI)(F)' `
'/grant' 'NT AUTHORITY\SYSTEM:(OI)(CI)(F)' 2>&1 | Out-Null
} else {
Say ' note: this dump contains password hashes - check who can read that directory' 'Yellow'
}
$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'
# The dump is NOT everything. Uploaded branding, map blueprints and
# generated files live in instance\ on disk, not in the database, so a
# restore from the .sql alone comes back with no floor map. Saying "all of
# your asset data" was true and misleading at the same time.
$instance = Join-Path $AppRoot 'instance'
if (Test-Path $instance) {
$zip = [System.IO.Path]::ChangeExtension($file, $null) + 'instance.zip'
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
if (Test-Path $zip) { Remove-Item $zip -Force }
[System.IO.Compression.ZipFile]::CreateFromDirectory($instance, $zip)
Say (" uploaded files: {0:N1} MB -> {1}" -f ((Get-Item $zip).Length / 1MB), (Split-Path $zip -Leaf)) 'Green'
} catch {
Say (" could not archive instance\: {0}" -f $_.Exception.Message) 'Yellow'
Say ' copy it by hand - it holds branding and floor-map images' 'Yellow'
}
}
Say ''
Say ' Store BOTH files off this server. The .sql holds the records; the' 'Yellow'
Say ' .zip holds uploaded branding and floor-map images, which the' 'Yellow'
Say ' database does not. A restore needs both.' 'Yellow'
Say ''
Say ' Contains user password hashes - treat it as sensitive.' '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 }
# Surface an unfinished install HERE, where somebody is already looking,
# rather than leaving them to infer it from 500s on unrelated pages.
$state = Test-Provisioned
if ($state.SchemaCurrent -eq $false -or $state.Seeded -eq $false) {
Say ''
Say ' THIS SERVER IS NOT FULLY PROVISIONED' 'Red'
foreach ($detail in $state.Detail) { Say (" - {0}" -f $detail) 'Red' }
Say ' Most pages will answer 500 until this is fixed. Run:' 'Yellow'
Say ' shopdb-admin.ps1 repair' 'White'
}
Say ''
Say ' For help from an AI assistant, paste the output of:' 'DarkGray'
Say ' shopdb-admin.ps1 check -Json' 'White'
}
function Test-Provisioned {
"""Is the schema current and the reference data seeded?"""
# A server whose migrations or seeds never completed does not fail politely:
# it answers 500 on most pages and 404 on settings that were never created,
# which reads as a broken application rather than an unfinished install. One
# site was stood up that way and spent a morning being debugged as a bug.
$result = @{ SchemaCurrent = $true; Seeded = $true; Detail = @() }
$out = Invoke-Flask @('db','current')
$current = ($out | Out-String)
$head = (Invoke-Flask @('db','heads') | Out-String)
if ($script:LastFlaskExit -ne 0) {
$result.SchemaCurrent = $false
$result.Detail += 'could not read the schema version'
} elseif ($current -notmatch '\(head\)' -and $head.Trim()) {
# `db current` appends "(head)" when the database is at the newest
# revision. Its absence means migrations are outstanding.
$result.SchemaCurrent = $false
$result.Detail += 'database schema is behind the application'
}
# Sentinel seeds. permissions, settings and the reference data are each
# created by a `flask seed` command, and their absence is what produced the
# 404s on settings keys at a site whose install never finished.
$seedOut = (Invoke-Flask @('db-utils','seed-state') | Out-String)
if ($seedOut -match 'No such command') {
# An older build with no seed-state. Report UNKNOWN rather than healthy:
# claiming a clean bill of health from a check that did not run is how a
# broken server passes inspection.
$result.Seeded = $null
$result.Detail += 'seed state could not be checked (older build)'
} elseif ($script:LastFlaskExit -ne 0 -or $seedOut -match 'MISSING') {
$result.Seeded = $false
$result.Detail += 'seed data is missing (permissions, settings or reference data)'
}
return $result
}
function Invoke-Repair {
Head 'Repair provisioning'
Say ' Brings the database up to the application: migrations, plugin'
Say ' migrations, and the seed data. Every step is idempotent, so running'
Say ' this on a healthy server changes nothing.'
Say ''
$steps = @(
@{ Name = 'core schema'; Args = @('db','upgrade') },
@{ Name = 'plugin schemas'; Args = @('plugin','upgrade-all') },
@{ Name = 'permissions'; Args = @('seed','permissions') },
@{ Name = 'settings'; Args = @('seed','settings') },
@{ Name = 'reference data'; Args = @('seed','reference-data') }
)
$failed = @()
foreach ($step in $steps) {
Say (" {0} ..." -f $step.Name) 'White'
$out = Invoke-Flask $step.Args
if ($script:LastFlaskExit -ne 0) {
$failed += $step.Name
Say (" FAILED (exit {0})" -f $script:LastFlaskExit) 'Red'
$out | Select-Object -Last 6 | ForEach-Object { Say " $_" 'Red' }
} else {
Say ' done' 'Green'
}
}
if ($failed.Count -gt 0) {
Say ''
Say (" {0} step(s) failed: {1}" -f $failed.Count, ($failed -join ', ')) 'Red'
Say ' Nothing further was skipped - each step ran independently.' 'Red'
Say ' Send the output above, or run: shopdb-admin.ps1 check -Json' 'Red'
return
}
Say ''
Say ' Provisioning complete. Restarting the application.' 'Green'
Restart-App
}
function Invoke-Catalog {
Head 'Load the shared equipment catalog'
Say ' Vendors, model numbers, printer supply part numbers and the type'
Say ' lists, as built up across the other sites. It only ADDS - nothing'
Say ' already here is changed or removed - so it is safe to run at any'
Say ' time, including on a site that has been running for years.'
Say ''
Say ' Nothing site-specific is included: no assets, locations or people.'
Say ''
# Dry run FIRST, always. Somebody running this on a populated site deserves
# to see what it would touch before it touches anything.
Say ' Checking what would be added...' 'White'
$preview = Invoke-Flask @('seed','catalog','--dry-run')
if ($script:LastFlaskExit -ne 0) {
Say ' could not read the catalog' 'Red'
$preview | Select-Object -Last 8 | ForEach-Object { Say " $_" 'Red' }
Say ''
Say ' An older build may not have this command. Update first.' 'Yellow'
return
}
$preview | ForEach-Object { Say " $_" }
if (($preview | Out-String) -match 'nothing to add') {
Say ''
Say ' Already loaded. Nothing to do.' 'Green'
return
}
Say ''
$answer = Read-Host ' Load it? (yes/no)'
if ($answer -ne 'yes') { Say ' Left alone.' 'Yellow'; return }
$out = Invoke-Flask @('seed','catalog')
$out | ForEach-Object { Say " $_" }
if ($script:LastFlaskExit -ne 0) {
Say ''
Say ' Catalog load FAILED. Nothing else was changed.' 'Red'
return
}
Say ''
Say ' Catalog loaded.' 'Green'
}
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)) } }
}
# Set by every Invoke-Flask call. Callers MUST test this rather than
# $LASTEXITCODE: when flask.exe is missing no native command runs at all, so
# $LASTEXITCODE keeps whatever value it had from something earlier - which reads
# as success and made a command that did nothing report that it had worked.
$script:LastFlaskExit = 0
function Invoke-Flask {
param([string[]] $Arguments)
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
if (-not (Test-Path $flask)) {
Say ' application not installed' 'Red'
$script:LastFlaskExit = 127
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
$script:LastFlaskExit = $LASTEXITCODE
}
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
}
# apply-profile, not plugin install. Five plugins ship default_enabled=false,
# so `install` alone left them installed-but-disabled: the command printed a
# green success line and the feature did not appear anywhere in the UI.
# apply-profile installs AND enables, and pulls in the dependency closure.
Say (" adding {0}..." -f $Name)
$profilePath = Join-Path $AppRoot 'site-profile.json'
$applied = $false
if (Test-Path $profilePath) {
try {
$profile = Get-Content $profilePath -Raw | ConvertFrom-Json
$wanted = @($profile.plugins)
if ($wanted -notcontains $Name) { $wanted += $Name }
$profile.plugins = $wanted
$profile | ConvertTo-Json -Depth 6 | Set-Content -Path $profilePath -Encoding UTF8
Invoke-Flask @('plugin','apply-profile',$profilePath) | ForEach-Object { Say " $_" }
$applied = ($script:LastFlaskExit -eq 0)
} catch { Say (" could not update site-profile.json: {0}" -f $_.Exception.Message) 'Yellow' }
}
if (-not $applied) {
# No profile on disk, or apply-profile failed: fall back, but enable
# explicitly so the outcome is the same either way.
Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say " $_" }
$installed = ($script:LastFlaskExit -eq 0)
Invoke-Flask @('plugin','enable',$Name) | ForEach-Object { Say " $_" }
$applied = $installed -and ($script:LastFlaskExit -eq 0)
}
if (-not $applied) {
# Do NOT print the green line on a failure. It used to be unconditional,
# so a failed add reported success and the operator went looking for a
# feature that was never enabled.
Say (" {0} was NOT added - see the output above" -f $Name) 'Red'
return
}
Say ' applying its database migrations...'
Invoke-Flask @('plugin','upgrade-all') | ForEach-Object { Say " $_" }
if ($script:LastFlaskExit -ne 0) {
Say ' migrations FAILED - the feature is installed but its tables are not' 'Red'
Say ' do not use it until this is resolved; restore a backup if needed' 'Red'
return
}
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 ' R Repair provisioning C Load equipment catalog' -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 }
'r' { Invoke-Repair } 'R' { Invoke-Repair }
'c' { Invoke-Catalog } 'C' { Invoke-Catalog }
'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 }
'repair' { Invoke-Repair }
'catalog' { Invoke-Catalog }
'sessions' { Show-Sessions }
'plugins' { Show-Plugins }
'add-plugin'{ Add-Plugin $Path }
'verify' { Invoke-Verify }
'uninstall' { Show-Uninstall }
default { Show-Menu }
}