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:
@@ -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 }
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user