The bundled-database option could not actually be built. Stage 0 looks for mysql\mysql-8.0.x-winx64.msi, and Oracle no longer publishes a standalone server MSI for 8.0 - every 8.0.x returns 404. What remains for 8.0 is the MySQL Installer bundle, which is an installer-manager: 'msiexec /i INSTALLDIR=' would install THAT rather than a database, and stage 0 would then fail on a missing mysqld.exe. MySQL 8.0 also reached end of life in April 2026, so bundling it would have put an unsupported database on every new site. 8.4 LTS still ships the standalone MSI (129MB, which is what the '125MB' note in stage 0 was written against) and is supported into 2032. Defaults follow it: install root MySQL Server 8.4, service MySQL84. The operator console still looks for an 8.0 install path as a fallback, for sites already running one. Also bundles mysqlclient\ - mysql.exe and mysqldump.exe with the two OpenSSL DLLs they actually import, 20MB rather than the 51MB of debug and auth-plugin libraries the archive ships. Stage 2 stages it onto the server, so a site whose database is on ANOTHER host can still take the pre-upgrade backup that every upgrade depends on. That was the gap the preflight had started warning about. Bundle is now 221MB.
818 lines
37 KiB
PowerShell
818 lines
37 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.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'
|
|
# 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'
|
|
|
|
# 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 }
|
|
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)) } }
|
|
}
|
|
|
|
|
|
# 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 ' 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 }
|
|
}
|