IIS does not set X-Forwarded-For on its own and HttpPlatformHandler connects from loopback, so without a rewrite rule every client reads as 127.0.0.1. The GE-Enforce IP allowlist, the dashboard visitor-location lookup and per-host login rate limiting all stop working, silently. The rule needed URL Rewrite, which the installer told operators to download - from an air-gapped server. URL Rewrite now ships in the bundle, and the wizard asks which case applies, because the two answers are mutually exclusive. Directly exposed: install it and set X-Forwarded-For from REMOTE_ADDR, which is what stops a client spoofing its own. Behind a proxy: leave the rule off, since REMOTE_ADDR is the proxy and applying it would discard the real client IP. The rule is enabled by deleting two explicit marker lines rather than by a regex over the surrounding comment, so editing that prose cannot silently disable it. An existing web.config is no longer overwritten. It is the one file on a server that legitimately carries hand-edits, and replacing it reverted them without a word - on a server where the X-Forwarded-For rule had been enabled by hand, that alone would have turned the GE-Enforce IP allowlist off. The installer reports what it found instead. pip now runs with --require-hashes and --only-binary=:all:. Hash-checking is requested explicitly rather than inferred from the lockfile, so shipping an unhashed requirements.txt fails loudly instead of quietly dropping the check. shopdb-admin.ps1 gains a verify command: which bundle this server was installed from, and whether the installed packages still match what shipped. The .iss states its compiler floor. WizardStyle uses the built-in windows11 custom style, which needs Inno Setup 6.6.0; older compilers now fail with that sentence rather than 'WizardStyle is invalid'.
596 lines
26 KiB
PowerShell
596 lines
26 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 = '',
|
|
[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.
|
|
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
|
|
$native = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
|
|
if (Test-Path $native) {
|
|
$relaunch = @('-NoProfile', '-ExecutionPolicy', 'Bypass',
|
|
'-File', ('"' + $PSCommandPath + '"'), $Command)
|
|
if ($Path) { $relaunch += @('-Path', ('"' + $Path + '"')) }
|
|
Start-Process -FilePath $native -ArgumentList $relaunch -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)
|
|
if (-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', '-NoProfile', '-ExecutionPolicy', 'Bypass',
|
|
'-File', ('"' + $PSCommandPath + '"'), $Command)
|
|
if ($Path) { $argList += @('-Path', ('"' + $Path + '"')) }
|
|
try {
|
|
Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs | Out-Null
|
|
} catch {
|
|
Write-Host ''
|
|
Write-Host ' Elevation was declined.' -ForegroundColor Red
|
|
Write-Host ' Right-click the shortcut and choose "Run as administrator".' -ForegroundColor Red
|
|
Read-Host ' Press Enter to close' | Out-Null
|
|
}
|
|
return
|
|
}
|
|
|
|
|
|
# --- brand header ----------------------------------------------------------
|
|
# Deliberately typographic, NOT ASCII art. The GE monogram is fine cursive
|
|
# linework; rendered as block characters at console resolution it reads as noise,
|
|
# which looks worse than no mark at all. A clean rule and correct wordmark says
|
|
# "considered"; mushy art says the opposite.
|
|
#
|
|
# Box-drawing characters used here are all in code page 437 (the Windows console
|
|
# default), and this file is saved UTF-8 WITH BOM so PowerShell 5.1 reads them
|
|
# correctly rather than assuming the ANSI code page.
|
|
function Show-Banner {
|
|
$rule = ([string][char]0x2500) * 58
|
|
Write-Host ''
|
|
Write-Host ' GE AEROSPACE' -ForegroundColor Blue
|
|
Write-Host (' ' + $rule) -ForegroundColor DarkGray
|
|
Write-Host ' ShopDB-Flask' -ForegroundColor White
|
|
Write-Host ' Asset management for the shop floor' -ForegroundColor DarkGray
|
|
Write-Host ''
|
|
}
|
|
|
|
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
|
function Head {
|
|
param($m)
|
|
Write-Host ''
|
|
Write-Host (' ' + $m) -ForegroundColor Cyan
|
|
Write-Host (' ' + (([string][char]0x2500) * $m.Length)) -ForegroundColor DarkGray
|
|
}
|
|
|
|
function Get-EnvValue {
|
|
param([string] $Key)
|
|
$envFile = Join-Path $AppRoot '.env'
|
|
if (-not (Test-Path $envFile)) { return '' }
|
|
$line = Get-Content $envFile | Where-Object { $_ -like "$Key=*" } | Select-Object -First 1
|
|
if ($line) { return $line.Substring($Key.Length + 1) }
|
|
return ''
|
|
}
|
|
|
|
function Get-Deployment {
|
|
<#
|
|
Which way was this installed?
|
|
|
|
Method A: its own IIS site on $SitePort.
|
|
Method B: an IIS Application under an existing site, at /<alias>, reached
|
|
on that site's port. MOUNT_PATH in .env is what distinguishes
|
|
them - it is the same value wsgi.py uses to mount the app.
|
|
|
|
Without this the console looked for a SITE that method B never creates and
|
|
reported "web site: not installed" on a perfectly healthy server, then
|
|
probed the wrong port and said it was not responding.
|
|
#>
|
|
$mount = (Get-EnvValue 'MOUNT_PATH').Trim()
|
|
if (-not $mount) {
|
|
return @{ Subpath = $false; Alias = ''; BaseUrl = "http://localhost:$SitePort"; Port = $SitePort }
|
|
}
|
|
$alias = $mount.Trim('/')
|
|
$port = 80
|
|
$parent = 'Default Web Site'
|
|
try {
|
|
Import-Module WebAdministration -ErrorAction Stop
|
|
foreach ($site in (Get-Website)) {
|
|
$app = Get-WebApplication -Site $site.Name -Name $alias -ErrorAction SilentlyContinue
|
|
if ($app) {
|
|
$parent = $site.Name
|
|
$b = $site.bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
|
|
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $port = [int]$Matches[1] }
|
|
break
|
|
}
|
|
}
|
|
} catch { }
|
|
$base = "http://localhost:$port/$alias"
|
|
if ($port -eq 80) { $base = "http://localhost/$alias" }
|
|
return @{ Subpath = $true; Alias = $alias; Parent = $parent; BaseUrl = $base; Port = $port }
|
|
}
|
|
|
|
function Get-DbParts {
|
|
# Pull host/port/name/user out of DATABASE_URL without printing the password.
|
|
$url = Get-EnvValue 'DATABASE_URL'
|
|
if ($url -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') {
|
|
# The password is PERCENT-ENCODED in DATABASE_URL (the installer applies
|
|
# [uri]::EscapeDataString). SQLAlchemy unescapes it; so must we. Without
|
|
# this, every command here fails to authenticate whenever the password
|
|
# contains a space, @, %, ! or /, and the console reports the database as
|
|
# unreachable on a server where the application is running perfectly.
|
|
return @{ User = [uri]::UnescapeDataString($Matches[1])
|
|
Pass = [uri]::UnescapeDataString($Matches[2])
|
|
Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Find-MysqlClient {
|
|
$candidates = @(
|
|
'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
|
|
'C:\mysql56\bin\mysql.exe'
|
|
) + @(Get-ChildItem 'C:\Program Files\MySQL' -Filter mysql.exe -Recurse -EA SilentlyContinue |
|
|
Select-Object -ExpandProperty FullName)
|
|
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } }
|
|
return ''
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
function Show-Status {
|
|
Head 'Status'
|
|
|
|
$deploy = Get-Deployment
|
|
$siteState = 'not installed'
|
|
$poolState = 'not installed'
|
|
try {
|
|
Import-Module WebAdministration -ErrorAction Stop
|
|
if ($deploy.Subpath) {
|
|
# Method B: there is no site of our own - we are an Application.
|
|
$app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
|
|
if ($app) {
|
|
$parentSite = Get-Website -Name $deploy.Parent -ErrorAction SilentlyContinue
|
|
$siteState = ("/{0} under '{1}' ({2})" -f $deploy.Alias, $deploy.Parent,
|
|
$(if ($parentSite) { $parentSite.State } else { 'unknown' }))
|
|
}
|
|
} else {
|
|
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
|
if ($site) { $siteState = $site.State }
|
|
}
|
|
if (Test-Path "IIS:\AppPools\$AppPool") { $poolState = (Get-Item "IIS:\AppPools\$AppPool").State }
|
|
} catch {
|
|
# Should be unreachable now that the script self-elevates, but if IIS is
|
|
# genuinely absent say THAT, rather than implying ShopDB is missing.
|
|
# Name the real cause instead of implying ShopDB is missing.
|
|
if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) {
|
|
$siteState = 'IIS is not installed on this server'
|
|
} else {
|
|
$siteState = 'could not read IIS - ' + $_.Exception.Message
|
|
}
|
|
$poolState = $siteState
|
|
}
|
|
|
|
$colour = if (($siteState -eq 'Started') -or ($siteState -like '*Started*')) { 'Green' } else { 'Yellow' }
|
|
Say (" published as : {0}" -f $siteState) $colour
|
|
Say (" application pool: {0}" -f $poolState) $colour
|
|
Say (" installed at : {0}" -f $(if (Test-Path $AppRoot) { $AppRoot } else { 'not found' }))
|
|
|
|
# Does it actually answer? A "Started" pool proves nothing. Under method B
|
|
# this must include the mount path, or it tests a URL that never existed.
|
|
$url = $deploy.BaseUrl + '/'
|
|
try {
|
|
$r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 20
|
|
Say (" responding : yes (HTTP {0})" -f $r.StatusCode) 'Green'
|
|
} catch {
|
|
Say ' responding : NO' 'Red'
|
|
Say (" {0}" -f $_.Exception.Message) 'DarkGray'
|
|
}
|
|
|
|
$db = Get-DbParts
|
|
if ($db) {
|
|
Say (" database : {0} on {1}:{2} as {3}" -f $db.Name, $db.Host, $db.Port, $db.User)
|
|
$mysql = Find-MysqlClient
|
|
if ($mysql) {
|
|
$q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
|
|
$n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
|
|
if ($LASTEXITCODE -eq 0) { Say (" tables : {0}" -f $n) 'Green' }
|
|
else { Say ' tables : could not connect' 'Red' }
|
|
}
|
|
} else { Say ' database : no .env found' 'Yellow' }
|
|
|
|
# Is anyone set up yet?
|
|
try {
|
|
$na = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/api/setup/needs-admin') -UseBasicParsing -TimeoutSec 15
|
|
if ($na.Content -match '"needsadmin"\s*:\s*true') {
|
|
Say ' first run : NOT SET UP - open the site to create the first administrator' 'Yellow'
|
|
} else { Say ' first run : complete' 'Green' }
|
|
} catch { }
|
|
|
|
Say ''
|
|
if ($deploy.Subpath) {
|
|
$shown = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias
|
|
if ($deploy.Port -ne 80) { $shown = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
|
|
} else {
|
|
$shown = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
|
|
}
|
|
Say (" open with : {0}" -f $shown) 'White'
|
|
}
|
|
|
|
function Start-App {
|
|
Head 'Starting'
|
|
$deploy = Get-Deployment
|
|
& $AppCmd start apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
|
if (-not $deploy.Subpath) {
|
|
& $AppCmd start site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
|
|
} else {
|
|
# Method B shares the parent site - starting or stopping THAT would take
|
|
# every other application on this server with it.
|
|
Say (" (published under '{0}' - only the application pool is ours to control)" -f $deploy.Parent) 'DarkGray'
|
|
}
|
|
Say ' started' 'Green'
|
|
}
|
|
|
|
function Stop-App {
|
|
Head 'Stopping'
|
|
$deploy = Get-Deployment
|
|
Say ' ShopDB-Flask will be unavailable until you start it again.' 'Yellow'
|
|
if (-not $deploy.Subpath) {
|
|
& $AppCmd stop site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
|
|
} else {
|
|
Say (" (leaving site '{0}' running - stopping it would take down everything else on it)" -f $deploy.Parent) 'DarkGray'
|
|
}
|
|
& $AppCmd stop apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
|
Say ' stopped' 'Yellow'
|
|
}
|
|
|
|
function Restart-App {
|
|
Head 'Restarting'
|
|
# Recycle rather than stop/start: it drains existing requests instead of
|
|
# cutting them off, and it is what a config change actually needs.
|
|
& $AppCmd recycle apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
|
Start-Sleep -Seconds 2
|
|
try {
|
|
$r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30
|
|
Say (" back up (HTTP {0})" -f $r.StatusCode) 'Green'
|
|
} catch { Say ' did not respond after restart - run: shopdb-admin.ps1 logs' 'Red' }
|
|
}
|
|
|
|
function Show-Logs {
|
|
Head 'Recent logs'
|
|
$appLog = Join-Path $AppRoot 'logs'
|
|
if (Test-Path $appLog) {
|
|
$newest = Get-ChildItem "$appLog\*.log" -EA SilentlyContinue |
|
|
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
|
if ($newest) {
|
|
Say (" application: {0}" -f $newest.FullName) 'White'
|
|
Get-Content $newest.FullName -Tail 20 | ForEach-Object { Say " $_" }
|
|
} else { Say ' no application log yet' }
|
|
}
|
|
$inst = 'C:\ProgramData\ShopDB-Flask\logs'
|
|
if (Test-Path $inst) {
|
|
$newest = Get-ChildItem "$inst\*.log" -EA SilentlyContinue |
|
|
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
|
if ($newest) {
|
|
Say ''
|
|
Say (" install: {0}" -f $newest.FullName) 'White'
|
|
Get-Content $newest.FullName -Tail 12 | ForEach-Object { Say " $_" }
|
|
}
|
|
}
|
|
}
|
|
|
|
function Open-Site {
|
|
$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 in your browser' 'Green'
|
|
}
|
|
|
|
function Backup-Db {
|
|
param([string] $Dest)
|
|
Head 'Database backup'
|
|
$db = Get-DbParts
|
|
if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return }
|
|
if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
|
|
if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }
|
|
|
|
$mysql = Find-MysqlClient
|
|
if (-not $mysql) { Say ' mysql client not found' 'Red'; return }
|
|
$dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe'
|
|
if (-not (Test-Path $dump)) { Say ' mysqldump not found' 'Red'; return }
|
|
|
|
$file = Join-Path $Dest ("shopdb_flask-{0}.sql" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
|
Say (" writing {0}" -f $file)
|
|
# Redirect rather than --result-file: keeps it working on 5.6 and 8.0 alike.
|
|
# Do NOT pipe mysqldump through the PowerShell pipeline into Out-File.
|
|
# PowerShell 5.1 writes a UTF-8 BOM and re-encodes the stream through the
|
|
# console code page, producing a dump MySQL refuses to load and mangling any
|
|
# non-ASCII data - discovered only when the backup is finally needed.
|
|
# Redirect the process's stdout straight to the file instead.
|
|
$args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)",
|
|
'--single-transaction', '--routines', '--triggers', $db.Name)
|
|
$quoted = $args | ForEach-Object {
|
|
if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
|
|
}
|
|
$err = [System.IO.Path]::GetTempFileName()
|
|
try {
|
|
$proc = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow `
|
|
-RedirectStandardOutput $file -RedirectStandardError $err
|
|
if ($proc.ExitCode -ne 0) {
|
|
Say (" mysqldump failed (exit {0})" -f $proc.ExitCode) 'Red'
|
|
Get-Content $err -Tail 3 -EA SilentlyContinue | ForEach-Object { Say (" " + $_) 'DarkGray' }
|
|
return
|
|
}
|
|
} finally { Remove-Item $err -Force -ErrorAction SilentlyContinue }
|
|
|
|
# Verify it is complete rather than merely present.
|
|
$tail = @(Get-Content $file -Tail 5 -ErrorAction SilentlyContinue)
|
|
if ((Test-Path $file) -and ((Get-Item $file).Length -gt 1024) -and ($tail -match 'Dump completed')) {
|
|
Say (" done - {0:N1} MB, verified complete" -f ((Get-Item $file).Length / 1MB)) 'Green'
|
|
Say ' Store this off the server. It contains all of your asset data.' 'Yellow'
|
|
} else {
|
|
Say ' backup is empty or truncated - do NOT rely on it' 'Red'
|
|
Remove-Item $file -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Invoke-Check {
|
|
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 }
|
|
}
|
|
|
|
function Show-Sessions {
|
|
Head 'Worker processes'
|
|
$w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
|
|
if (-not $w) { Say ' no IIS worker running (the site starts one on first request)' }
|
|
else {
|
|
foreach ($p in $w) {
|
|
Say (" pid {0} {1:N0} MB started {2}" -f $p.ProcessId,
|
|
($p.WorkingSetSize/1MB), $p.CreationDate)
|
|
}
|
|
}
|
|
$py = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -EA SilentlyContinue |
|
|
Where-Object { $_.CommandLine -like "*$AppRoot*" }
|
|
if ($py) { foreach ($p in $py) { Say (" python pid {0} {1:N0} MB" -f $p.ProcessId, ($p.WorkingSetSize/1MB)) } }
|
|
}
|
|
|
|
|
|
function Invoke-Flask {
|
|
param([string[]] $Arguments)
|
|
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
|
|
if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return $null }
|
|
Push-Location $AppRoot
|
|
$env:FLASK_APP = 'shopdb'
|
|
# The app logs plugin startup to STDERR even on success; with EAP=Stop that
|
|
# becomes a terminating error and a healthy command looks like a failure.
|
|
$prev = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
try { & $flask @Arguments 2>&1 }
|
|
finally { $ErrorActionPreference = $prev; Pop-Location }
|
|
}
|
|
|
|
function Show-Plugins {
|
|
Head 'Plugins'
|
|
Invoke-Flask @('plugin','list') | ForEach-Object { Say " $_" }
|
|
|
|
# What is on disk but NOT installed can still be added here. What is absent
|
|
# from disk cannot - see the note below.
|
|
$dir = Join-Path $AppRoot 'plugins'
|
|
if (Test-Path $dir) {
|
|
$onDisk = (Get-ChildItem $dir -Directory -EA SilentlyContinue |
|
|
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
|
|
Select-Object -ExpandProperty Name) -join ', '
|
|
Say ''
|
|
Say (" shipped in this build : {0}" -f $onDisk) 'White'
|
|
}
|
|
Say ''
|
|
Say ' Add one that is shipped : shopdb-admin.ps1 add-plugin -Path <name>' 'White'
|
|
Say ''
|
|
Say ' A plugin NOT listed above is not on this server at all. This build was' 'DarkGray'
|
|
Say ' made for your site''s chosen plugin set, so its code was never shipped.' 'DarkGray'
|
|
Say ' Adding one means a new installer built from an updated site profile.' 'DarkGray'
|
|
}
|
|
|
|
function Add-Plugin {
|
|
param([string] $Name)
|
|
Head 'Add a plugin'
|
|
if (-not $Name) { Say ' usage: shopdb-admin.ps1 add-plugin -Path <plugin-name>' 'Yellow'; return }
|
|
|
|
$dir = Join-Path $AppRoot ('plugins\' + $Name)
|
|
if (-not (Test-Path (Join-Path $dir 'manifest.json'))) {
|
|
Say (" '{0}' is not present on this server." -f $Name) 'Red'
|
|
Say ''
|
|
Say ' This build ships only the plugins your site chose. Adding a new one' 'Yellow'
|
|
Say ' requires a new installer built from an updated site profile - the' 'Yellow'
|
|
Say ' code is not here to install.' 'Yellow'
|
|
Say ''
|
|
Say ' Run "shopdb-admin.ps1 plugins" to see what IS available.' 'White'
|
|
return
|
|
}
|
|
|
|
Say (" installing {0}..." -f $Name)
|
|
Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say " $_" }
|
|
Say ' applying its database migrations...'
|
|
Invoke-Flask @('plugin','upgrade-all') | ForEach-Object { Say " $_" }
|
|
Say ' restarting so its routes register...'
|
|
Restart-App
|
|
Say (" {0} added" -f $Name) 'Green'
|
|
}
|
|
|
|
function Invoke-Verify {
|
|
<#
|
|
Prove the installed dependencies are still the ones that shipped.
|
|
|
|
Two independent records, both written at install time:
|
|
requirements.txt a sha256 for every wheel, checked by pip on install
|
|
bundle-lock.json the exact third-party payload of the bundle
|
|
|
|
This re-checks what can still be checked on a live server. It reports; it
|
|
changes nothing.
|
|
#>
|
|
Head 'Verify'
|
|
|
|
$lock = Join-Path $AppRoot 'bundle-lock.json'
|
|
if (Test-Path $lock) {
|
|
try {
|
|
$l = Get-Content $lock -Raw | ConvertFrom-Json
|
|
Say (" installed from : {0} bundle ({1}, {2})" -f $l.generated, $l.pythontag, $l.platform)
|
|
} catch { Say ' bundle-lock.json is present but unreadable' 'Yellow' }
|
|
} else {
|
|
Say ' no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow'
|
|
}
|
|
|
|
# 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 }
|
|
}
|