A server whose migrations or seeds never finished does not fail politely. Most
pages answer 500 and settings endpoints answer 404 for keys that were never
created, which reads as a broken application rather than an unfinished install.
One site spent a morning being debugged that way.
`shopdb-admin.ps1 repair` runs what stage 3 of the installer runs: db upgrade,
plugin upgrade-all, and the three seeds. Every step is idempotent, so running it
on a healthy server changes nothing, and each step runs independently so one
failure does not silently skip the rest.
`check` now says so before anyone has to infer it:
THIS SERVER IS NOT FULLY PROVISIONED
- seed data is missing (permissions, settings or reference data)
Most pages will answer 500 until this is fixed. Run:
shopdb-admin.ps1 repair
That needs a real test to sit on, so `flask db-utils seed-state` reports each
seed group and exits non-zero when any is missing. Verified by emptying the
settings table inside a transaction: MISSING, exit 1, rollback clean. Without it
the console check would have looked reassuring while testing nothing - an older
build with no such command reports UNKNOWN rather than healthy, for the same
reason.
957 lines
43 KiB
PowerShell
957 lines
43 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','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 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 }
|
|
'repair' { Invoke-Repair }
|
|
'sessions' { Show-Sessions }
|
|
'plugins' { Show-Plugins }
|
|
'add-plugin'{ Add-Plugin $Path }
|
|
'verify' { Invoke-Verify }
|
|
'uninstall' { Show-Uninstall }
|
|
default { Show-Menu }
|
|
}
|