Give the console a repair verb, and something real to check

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.
This commit is contained in:
cproudlock
2026-08-05 13:16:24 -04:00
parent 705dd771bd
commit ead5bd8f58
2 changed files with 327 additions and 1 deletions

View File

@@ -22,7 +22,7 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[ValidateSet('menu','status','start','stop','restart','logs','open','backup', [ValidateSet('menu','status','start','stop','restart','logs','open','backup',
'check','sessions','plugins','add-plugin','verify','uninstall')] 'check','repair','sessions','plugins','add-plugin','verify','uninstall')]
[string] $Command = 'menu', [string] $Command = 'menu',
[string] $Path = '', [string] $Path = '',
# Machine-readable output for 'check'. The people running this are expected to # Machine-readable output for 'check'. The people running this are expected to
@@ -577,11 +577,101 @@ function Invoke-Check {
$env:FLASK_APP = 'shopdb' $env:FLASK_APP = 'shopdb'
try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } } try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } }
finally { Pop-Location } 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 ''
Say ' For help from an AI assistant, paste the output of:' 'DarkGray' Say ' For help from an AI assistant, paste the output of:' 'DarkGray'
Say ' shopdb-admin.ps1 check -Json' 'White' 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 { function Show-Sessions {
Head 'Worker processes' Head 'Worker processes'
$w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue $w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
@@ -856,6 +946,7 @@ switch ($Command) {
'open' { Open-Site } 'open' { Open-Site }
'backup' { Backup-Db $Path } 'backup' { Backup-Db $Path }
'check' { Invoke-Check } 'check' { Invoke-Check }
'repair' { Invoke-Repair }
'sessions' { Show-Sessions } 'sessions' { Show-Sessions }
'plugins' { Show-Plugins } 'plugins' { Show-Plugins }
'add-plugin'{ Add-Plugin $Path } 'add-plugin'{ Add-Plugin $Path }

View File

@@ -10,6 +10,58 @@ def db_cli():
pass pass
@db_cli.command('seed-state')
@with_appcontext
def seed_state():
"""Report whether the seed data a working install depends on is present.
A server whose seeds never ran does not fail politely: settings endpoints
answer 404 for keys that were never created and most pages answer 500, which
reads as a broken application rather than an unfinished install. This gives
the operator console something definite to test, so it can say "run repair"
instead of leaving somebody to infer it from unrelated errors.
Prints one line per group and exits non-zero if anything is missing, so it
can be used as a gate as well as read by a person.
"""
import sys
from shopdb.extensions import db
from sqlalchemy import text
# Sentinels, not exhaustive counts. Each is created by one of the three seed
# commands, so a zero here means that command never ran.
checks = [
('permissions', 'SELECT COUNT(*) FROM permissions', 'flask seed permissions'),
('settings', 'SELECT COUNT(*) FROM settings', 'flask seed settings'),
('asset types', 'SELECT COUNT(*) FROM assettypes', 'flask seed reference-data'),
('location types', 'SELECT COUNT(*) FROM locationtypes', 'flask seed reference-data'),
]
missing = []
for label, sql, remedy in checks:
try:
count = db.session.execute(text(sql)).scalar() or 0
except Exception as exc:
click.echo(click.style(' MISSING ', fg='red') +
'%s - table unreadable (%s)' % (label, type(exc).__name__))
missing.append((label, remedy))
continue
if count == 0:
click.echo(click.style(' MISSING ', fg='red') +
'%s - none present, run: %s' % (label, remedy))
missing.append((label, remedy))
else:
click.echo(click.style(' OK ', fg='green') + '%s (%d)' % (label, count))
if missing:
click.echo('')
click.echo(click.style('%d group(s) missing. This server is not fully provisioned.'
% len(missing), fg='red'))
sys.exit(1)
click.echo('')
click.echo(click.style('Seed data present.', fg='green'))
@db_cli.command('create-all') @db_cli.command('create-all')
@with_appcontext @with_appcontext
def create_all(): def create_all():
@@ -147,6 +199,189 @@ def seed_cli():
pass pass
@seed_cli.command('catalog')
@click.option('--file', 'path', default=None,
help='catalog JSON to load (default shopdb/data/catalog.json)')
@click.option('--dry-run', is_flag=True, help='report what would be added, write nothing')
@with_appcontext
def seed_catalog(path, dry_run):
"""Load the shared vendor/model catalog shipped with the product.
`seed reference-data` writes a dozen generic model types and no vendors or
models, so every new site began by retyping a catalog another site had
already built. This loads that catalog instead.
IDEMPOTENT and ADDITIVE. Records are matched by natural key - a vendor by
name, a model by vendor plus model number, a type by its name - so running
it twice adds nothing the second time. It never updates or deletes an
existing record: a site that has corrected a description or pointed a model
at its own photo keeps its version.
Catalog only. Nothing here identifies a site: no assets, locations,
employees or serial numbers.
"""
import json
import os
from shopdb.extensions import db
from shopdb.core.models import ModelType, OperatingSystem, LocationType, Vendor, Model
if not path:
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'data', 'catalog.json')
if not os.path.isfile(path):
click.echo(click.style('No catalog file at %s' % path, fg='red'))
raise SystemExit(1)
with open(path, encoding='utf-8') as handle:
data = json.load(handle)
added = {}
def note(kind, count):
if count:
added[kind] = added.get(kind, 0) + count
def simple(key, model, column):
"""Type vocabularies: one row per name."""
records = data.get(key) or []
count = 0
for record in records:
name = record.get(column)
if not name:
continue
if db.session.query(model).filter_by(**{column: name}).first():
continue
db.session.add(model(**{k: v for k, v in record.items()
if hasattr(model, k)}))
count += 1
note(key, count)
simple('modeltypes', ModelType, 'modeltype')
simple('locationtypes', LocationType, 'locationtype')
simple('operatingsystems', OperatingSystem, 'osname')
# Per-plugin type tables exist only where that plugin is installed, so they
# are loaded through the ORM registry rather than imported directly - a lean
# build without printers must not fail here.
plugin_types = [
('machinetypes', 'machinetype'),
('computertypes', 'computertype'),
('printertypes', 'printertype'),
('networkdevicetypes', 'networkdevicetype'),
]
by_table = {m.class_.__tablename__: m.class_ for m in db.Model.registry.mappers}
for key, column in plugin_types:
model = by_table.get(key)
if model is None:
continue
simple(key, model, column)
# Vendors before models, since a model resolves its vendor by name.
count = 0
for record in data.get('vendors') or []:
name = record.get('vendor')
if not name or Vendor.query.filter_by(vendor=name).first():
continue
db.session.add(Vendor(**{k: v for k, v in record.items() if hasattr(Vendor, k)}))
count += 1
note('vendors', count)
db.session.flush()
vendor_ids = {v.vendor: v.vendorid for v in Vendor.query.all()}
modeltype_ids = {m.modeltype: m.modeltypeid for m in ModelType.query.all()}
count = 0
skipped_vendor = 0
for record in data.get('models') or []:
modelnumber = record.get('modelnumber')
if not modelnumber:
continue
vendorid = vendor_ids.get(record.get('vendor'))
# The catalog's unique key is model number PLUS vendor, so the same
# number from two makers stays two records.
if Model.query.filter_by(modelnumber=modelnumber, vendorid=vendorid).first():
continue
if record.get('vendor') and vendorid is None:
skipped_vendor += 1
continue
db.session.add(Model(
modelnumber=modelnumber,
vendorid=vendorid,
modeltypeid=modeltype_ids.get(record.get('modeltype')),
description=record.get('description'),
documentationurl=record.get('documentationurl'),
imageurl=record.get('imageurl'),
))
count += 1
note('models', count)
# Small plugin vocabularies, loaded through the registry so a lean build
# missing that plugin skips them instead of failing to import.
for key, model_table, column in (('measuringtooltypes', 'measuringtooltypes', 'name'),
('notificationtypes', 'notificationtypes', 'typename'),
('accessprotocols', 'accessprotocols', 'name')):
model = by_table.get(model_table)
if model is not None:
simple(key, model, column)
# Printer supplies. Resolved against the models loaded above, by the model's
# natural key - part numbers are useless attached to the wrong printer.
supply_model = by_table.get('modelsupplies')
if supply_model is not None and (data.get('modelsupplies') or []):
db.session.flush()
model_key = {}
for m in Model.query.all():
model_key[(m.modelnumber, m.vendorid)] = m.modelnumberid
vendor_ids = {v.vendor: v.vendorid for v in Vendor.query.all()}
count = 0
orphaned = 0
for record in data['modelsupplies']:
partnumber = record.get('partnumber')
modelnumber = record.get('modelnumber')
if not partnumber or not modelnumber:
continue
modelnumberid = model_key.get((modelnumber, vendor_ids.get(record.get('vendor'))))
if modelnumberid is None:
orphaned += 1
continue
if db.session.query(supply_model).filter_by(
modelnumberid=modelnumberid, partnumber=partnumber).first():
continue
db.session.add(supply_model(
modelnumberid=modelnumberid,
supplytype=record.get('supplytype') or 'toner',
color=record.get('color') or 'none',
capacitytier=record.get('capacitytier') or 'standard',
partnumber=partnumber,
marketingname=record.get('marketingname'),
pageyield=record.get('pageyield'),
notes=record.get('notes'),
))
count += 1
note('modelsupplies', count)
if orphaned:
click.echo(click.style(' %d supply record(s) skipped: their model is not in this catalog'
% orphaned, fg='yellow'))
if dry_run:
db.session.rollback()
click.echo(click.style('DRY RUN - nothing written.', fg='yellow'))
else:
db.session.commit()
if not added:
click.echo(click.style('Catalog already present, nothing to add.', fg='green'))
else:
for kind in sorted(added):
click.echo(' %-22s +%d' % (kind, added[kind]))
click.echo('')
click.echo(click.style('Catalog loaded from %s' % os.path.basename(path), fg='green'))
if skipped_vendor:
click.echo(click.style(' %d model(s) skipped: their vendor is not in this catalog'
% skipped_vendor, fg='yellow'))
@seed_cli.command('reference-data') @seed_cli.command('reference-data')
@with_appcontext @with_appcontext
def seed_reference_data(): def seed_reference_data():