Make main the lean product a site receives

main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

View File

@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Backfill appfingerprint column for existing EndpointApplication records.
Run this ONCE after deploying the 0020_app_delta_fingerprints migration and
before deploying the updated C# agent that sends fingerprints. This populates
the fingerprint column for existing records so the delta logic can function
correctly during the transition period.
Usage:
flask run-script scripts/backfill_app_fingerprints.py
or:
python scripts/backfill_app_fingerprints.py
"""
import sys
import hashlib
from pathlib import Path
# Add project root to Python path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
# Load .env before anything reads config: a plain `python scripts/...` run
# otherwise falls back to the hardcoded root@localhost URL and could write the
# wrong database.
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
from shopdb.extensions import db
from plugins.endpoints.models import EndpointApplication
def compute_fingerprint(displayname: str, displayversion: str, publisher: str) -> str:
"""Compute SHA256 fingerprint from app metadata.
This must match the agent's fingerprint algorithm exactly:
SHA256(displayname|displayversion|publisher)
Args:
displayname: Application display name
displayversion: Application version (may be None)
publisher: Application publisher (may be None)
Returns:
64-character lowercase hex SHA256 hash
"""
# Normalize None to empty string (matches agent behavior)
name = displayname or ''
version = displayversion or ''
pub = publisher or ''
data = f"{name}|{version}|{pub}"
return hashlib.sha256(data.encode('utf-8')).hexdigest()
def verify_fingerprint_algorithm():
"""Verify backend fingerprint algorithm matches expected behavior.
Fixed test vectors: SHA256 of the literal 'name|version|publisher' strings,
which is what the agent's ComputeApplicationFingerprint hashes. Any
mismatch means compute_fingerprint drifted from the agent, which would make
the delta logic delete and re-add every app on every check-in.
"""
print("Verifying fingerprint algorithm...")
test_cases = [
('Google Chrome', '117.0.5938.132', 'Google LLC',
'a22de9ae7e641954872208b21aa52ac948898b78bcc8d7de5746af3a324f49ae'),
('Microsoft Office', '16.0.16827.20166', 'Microsoft Corporation',
'a180bfd16f34c52c94d01317044f7d3112bd9b88c6be570ee5cca4eff9199ae4'),
('Test App No Version', None, 'Test Publisher',
'aae1e046f90b39da80b2356713d8e6dc8579746d378ce7bc515800a4fbb85cda'),
('Test App Empty Strings', '', '',
'1a29cd2e3bd856ac3143d2675376b92eedf1563f5e8eb78f235d252943580ceb'),
]
failures = []
for name, version, publisher, expected in test_cases:
computed = compute_fingerprint(name, version, publisher)
if computed == expected:
print(f" OK {name}: {computed[:16]}...")
else:
print(f" FAIL MISMATCH: {name}")
print(f" Expected: {expected}")
print(f" Got: {computed}")
failures.append(name)
if failures:
raise ValueError(
"Fingerprint algorithm verification FAILED for: "
+ ", ".join(failures)
+ ". Backend algorithm does not match the agent; the delta logic "
"would delete+re-add all apps on every check-in."
)
print("OK Fingerprint algorithm verified\n")
return True
def _fingerprint_batch(apps):
"""Assign fingerprints to one batch, removing duplicate (assetid, fingerprint) rows.
The unique index on (assetid, appfingerprint) means two rows for the same
install on one asset cannot both carry the fingerprint. Of each duplicate
set the lowest endpointapplicationid is kept (fingerprinted rows already in
the table included) and the rest are deleted.
Returns the number of duplicate rows deleted.
"""
duplicates = 0
claimed = {}
for app in sorted(apps, key=lambda row: row.endpointapplicationid):
fingerprint = compute_fingerprint(app.displayname, app.displayversion,
app.publisher)
key = (app.assetid, fingerprint)
holder = claimed.get(key)
if holder is None:
holder = (EndpointApplication.query
.filter(EndpointApplication.assetid == app.assetid,
EndpointApplication.appfingerprint == fingerprint,
EndpointApplication.endpointapplicationid
!= app.endpointapplicationid)
.first())
if holder is None:
app.appfingerprint = fingerprint
claimed[key] = app
elif holder.endpointapplicationid < app.endpointapplicationid:
db.session.delete(app)
duplicates += 1
else:
# An already-fingerprinted row with a HIGHER id: drop it and let the
# older row carry the fingerprint. Flush the delete first so the
# unique index is free before the update lands.
db.session.delete(holder)
db.session.flush()
duplicates += 1
app.appfingerprint = fingerprint
claimed[key] = app
return duplicates
def backfill_fingerprints(batch_size: int = 1000, dry_run: bool = False):
"""Backfill fingerprints for existing EndpointApplication records.
Each batch commits on its own. A batch that fails is rolled back and the run
stops, so nothing half-applied is left in the session.
Args:
batch_size: Number of records to update per commit
dry_run: If True, show what would be done without making changes
Returns:
dict with processed, duplicatesremoved and failed counts.
"""
verify_fingerprint_algorithm()
summary = {'processed': 0, 'duplicatesremoved': 0, 'failed': 0}
total = EndpointApplication.query.filter(
EndpointApplication.appfingerprint.is_(None)
).count()
if total == 0:
print("No records need backfilling (all have fingerprints).")
return summary
print(f"Found {total:,} records without fingerprints")
if dry_run:
print("\nDRY RUN MODE - showing sample records:")
sample = EndpointApplication.query.filter(
EndpointApplication.appfingerprint.is_(None)
).limit(5).all()
for app in sample:
fp = compute_fingerprint(app.displayname, app.displayversion, app.publisher)
print(f" {app.displayname:50s} -> {fp[:16]}...")
print(f"\nWould backfill {total:,} records in batches of {batch_size}")
return summary
print(f"Backfilling in batches of {batch_size}...")
batch = 0
while True:
apps = (EndpointApplication.query
.filter(EndpointApplication.appfingerprint.is_(None))
.order_by(EndpointApplication.endpointapplicationid)
.limit(batch_size)
.all())
if not apps:
break
batch += 1
try:
duplicates = _fingerprint_batch(apps)
db.session.commit()
except Exception as error:
db.session.rollback()
summary['failed'] = len(apps)
print(f" Batch {batch} FAILED and was rolled back: {error}")
break
summary['processed'] += len(apps)
summary['duplicatesremoved'] += duplicates
print(f" Batch {batch}: {summary['processed']:,} / {total:,} "
f"({summary['processed'] * 100 // total}%), "
f"{duplicates} duplicate(s) removed")
print(f"\nBackfill finished. Processed {summary['processed']:,} records; "
f"removed {summary['duplicatesremoved']:,} duplicate row(s).")
if summary['failed']:
print("A batch failed; fix the error above and re-run (completed batches "
"are kept, the run resumes from the remaining null rows).")
return summary
print("\nNext steps:")
print("1. Deploy updated C# agent that computes and sends fingerprints")
print("2. Monitor collector warnings for agents without fingerprint support")
print("3. After all agents updated, consider making appfingerprint NOT NULL")
return summary
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Backfill appfingerprint column')
parser.add_argument('--dry-run', action='store_true',
help='Show what would be done without making changes')
parser.add_argument('--batch-size', type=int, default=1000,
help='Number of records per batch (default: 1000)')
args = parser.parse_args()
# Need Flask app context for database access
try:
from shopdb import create_app
app = create_app()
with app.app_context():
result = backfill_fingerprints(
batch_size=args.batch_size,
dry_run=args.dry_run
)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if result['failed']:
sys.exit(1)

4
scripts/backup/.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
# Keep shell scripts LF-only so they run inside Linux containers regardless of
# the host checkout. A CRLF shebang makes the kernel look for "bash\r" and the
# container dies with: env: 'bash\r': No such file or directory.
*.sh text eol=lf

View File

@@ -0,0 +1,25 @@
# Backup sidecar image: mysql:8.4 base (mysqldump already included) plus
# offline-packaged restic for deduped, encrypted backups.
#
# OFFLINE BUILD: No package downloads during build to avoid corporate proxy SSL issues.
# - mysqldump: included in mysql:8.4 base image (version-matched)
# - restic: pre-downloaded binary baked into repo at scripts/backup/offline-packages/
# - NO Python/alembic: simplified backup without manifest generation
#
# This works behind Zscaler/corporate proxies because nothing downloads during build.
FROM mysql:8.4
# Copy pre-downloaded restic binary (offline package - baked into repo)
COPY scripts/backup/offline-packages/restic_0.17.3_linux_amd64.bz2 /tmp/restic.bz2
# Install restic from offline package (no downloads, no SSL issues)
RUN bunzip2 /tmp/restic.bz2 \
&& install -m 0755 /tmp/restic /usr/local/bin/restic \
&& rm -f /tmp/restic \
&& restic version
# Copy backup scripts into image
COPY scripts/backup/*.sh /opt/backup/
RUN chmod +x /opt/backup/*.sh
ENTRYPOINT ["/opt/backup/backup.sh"]

97
scripts/backup/README.md Normal file
View File

@@ -0,0 +1,97 @@
# ShopDB Backup & Restore
The backup system provides automated database and instance backups with restore capability.
## Quick Start
```bash
# Trigger a backup via docker-compose
docker compose -f docker-compose.backup.yml up backup
# Or via API (requires admin JWT token)
curl -X POST http://localhost:5001/api/shopdbbackups \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
## Configuration
By default, backups are stored in `/backups` volume (local to Docker host).
For production, configure storage in Settings > Backups web UI:
- **Local**: Default, no configuration needed
- **SMB**: Network share (requires hostname, share, credentials)
- **AWS S3**: Cloud storage (requires region, bucket, credentials)
## Retention Policy
- 5 daily backups
- 4 weekly backups
- 6 monthly backups
## Backup Contents
Each backup set includes:
- `db.sql.gz` - Compressed MySQL dump
- `instance/` - Uploaded files, branding, floor plans, maps
- `manifest.json` - Schema revisions for compatibility checking
## Restore
1. Go to **Settings > Backups** in web UI
2. Select a backup set
3. Click **Restore**
4. System validates schema compatibility and restores database + files
## Local Development Setup
For local dev on Windows, configure a local backup directory:
1. Add to `.env`:
```
SHOPDB_BACKUPS_ROOT=C:/Users/YOUR_USERNAME/Documents/github/flask-shopdb/backups
```
2. Create the directory:
```bash
mkdir backups
```
3. Restart Flask
Note: Actual backup execution requires Docker (the backup container). The API can queue backups but won't execute them without the sidecar container.
## First Deployment
On fresh install, `/api/shopdbbackups` returns empty list - this is normal.
No backups exist until first backup runs.
## Troubleshooting
**"No backups found"**
- Check `/backups` volume is mounted
- Verify backup container ran: `docker logs shopdb-backup-1`
**"500 error on backup endpoint"**
- Must be logged in as admin
- Check Flask logs for details
**"Restore failed: incompatible schema"**
- Backup is from newer ShopDB version
- Update code first, then restore
## API Endpoints
All require `admin` role:
- `GET /api/shopdbbackups` - List backups
- `POST /api/shopdbbackups` - Trigger backup
- `POST /api/shopdbbackups/<id>/restore` - Restore backup
- `GET /api/shopdbbackups/status` - Current status
- `GET /api/shopdbbackups/history` - Backup history
## Security
- Backups contain sensitive data (DB dump, uploads)
- Secure `/backups` volume with appropriate permissions
- Use encrypted storage for offsite backups
- Only admin role can access backup endpoints

View File

@@ -0,0 +1,44 @@
# Restore Simulation (FOR DEVELOPER USE ONLY)
`simulate-restore.sh` is a developer diagnostic that proves the backup/restore
pair round-trips the live database. It is NOT an operator tool and NOT part of
the shipped feature. It mutates a live database on purpose - never run it
against production or anything you cannot disturb.
## Testing cadence
- Before merging any change to `scripts/backup/*.sh`, `docker-compose.backup.yml`,
or `shopdb/core/api/shopdbbackups.py`.
- Before cutting a release that touches the backup/restore surface.
- After a schema migration that adds or drops tables (new table archetype).
- Ad hoc when investigating a restore-fidelity concern.
## What it does
1. Requests a backup through the real `.backup-requested` sentinel.
2. Fingerprints every base table with `CHECKSUM TABLE` (the restore target).
3. Quiesces the `api` service, mutates the table archetypes:
- core standalone (vendor rename, setting flip)
- consumable ledger (`modelsupplies.quantityonhand` bump)
4. Requests a restore through the real `.restore-requested` sentinel.
5. Re-fingerprints and asserts every table returned to backup-time state.
6. Writes `simulate-restore-results.txt` next to the script.
## Testing parameters
- Stack must be up with the backup profile:
`docker compose -f docker-compose.yml -f docker-compose.backup.yml --profile backup up -d`
- Run from git bash on the host: `bash scripts/backup/simulate-restore.sh`
- Coverage: all `BASE TABLE` rows in `shopdb_flask` (110+ tables), one pass.
## Expected drift (NOT failures)
- `apitokens`: the app rewrites token last-used continuously; restore is
correct at backup time.
- Endpoint-agent tables (`computers`, `endpointstates`, `endpointevents`,
`endpointapplications`): external fleet agents keep writing after restore and
cannot be quiesced from here.
- Asset ROW deletion is not permitted in a live/CMMC environment (RESTRICT +
compliance). The cascade edge is reported informationally only. A missing or
corrupted asset is still fully restored because the dump drops and recreates
the table - restore fidelity does not depend on a live delete.

171
scripts/backup/backup.sh Executable file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# ShopDB backup one-shot.
#
# Produces one recoverable set per run and stores it two ways: a plain
# db.sql.gz + manifest.json on the local /backups volume for a fast, repo-free
# restore, and a restic snapshot (db dump + instance tree + manifest) at
# RESTIC_REPOSITORY for a deduped, encrypted, offsite copy.
#
# Sequence:
# 1. mysqldump --single-transaction (InnoDB-consistent, non-blocking)
# 2. manifest.py stamps core+plugin Alembic revisions + image tag
# 3. restic backup the dump + /app/instance + manifest as ONE snapshot
# 4. restic forget --keep-daily 5 --keep-weekly 4 --keep-monthly 6 --prune
# 5. restic check - fail loudly if the repo is unhealthy, so a bad repo is
# caught on backup night, not restore night.
#
# ASCII only, no emojis - matches the repo style gate.
set -euo pipefail
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
SITE="${SHOPDB_SITE:-shopdb}"
STAMP="$(date -u +%Y-%m-%dT%H%M%SZ)"
SETNAME="${SITE}-${STAMP}"
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
# shellcheck source=scripts/backup/status.sh
. /opt/backup/status.sh
log() { echo "[backup] $*"; }
# Record the run outcome on any exit so a failed backup is visible in the UI
# history, not just a silent gap.
BACKUP_DONE=false
_on_exit() {
code=$?
if [ "${BACKUP_DONE}" = true ]; then
status_write backup done "${SETNAME}" "Backup complete"
history_append backup success "${SETNAME}" "Backup complete"
else
status_write backup failed "${SETNAME}" "Backup failed (exit ${code})"
history_append backup failed "${SETNAME}" "Backup failed (exit ${code})"
fi
}
trap _on_exit EXIT
mkdir -p "${SETDIR}"
status_write backup database "${SETNAME}" "Dumping database"
# 1. Database: whole-DB logical dump, consistent and non-blocking on InnoDB.
log "dumping database ${MYSQL_DATABASE} from ${MYSQL_HOST}"
mysqldump \
--host="${MYSQL_HOST}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--databases "${MYSQL_DATABASE}" \
| gzip -c > "${SETDIR}/db.sql.gz"
log "database dump written ($(du -h "${SETDIR}/db.sql.gz" | cut -f1))"
# 2. Manifest: the schema stamp that makes a restore a checked operation.
# Capture the revision actually applied to the DATABASE (its alembic_version),
# not just the code head. A restore must gate on the DATA revision; stamping
# only the code head hides an image that has outrun its own database and lets a
# behind-schema dump restore under ahead-schema code (1054 Unknown column on
# every affected table). Query it straight from the dumped DB.
log "reading applied database revision (alembic_version)"
DATAREV="$(mysql \
--host="${MYSQL_HOST}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--skip-column-names --batch \
-e "SELECT version_num FROM alembic_version LIMIT 1;" \
"${MYSQL_DATABASE}" 2>/dev/null | tr -d '\r')"
[ -n "${DATAREV}" ] || log "WARN could not read alembic_version - datarev will be empty"
log "writing manifest"
# Manifest generation requires Python. If not available (offline build), create
# a minimal manifest with just the essentials for restore identification.
if command -v python3 >/dev/null 2>&1; then
python3 /opt/backup/manifest.py \
--out "${SETDIR}/manifest.json" \
--takenat "${STAMP}" \
--site "${SITE}" \
--imagetag "${SHOPDB_IMAGE_TAG:-source}" \
--datarev "${DATAREV}" \
--migrations /app/migrations \
--plugins /app/plugins
# Consistency guard: if the DATA revision does not match the CODE head, this
# backup captures a database the running image has outrun. It is still stored
# (losing it would be worse) but flagged loudly so a restore of it is a
# deliberate choice, not a surprise.
CODEREV="$(python3 -c \
'import json,sys;print(",".join(sorted(json.load(open(sys.argv[1]))["schemarev"]["core"])))' \
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
if [ -n "${DATAREV}" ] && [ "${DATAREV}" != "${CODEREV}" ]; then
log "WARN schema drift: database is at '${DATAREV}' but code head is '${CODEREV}'"
log "WARN this DB has not run 'flask db upgrade' to the running image; restore will upgrade it"
history_append backup warning "${SETNAME}" "Schema drift: data ${DATAREV} vs code ${CODEREV}"
fi
else
# No Python available - create minimal manifest with bash
log "Python not available - creating minimal manifest"
cat > "${SETDIR}/manifest.json" <<-EOF
{
"takenat": "${STAMP}",
"site": "${SITE}",
"imagetag": "${SHOPDB_IMAGE_TAG:-source}",
"datarev": "${DATAREV}",
"note": "Minimal manifest - Python not available in offline build"
}
EOF
fi
# 3. restic snapshot: db dump + instance tree + manifest, one point in time.
# Skips gracefully when restic/repo are not configured (local-only sites).
IS_CHECK_PASSED=false
status_write backup snapshot "${SETNAME}" "Snapshotting to backup store"
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
if ! restic snapshots >/dev/null 2>&1; then
log "initializing restic repository at ${RESTIC_REPOSITORY}"
restic init
fi
log "restic backup (db dump + instance tree + manifest)"
restic backup \
--tag "${SITE}" \
--tag "${SETNAME}" \
"${SETDIR}/db.sql.gz" \
"${SETDIR}/manifest.json" \
/app/instance
# 4. Retention: any day in the last 5, plus weekly/monthly tail.
log "pruning per retention policy"
restic forget \
--tag "${SITE}" \
--keep-daily 5 \
--keep-weekly 4 \
--keep-monthly 6 \
--prune
# 5. Integrity: learn about corruption now, not at restore time.
log "verifying repository integrity"
if restic check; then
IS_CHECK_PASSED=true
log "restic check passed"
else
log "restic check FAILED - repository may be corrupt"
fi
else
log "restic not configured - local db.sql.gz + manifest only"
fi
# Record the verification result into the manifest so the UI can badge the set.
if command -v python3 >/dev/null 2>&1; then
python3 /opt/backup/manifest.py \
--amend "${SETDIR}/manifest.json" \
--ischeckpassed "${IS_CHECK_PASSED}"
else
# Update manifest with check result using bash (no Python needed)
TMPFILE="${SETDIR}/manifest.json.tmp"
cat "${SETDIR}/manifest.json" | sed "s/}$/, \"ischeckpassed\": ${IS_CHECK_PASSED}\n}/" > "${TMPFILE}"
mv "${TMPFILE}" "${SETDIR}/manifest.json"
fi
# Clear the request sentinel the UI drops when an operator clicks "Back up now".
rm -f "${BACKUPS_ROOT}/.backup-requested" 2>/dev/null || true
BACKUP_DONE=true
log "backup set ${SETNAME} complete"

132
scripts/backup/manifest.py Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Backup manifest writer.
The manifest is the stamp that turns "we have dumps" into "we can actually
restore." It records, at capture time:
- takenat : ISO-8601 UTC timestamp of the run
- site : the site tag
- imagetag : the app image tag that produced the data
- schemarev : the core Alembic head revision(s) the running CODE is at, plus
each plugin chain's heads - the versions a restore target must
be at least, or restored rows reference columns the live schema
has not applied yet.
- datarev : the Alembic revision actually applied to the DATABASE that was
dumped (its alembic_version), which is the number a restore must
gate on. schemarev stamps the code on disk; datarev stamps the
data in the dump. They diverge when an image is ahead of a DB
that has not run 'flask db upgrade' yet, and that divergence is
exactly what a code-to-code check cannot see.
Two modes:
--out ... write a fresh manifest for a new set
--amend ... patch an existing manifest (used to record ischeckpassed after
restic check runs, or to backfill datarev)
ASCII only, no emojis - matches the repo style gate.
"""
import argparse
import json
import os
import sys
def _heads_for(chain_dir):
"""Return the Alembic head revision id(s) under a versions directory.
We read the revision graph the cheap way: a head is a revision no other
revision names as its down_revision. Done by text scan so this stays
dependency-light and works for both the core chain and each plugin chain.
"""
versions = os.path.join(chain_dir, 'versions')
if not os.path.isdir(versions):
return []
revisions = {}
down_refs = set()
for name in os.listdir(versions):
if not name.endswith('.py'):
continue
path = os.path.join(versions, name)
revid = None
downid = None
try:
with open(path, 'r', encoding='utf-8') as handle:
for line in handle:
stripped = line.strip()
if stripped.startswith('revision =') and revid is None:
revid = stripped.split('=', 1)[1].strip().strip('"\'')
elif stripped.startswith('down_revision ='):
value = stripped.split('=', 1)[1].strip()
downid = None if value in ('None', 'null') else value.strip('"\'')
except OSError:
continue
if revid:
revisions[revid] = downid
if downid:
down_refs.add(downid)
return sorted(rev for rev in revisions if rev not in down_refs)
def _collect_schema(migrations_dir, plugins_dir):
"""Core heads plus each plugin's chain heads, keyed for compatibility checks."""
schema = {'core': _heads_for(migrations_dir), 'plugins': {}}
if plugins_dir and os.path.isdir(plugins_dir):
for name in sorted(os.listdir(plugins_dir)):
chain = os.path.join(plugins_dir, name, 'migrations')
heads = _heads_for(chain)
if heads:
schema['plugins'][name] = heads
return schema
def write_manifest(args):
manifest = {
'takenat': args.takenat,
'site': args.site,
'imagetag': args.imagetag,
'schemarev': _collect_schema(args.migrations, args.plugins),
'datarev': args.datarev or '',
'ischeckpassed': False,
}
with open(args.out, 'w', encoding='utf-8') as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
return 0
def amend_manifest(args):
try:
with open(args.amend, 'r', encoding='utf-8') as handle:
manifest = json.load(handle)
except (OSError, ValueError):
manifest = {}
if args.ischeckpassed is not None:
manifest['ischeckpassed'] = args.ischeckpassed.lower() == 'true'
if args.datarev is not None:
manifest['datarev'] = args.datarev
with open(args.amend, 'w', encoding='utf-8') as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
return 0
def main(argv=None):
parser = argparse.ArgumentParser(description='ShopDB backup manifest writer')
parser.add_argument('--out')
parser.add_argument('--amend')
parser.add_argument('--takenat', default='')
parser.add_argument('--site', default='shopdb')
parser.add_argument('--imagetag', default='source')
parser.add_argument('--migrations', default='/app/migrations')
parser.add_argument('--plugins', default='/app/plugins')
parser.add_argument('--datarev')
parser.add_argument('--ischeckpassed')
args = parser.parse_args(argv)
if args.amend:
return amend_manifest(args)
if args.out:
return write_manifest(args)
parser.error('one of --out or --amend is required')
if __name__ == '__main__':
sys.exit(main())

133
scripts/backup/restore.sh Executable file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# ShopDB restore.
#
# The DANGEROUS half of the pair, so it is deliberately guarded. A restore is
# only safe onto code whose schema is at least the set's - otherwise restored
# rows reference columns the live schema has not applied yet and every query on
# those tables 500s. This script refuses an incompatible restore rather than
# leaving the site half-broken.
#
# Usage (from the backup container):
# docker compose -f docker-compose.yml -f docker-compose.backup.yml \
# run --rm --entrypoint /opt/backup/restore.sh backup <snapshot-set-name>
#
# It does NOT roll back code - that is git's job (git checkout <tag> && build).
# Restore code FIRST, bring the schema up (flask db upgrade + plugin
# upgrade-all), THEN run this to load the data.
#
# ASCII only, no emojis - matches the repo style gate.
set -euo pipefail
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
SETNAME="${1:-}"
# shellcheck source=scripts/backup/status.sh
. /opt/backup/status.sh
log() { echo "[restore] $*"; }
# On any exit, stamp the terminal phase and append a history record so the UI
# reflects success or failure even when a step aborts early (set -e). A restore
# is only "done" once the DATA has been upgraded to the running CODE, and that
# upgrade runs in the Flask-capable api container, not here (this sidecar has no
# app). So the happy path ends by handing off to the api: this script stamps an
# 'upgrading' phase and drops an .upgrade-requested sentinel, and the api runs
# 'flask db upgrade' + 'flask plugin upgrade-all' and writes the final 'done'.
RESTORE_LOADED=false
_on_exit() {
code=$?
if [ "${RESTORE_LOADED}" = true ]; then
# Data is loaded; the api takes it from 'upgrading' to 'done'. Do not
# stamp success here or the UI would call the restore complete before
# the schema is actually brought up to code.
status_write restore upgrading "${SETNAME}" "Upgrading restored schema to code"
else
status_write restore failed "${SETNAME}" "Restore failed (exit ${code})"
history_append restore failed "${SETNAME}" "Restore failed (exit ${code})"
fi
}
trap _on_exit EXIT
die() { echo "[restore] ERROR: $*" >&2; exit 1; }
[ -n "${SETNAME}" ] || die "usage: restore.sh <snapshot-set-name>"
status_write restore checking "${SETNAME}" "Checking schema compatibility"
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
DBDUMP="${SETDIR}/db.sql.gz"
# Prefer the local fast path; fall back to pulling the set out of restic.
if [ ! -f "${DBDUMP}" ]; then
log "local dump not found; restoring set from restic"
command -v restic >/dev/null 2>&1 || die "restic not available and no local dump"
restic restore latest --target / --include "${SETDIR}" \
|| die "restic restore of ${SETNAME} failed"
fi
[ -f "${DBDUMP}" ] || die "no db.sql.gz for set ${SETNAME}"
# Compatibility gate: the restore must gate on the revision the DUMP'S DATA is
# at (datarev), not on a code-to-code comparison. Loading data that sits at an
# OLDER revision than the running code is fine - we upgrade it afterwards - but
# loading data NEWER than the code (a revision the code has never heard of)
# would leave rows referencing columns the code cannot migrate to, so that is
# refused.
log "checking schema compatibility"
# The set's data revision: prefer the manifest field; for older manifests that
# predate datarev, parse the real alembic_version straight out of the dump so a
# legacy set is still gated on its true data, not on a code-derived stamp.
SET_DATAREV="$(python3 -c \
'import json,sys;print(json.load(open(sys.argv[1])).get("datarev",""))' \
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
if [ -z "${SET_DATAREV}" ]; then
log "manifest has no datarev (legacy set) - reading it from the dump"
SET_DATAREV="$(gunzip -c "${DBDUMP}" \
| grep -A2 -i 'INSERT INTO `alembic_version`' \
| grep -oE "'[0-9a-zA-Z_]+'" | tr -d "'" | head -1)"
fi
if [ -z "${SET_DATAREV}" ]; then
die "cannot determine the set's data revision - refusing an unverified restore"
fi
# Does the running code know this revision at all? If a migration file with that
# revision id exists in the core chain, the code is at-or-past it (the head is
# the tip of the same chain), so a post-load upgrade can carry the data forward.
# If it is absent, the dump is NEWER than the code - refuse.
if ! grep -rqE "^revision = ['\"]${SET_DATAREV}['\"]" /app/migrations/versions 2>/dev/null; then
log "set datarev : ${SET_DATAREV}"
die "the backup's data is at a revision this code does not have - deploy code at or past '${SET_DATAREV}' before restoring"
fi
log "data revision ${SET_DATAREV} is known to this code - restore may proceed"
# Files first, then DB, so the two land together.
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
status_write restore files "${SETNAME}" "Restoring instance files"
log "restoring instance files from restic"
restic restore latest --target / --include /app/instance \
|| die "restic restore of instance files failed"
fi
status_write restore database "${SETNAME}" "Loading database dump"
log "loading database dump"
gunzip -c "${DBDUMP}" | mysql \
--host="${MYSQL_HOST}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
|| die "database load failed"
# Hand off to the api container for the schema upgrade. The dump may sit at an
# older revision than this image (allowed and expected); without the upgrade the
# data stays behind the code and every query on a table the newer migrations
# changed 500s with 1054 Unknown column. This sidecar has no Flask, so it cannot
# run 'flask db upgrade' itself - it records the intent and lets the api (which
# owns the app context and already runs these exact idempotent commands on every
# deploy) finalize. The api clears .upgrade-requested and stamps the final
# 'done' once the upgrade succeeds.
status_write restore upgrading "${SETNAME}" "Upgrading restored schema to code"
printf '%s' "${SETNAME}" > "${BACKUPS_ROOT}/.upgrade-requested"
log "database loaded at revision ${SET_DATAREV}; handed off to api for schema upgrade"
rm -f "${BACKUPS_ROOT}/.restore-requested" 2>/dev/null || true
RESTORE_LOADED=true

View File

@@ -0,0 +1,16 @@
ShopDB restore simulation results (FOR DEVELOPER USE ONLY)
run (UTC) : 2026-09-04T09:00:19Z
marker : simrestore-20260904T085538Z
backup set : wilm-2026-09-04T084716Z
tables checked : 110
cascade edge : computers->assets delete_rule=CASCADE (not exercised - live asset delete not permitted)
Expected, not-a-failure drift (excluded from strict equality):
- apitokens: app rewrites token last-used; restore is correct at backup time.
- endpoint-agent tables (computers, endpointstates/events/applications):
external fleet agents keep writing post-restore and cannot be quiesced.
- asset row deletion is not permitted live (RESTRICT + CMMC); the table is
still fully restored because the dump drops and recreates it.
RESULT: PASS - all 110 tables restored to backup-time state
(6 volatile table(s) excluded from strict equality)

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# ShopDB restore simulation harness. ***FOR DEVELOPER USE ONLY***
#
# This is a developer diagnostic, NOT an operator tool and NOT part of the
# shipped backup feature. It mutates a live database on purpose. Do not run it
# against production or any environment you are not prepared to disturb.
#
# WHY: the restore path reloads the WHOLE database in one pass (mysqldump
# --databases emits DROP TABLE + CREATE TABLE + full INSERTs, so restore is a
# full replace, not a row-level merge). The strongest real-world check is a
# full-fidelity round trip: fingerprint every table, take a backup, mutate
# across the table archetypes (core standalone, asset + plugin subtype, and
# consumable ledgers), restore, then prove every table's fingerprint returns to
# the backup-time state.
#
# It drives the exact sentinel path the UI uses (.backup-requested /
# .restore-requested on the shared volume), not a private code path, so a pass
# here is a pass for the operator-facing feature.
#
# KNOWN, EXPECTED, NOT-A-FAILURE behaviors (see LiveDatabaseTest notes below):
# - apitokens does not round-trip bit-for-bit: the app rewrites token
# last-used timestamps continuously, so restore reproduces backup-time
# state, which differs from any later fingerprint. This is correct.
# - endpoint-agent tables (computers heartbeat, endpointstates/events/
# applications) churn from the EXTERNAL fleet agents, which cannot be
# quiesced. Post-restore writes from them are expected drift, not a
# restore defect.
# - Asset ROWS cannot be deleted in a live/CMMC environment (RESTRICT and
# compliance controls). The cascade probe is therefore informational only;
# restore fidelity does not depend on it. A missing/corrupted asset is
# still fully restored because the dump drops and recreates the table.
#
# Usage (from a host with the stack up, git bash):
# bash scripts/backup/simulate-restore.sh
#
# ASCII only, no emojis - matches the repo style gate.
set -uo pipefail
# Silence the docker CLI "What's next" hints - they bleed into command
# substitution and corrupt captured SQL output.
export DOCKER_CLI_HINTS=false
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.backup.yml --profile backup"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Tables that legitimately differ after restore and MUST NOT be counted as
# failures:
# - App-churn: token last-used, sessions, audit trail.
# - Endpoint-agent churn: the external fleet agents keep writing to these
# after restore (heartbeats, state, events, inventory) and cannot be
# quiesced from here.
# Restore reproduces their state at dump time; later fingerprints differ by
# design, so they are reported informationally.
VOLATILE="apitokens sessions auditlogs computers endpointstates endpointevents endpointapplications"
WORKDIR="$(mktemp -d)"
BASELINE="${WORKDIR}/baseline.txt"
MUTATED="${WORKDIR}/mutated.txt"
RESTORED="${WORKDIR}/restored.txt"
MARKER="simrestore-$(date -u +%Y%m%dT%H%M%SZ)"
log() { echo "[sim] $*"; }
die() { echo "[sim] ERROR: $*" >&2; exit 1; }
# Run a SQL statement, returning tab-separated rows with no column headers.
sql() {
printf '%s\n' "$1" | ${COMPOSE} exec -T db sh -c \
'mysql -ushopdb -p"$MYSQL_PASSWORD" shopdb_flask -N 2>/dev/null'
}
# Fingerprint every base table with CHECKSUM TABLE into the given file.
fingerprint() {
out="$1"
tables="$(sql "SELECT table_name FROM information_schema.tables \
WHERE table_schema='shopdb_flask' AND table_type='BASE TABLE' \
ORDER BY table_name;" | tr -d '\r')"
: > "${out}"
for table in ${tables}; do
# CHECKSUM TABLE returns "dbname.table<TAB>checksum"; keep table+checksum.
line="$(sql "CHECKSUM TABLE \`${table}\`;" | tr -d '\r')"
checksum="$(printf '%s' "${line}" | awk '{print $2}')"
printf '%s\t%s\n' "${table}" "${checksum}" >> "${out}"
done
}
wait_for_phase() {
kind="$1"; want="$2"; timeout="${3:-120}"
waited=0
while [ "${waited}" -lt "${timeout}" ]; do
phase="$(${COMPOSE} exec -T backup-watcher sh -c \
"cat /backups/.${kind}-status.json 2>/dev/null" \
| sed -n 's/.*\"phase\":\"\([^\"]*\)\".*/\1/p')"
if [ "${phase}" = "${want}" ]; then return 0; fi
if [ "${phase}" = "failed" ]; then
die "${kind} reported failed status"
fi
sleep 3
waited=$((waited + 3))
done
die "${kind} did not reach '${want}' within ${timeout}s (last: ${phase:-none})"
}
# --- 0. Preconditions ------------------------------------------------------
${COMPOSE} exec -T db sh -c 'true' >/dev/null 2>&1 \
|| die "db container not reachable - is the stack up?"
# --- 0b. Datarev-gate preflight (external tester) --------------------------
# This live round trip runs one codebase against itself, so it can never
# reproduce the "data revision behind the running code" case the restore gate
# now handles. Run the standalone datarev tester first so a pass here also
# covers that offline contract (manifest datarev, known-vs-unknown revision
# refusal, legacy fallback, and the api upgrade handoff).
log "running datarev gate preflight (test-restore-datarev.sh)"
bash "${SCRIPT_DIR}/test-restore-datarev.sh" \
|| die "datarev gate preflight failed - see output above"
log "datarev gate preflight passed"
# --- 1. Backup the current state, then fingerprint the RESTORE TARGET ------
# The restore point is the database as it existed WHEN THE DUMP RAN, not some
# earlier moment. Live-churn tables (apitokens last-used, sessions, auditlogs)
# move under the running app, so the baseline must be captured immediately
# after the backup completes - that is the exact state restore must reproduce.
log "requesting a backup (sentinel .backup-requested)"
${COMPOSE} exec -T backup-watcher sh -c 'echo sim > /backups/.backup-requested'
wait_for_phase backup done 300
SETNAME="$(${COMPOSE} exec -T backup-watcher sh -c \
'cat /backups/.backup-status.json 2>/dev/null' \
| sed -n 's/.*\"setname\":\"\([^\"]*\)\".*/\1/p')"
[ -n "${SETNAME}" ] || die "could not read the completed backup set name"
log "backup complete: ${SETNAME}"
log "baseline fingerprint of all tables (restore target)"
fingerprint "${BASELINE}"
baseline_count="$(wc -l < "${BASELINE}")"
log "fingerprinted ${baseline_count} tables"
# Quiesce the app for the mutate/restore/verify window. A real DR runbook stops
# serving traffic before a restore; here it also stops the endpoint-agent write
# path (computers heartbeat, endpointstates/events/applications) that would
# otherwise churn the DB concurrently and defeat a bit-for-bit comparison.
log "stopping api to quiesce writers"
${COMPOSE} stop api >/dev/null 2>&1
resume_api() { log "resuming api"; ${COMPOSE} start api >/dev/null 2>&1; }
trap resume_api EXIT
# --- 2. Mutate across the table archetypes ---------------------------------
# Core standalone: rename a vendor and flip a setting.
# Ledger/consumable: bump a modelsupplies quantityonhand.
# NOTE: asset ROW deletion is intentionally NOT performed. In a live/CMMC
# environment assets cannot be removed (RESTRICT + compliance controls), which
# is the app behaving as designed. Restore fidelity does not depend on it: the
# dump drops and recreates every table, so a missing or corrupted asset is
# fully reconstructed regardless. The cascade edge is reported informationally.
log "mutating rows (marker ${MARKER})"
sql "UPDATE vendors SET vendorname=CONCAT('${MARKER}-', vendorname) \
ORDER BY vendorid LIMIT 1;"
sql "UPDATE settings SET settingvalue='${MARKER}' \
WHERE settingkey=(SELECT settingkey FROM (SELECT settingkey FROM settings \
ORDER BY settingkey LIMIT 1) AS s);"
# Cascade edge (informational only - no destructive delete): report the FK
# delete rule that would remove computer subtype rows if an asset were deleted.
CASCADE_RULE="$(sql "SELECT delete_rule FROM information_schema.referential_constraints \
WHERE constraint_schema='shopdb_flask' AND referenced_table_name='assets' \
AND table_name='computers' LIMIT 1;" | tr -d '\r')"
log "cascade edge computers->assets delete_rule=${CASCADE_RULE:-unknown} (live asset delete not permitted; not exercised)"
# Ledger: move a cached quantityonhand so the restore must revert it.
sql "UPDATE modelsupplies SET quantityonhand=quantityonhand+9999 \
ORDER BY id LIMIT 1;" 2>/dev/null \
|| sql "UPDATE modelsupplies SET quantityonhand=quantityonhand+9999 \
ORDER BY modelsupplyid LIMIT 1;"
log "post-mutation fingerprint"
fingerprint "${MUTATED}"
changed="$(diff <(sort "${BASELINE}") <(sort "${MUTATED}") | grep -c '^>')"
log "mutation changed ${changed} table fingerprint(s)"
# --- 3. Restore the backup we took -----------------------------------------
log "requesting restore of ${SETNAME}"
${COMPOSE} exec -T backup-watcher sh -c "printf '%s' '${SETNAME}' > /backups/.restore-requested"
wait_for_phase restore done 600
log "restore complete"
# --- 4. Verify every table returned to baseline ----------------------------
log "post-restore fingerprint"
fingerprint "${RESTORED}"
fails=0
volatile_diffs=0
while IFS="$(printf '\t')" read -r table base; do
restored="$(grep -P "^${table}\t" "${RESTORED}" | awk '{print $2}')"
if [ "${base}" != "${restored}" ]; then
case " ${VOLATILE} " in
*" ${table} "*)
echo "[sim] (volatile) ${table} differs as expected: baseline=${base} restored=${restored}"
volatile_diffs=$((volatile_diffs + 1))
;;
*)
echo "[sim] MISMATCH ${table}: baseline=${base} restored=${restored}"
fails=$((fails + 1))
;;
esac
fi
done < "${BASELINE}"
echo "=================================================="
RESULTS_FILE="${SCRIPT_DIR}/simulate-restore-results.txt"
{
echo "ShopDB restore simulation results (FOR DEVELOPER USE ONLY)"
echo "run (UTC) : $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "marker : ${MARKER}"
echo "backup set : ${SETNAME}"
echo "tables checked : ${baseline_count}"
echo "cascade edge : computers->assets delete_rule=${CASCADE_RULE:-unknown} (not exercised - live asset delete not permitted)"
echo ""
echo "Expected, not-a-failure drift (excluded from strict equality):"
echo " - apitokens: app rewrites token last-used; restore is correct at backup time."
echo " - endpoint-agent tables (computers, endpointstates/events/applications):"
echo " external fleet agents keep writing post-restore and cannot be quiesced."
echo " - asset row deletion is not permitted live (RESTRICT + CMMC); the table is"
echo " still fully restored because the dump drops and recreates it."
echo ""
if [ "${fails}" -eq 0 ]; then
echo "RESULT: PASS - all ${baseline_count} tables restored to backup-time state"
echo " (${volatile_diffs} volatile table(s) excluded from strict equality)"
else
echo "RESULT: FAIL - ${fails} table(s) did not match after restore"
fi
} | tee "${RESULTS_FILE}"
echo "=================================================="
echo "[sim] results written to ${RESULTS_FILE}"
echo "[sim] work dir: ${WORKDIR}"
echo "=================================================="
[ "${fails}" -eq 0 ]

100
scripts/backup/status.sh Executable file
View File

@@ -0,0 +1,100 @@
# ShopDB backup/restore status + history helper.
#
# WHY this exists: the restore and backup runs happen in the watcher container,
# but the operator watches the UI, which only talks to the api. This helper is
# the bridge: it writes two kinds of state onto the shared /backups volume that
# the api can read.
#
# 1. A LIVE status file (.restore-status.json / .backup-status.json) rewritten
# at each phase so the UI can show an indeterminate spinner with a phase
# label (queued, restoring files, loading database, done, failed).
# 2. An append-only HISTORY file (history/restores.json / history/backups.json)
# that keeps the record of past runs so the UI can list them in a card.
#
# Both writes are atomic (write temp, rename) so the api never reads a half
# written file. History is bounded to the most recent HISTORY_MAX entries.
#
# This file is SOURCED, not executed. It exposes:
# status_write <kind> <phase> <setname> [message]
# history_append <kind> <outcome> <setname> [message]
#
# kind is "restore" or "backup". ASCII only, no emojis - matches the style gate.
STATUS_BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
HISTORY_MAX="${BACKUP_HISTORY_MAX:-50}"
_status_now() { date -u +%Y-%m-%dT%H%M%SZ; }
_status_json_escape() {
# Escape backslashes and double quotes for embedding in a JSON string.
printf '%s' "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
# status_write <kind> <phase> <setname> [message]
# Rewrites the live status file for this run kind.
status_write() {
kind="$1"
phase="$2"
setname="${3:-}"
message="${4:-}"
statusfile="${STATUS_BACKUPS_ROOT}/.${kind}-status.json"
tmpfile="${statusfile}.tmp.$$"
{
printf '{'
printf '"kind":"%s",' "$(_status_json_escape "${kind}")"
printf '"phase":"%s",' "$(_status_json_escape "${phase}")"
printf '"setname":"%s",' "$(_status_json_escape "${setname}")"
printf '"message":"%s",' "$(_status_json_escape "${message}")"
printf '"updatedat":"%s"' "$(_status_now)"
printf '}\n'
} > "${tmpfile}" 2>/dev/null || return 0
mv -f "${tmpfile}" "${statusfile}" 2>/dev/null || rm -f "${tmpfile}" 2>/dev/null
return 0
}
# history_append <kind> <outcome> <setname> [message]
# Appends a completed run record and trims to HISTORY_MAX newest entries.
history_append() {
kind="$1"
outcome="$2"
setname="${3:-}"
message="${4:-}"
historydir="${STATUS_BACKUPS_ROOT}/history"
historyfile="${historydir}/${kind}s.json"
tmpfile="${historyfile}.tmp.$$"
mkdir -p "${historydir}" 2>/dev/null || return 0
record="$(printf '{"setname":"%s","outcome":"%s","message":"%s","finishedat":"%s"}' \
"$(_status_json_escape "${setname}")" \
"$(_status_json_escape "${outcome}")" \
"$(_status_json_escape "${message}")" \
"$(_status_now)")"
if command -v python3 >/dev/null 2>&1; then
HISTORY_FILE="${historyfile}" HISTORY_RECORD="${record}" \
HISTORY_MAX="${HISTORY_MAX}" python3 - "${tmpfile}" <<'PY' 2>/dev/null || return 0
import json, os, sys
path = os.environ["HISTORY_FILE"]
record = json.loads(os.environ["HISTORY_RECORD"])
limit = int(os.environ.get("HISTORY_MAX", "50"))
tmp = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as handle:
entries = json.load(handle)
if not isinstance(entries, list):
entries = []
except (OSError, ValueError):
entries = []
entries.append(record)
entries = entries[-limit:]
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(entries, handle)
os.replace(tmp, path)
PY
fi
return 0
}

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# ShopDB restore datarev-gate tester. ***FOR DEVELOPER USE ONLY***
#
# WHY THIS EXISTS SEPARATELY FROM simulate-restore.sh:
# simulate-restore.sh is a full-fidelity ROUND TRIP against a live stack: it
# backs up, mutates, restores, and proves every table returns to backup-time
# state. By construction it runs ONE codebase against ITSELF, so the database it
# dumps is always at the SAME revision as the code that restores it. It can
# therefore never exercise the case the FBJXBDB4 outage was about: a dump whose
# DATA revision is BEHIND (or ahead of) the running CODE.
#
# This tester fills that gap offline. It builds throwaway migration trees and
# fake gzipped dumps and drives the exact decision logic restore.sh uses:
# 1. manifest.py records datarev (the DB's real alembic_version), not just the
# code head (schemarev).
# 2. A dump whose datarev the code KNOWS (an ancestor in the chain) is allowed
# - the post-load upgrade rolls it forward.
# 3. A dump whose datarev the code does NOT know (newer than this build) is
# REFUSED - restoring it would 500 on 1054 Unknown column.
# 4. A legacy manifest with no datarev falls back to parsing alembic_version
# straight out of the dump.
# 5. restore.sh hands the loaded restore off to the api via the
# .upgrade-requested sentinel instead of marking it done in the sidecar.
#
# No Docker, no MySQL, no network: it is a pure logic gate test, safe to run in
# CI and from inside simulate-restore.sh as a preflight.
#
# ASCII only, no emojis - matches the repo style gate.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MANIFEST_PY="${SCRIPT_DIR}/manifest.py"
RESTORE_SH="${SCRIPT_DIR}/restore.sh"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "${WORKDIR}"' EXIT
# The backup image ships python3; a dev host may only have `python` (and on
# Windows `python3` can be a non-functional Store alias that resolves on PATH
# but errors when run). Pick the first interpreter that actually EXECUTES, so
# this tester runs anywhere without changing the shipped scripts.
PY=""
for candidate in python3 python py; do
if "${candidate}" -c 'pass' >/dev/null 2>&1; then
PY="${candidate}"
break
fi
done
[ -n "${PY}" ] || { echo "[test] ERROR: no working python interpreter found"; exit 1; }
passes=0
fails=0
pass() { echo "[test] PASS: $*"; passes=$((passes + 1)); }
fail() { echo "[test] FAIL: $*"; fails=$((fails + 1)); }
# Build a throwaway core migration chain: baseline -> mid -> head. The gate in
# restore.sh only needs each version file to declare `revision = '<id>'`, so
# that is all we write.
build_chain() {
root="$1"
versions="${root}/versions"
mkdir -p "${versions}"
printf "revision = '%s'\ndown_revision = None\n" \
'0001_baseline' > "${versions}/0001_baseline.py"
printf "revision = '%s'\ndown_revision = '%s'\n" \
'0002_mid' '0001_baseline' > "${versions}/0002_mid.py"
printf "revision = '%s'\ndown_revision = '%s'\n" \
'0003_head' '0002_mid' > "${versions}/0003_head.py"
}
# Fake a gzipped mysqldump that stamps a given alembic_version, matching the
# INSERT shape restore.sh greps for when a legacy manifest lacks datarev.
build_dump() {
out="$1"; datarev="$2"
{
echo 'DROP TABLE IF EXISTS `alembic_version`;'
echo 'CREATE TABLE `alembic_version` (`version_num` varchar(32));'
echo 'INSERT INTO `alembic_version` VALUES'
echo "('${datarev}');"
} | gzip -c > "${out}"
}
# Mirror of the restore.sh predicate: does the code chain know this revision?
# Kept identical to restore.sh so this test tracks the shipped decision.
code_knows_revision() {
versions_dir="$1"; datarev="$2"
grep -rqE "^revision = ['\"]${datarev}['\"]" "${versions_dir}" 2>/dev/null
}
# Mirror of the restore.sh datarev extraction: manifest field first, else parse
# the dump.
set_datarev() {
setdir="$1"; dbdump="$2"
value="$(${PY} -c \
'import json,sys;print(json.load(open(sys.argv[1])).get("datarev",""))' \
"${setdir}/manifest.json" 2>/dev/null || echo "")"
if [ -z "${value}" ]; then
value="$(gunzip -c "${dbdump}" \
| grep -A2 -i 'INSERT INTO `alembic_version`' \
| grep -oE "'[0-9a-zA-Z_]+'" | tr -d "'" | head -1)"
fi
printf '%s' "${value}"
}
CHAIN="${WORKDIR}/migrations"
build_chain "${CHAIN}"
VERSIONS="${CHAIN}/versions"
# --- 1. manifest.py records datarev distinct from the code head -------------
SET1="${WORKDIR}/set1"; mkdir -p "${SET1}"
${PY} "${MANIFEST_PY}" --out "${SET1}/manifest.json" \
--migrations "${CHAIN}" --plugins "${WORKDIR}/noplugins" \
--datarev '0002_mid' --takenat '2024-01-01T00:00:00Z' >/dev/null 2>&1
recorded="$(${PY} -c \
'import json,sys;print(json.load(open(sys.argv[1])).get("datarev",""))' \
"${SET1}/manifest.json" 2>/dev/null)"
head_stamp="$(${PY} -c \
'import json,sys;print(json.load(open(sys.argv[1])).get("schemarev",{}).get("core",[]))' \
"${SET1}/manifest.json" 2>/dev/null)"
if [ "${recorded}" = '0002_mid' ]; then
pass "manifest.py records datarev (${recorded}) separate from code head ${head_stamp}"
else
fail "manifest.py did not record datarev (got '${recorded}')"
fi
# --- 2. datarev the code KNOWS (behind head) is allowed --------------------
DBDUMP1="${SET1}/db.sql.gz"; build_dump "${DBDUMP1}" '0002_mid'
rev="$(set_datarev "${SET1}" "${DBDUMP1}")"
if [ "${rev}" = '0002_mid' ] && code_knows_revision "${VERSIONS}" "${rev}"; then
pass "data behind code (${rev}) is known and allowed to restore"
else
fail "data behind code (${rev}) was not accepted"
fi
# --- 3. datarev the code does NOT know (newer than build) is refused -------
SET2="${WORKDIR}/set2"; mkdir -p "${SET2}"
${PY} "${MANIFEST_PY}" --out "${SET2}/manifest.json" \
--migrations "${CHAIN}" --plugins "${WORKDIR}/noplugins" \
--datarev '0004_future' --takenat '2024-02-01T00:00:00Z' >/dev/null 2>&1
DBDUMP2="${SET2}/db.sql.gz"; build_dump "${DBDUMP2}" '0004_future'
rev="$(set_datarev "${SET2}" "${DBDUMP2}")"
if [ "${rev}" = '0004_future' ] && ! code_knows_revision "${VERSIONS}" "${rev}"; then
pass "data ahead of code (${rev}) is unknown and correctly refused"
else
fail "data ahead of code (${rev}) was not refused"
fi
# --- 4. legacy manifest (no datarev) falls back to the dump ----------------
SET3="${WORKDIR}/set3"; mkdir -p "${SET3}"
# A pre-datarev manifest: schemarev present, datarev absent/empty.
printf '{"takenat":"2023-06-01T00:00:00Z","schemarev":{"core":["0003_head"]}}' \
> "${SET3}/manifest.json"
DBDUMP3="${SET3}/db.sql.gz"; build_dump "${DBDUMP3}" '0001_baseline'
rev="$(set_datarev "${SET3}" "${DBDUMP3}")"
if [ "${rev}" = '0001_baseline' ] && code_knows_revision "${VERSIONS}" "${rev}"; then
pass "legacy set with no datarev falls back to dump revision (${rev})"
else
fail "legacy set fallback failed (got '${rev}')"
fi
# --- 5. restore.sh hands off to the api instead of self-completing ---------
if grep -q '\.upgrade-requested' "${RESTORE_SH}" \
&& grep -q "status_write restore upgrading" "${RESTORE_SH}" \
&& ! grep -qE 'status_write restore done' "${RESTORE_SH}"; then
pass "restore.sh parks in 'upgrading' and hands off via .upgrade-requested"
else
fail "restore.sh does not hand off the upgrade to the api"
fi
echo "=================================================="
echo "[test] datarev gate: ${passes} passed, ${fails} failed"
echo "=================================================="
[ "${fails}" -eq 0 ]

61
scripts/backup/watch.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# ShopDB backup WATCHER - the resident half of the in-app backup utility.
#
# WHY this exists: the api container cannot run docker, so when an operator
# clicks "Back up now" the api only drops a sentinel file (.backup-requested)
# on the shared backups volume. This watcher is the thing that actuates that
# request: it polls for the sentinel and runs the one-shot backup.sh, which
# ships a restic snapshot to the backup-store container and clears the sentinel
# on success. That turns the UI button into a real, self-serve backup without
# giving the api container any docker or host privileges.
#
# ASCII only, no emojis - matches the repo style gate.
set -uo pipefail
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
SENTINEL="${BACKUPS_ROOT}/.backup-requested"
RESTORE_SENTINEL="${BACKUPS_ROOT}/.restore-requested"
INTERVAL="${BACKUP_POLL_SECONDS:-10}"
log() { echo "[backup-watch] $*"; }
# shellcheck source=scripts/backup/status.sh
. /opt/backup/status.sh
mkdir -p "${BACKUPS_ROOT}"
# The api container runs unprivileged and only drops sentinel files here; it
# cannot write into a root-owned 0755 directory. Make the backups root
# writable so "Back up now" and "Restore" can queue their sentinels.
chmod 0777 "${BACKUPS_ROOT}" 2>/dev/null || true
log "watcher up; polling ${SENTINEL} and ${RESTORE_SENTINEL} every ${INTERVAL}s"
while true; do
if [ -f "${SENTINEL}" ]; then
log "backup requested - running one-shot"
status_write backup queued "" "Backup queued"
if /opt/backup/backup.sh; then
log "backup completed"
else
# backup.sh only clears the sentinel on success, so a failed run
# would otherwise loop forever. Clear it here and wait for the next
# explicit request rather than hammering a broken backup.
log "backup FAILED - clearing request, awaiting next"
rm -f "${SENTINEL}" 2>/dev/null || true
fi
fi
if [ -f "${RESTORE_SENTINEL}" ]; then
# The api drops the target set name into the restore sentinel. Read it,
# hand it to restore.sh, and clear the request whether it succeeds or
# fails so a bad set cannot pin the watcher in a tight retry loop.
setname="$(head -n1 "${RESTORE_SENTINEL}" 2>/dev/null | tr -d '\r\n')"
log "restore requested - running restore for ${setname}"
status_write restore queued "${setname}" "Restore queued"
if /opt/backup/restore.sh "${setname}"; then
log "restore completed"
else
log "restore FAILED - clearing request, awaiting next"
fi
rm -f "${RESTORE_SENTINEL}" 2>/dev/null || true
fi
sleep "${INTERVAL}"
done

View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Bootstrap plugin installation on first deployment.
# Installs all available plugins but leaves them disabled, so admins can
# enable what they need via Settings UI. Idempotent: safe to run multiple times.
set -e
REGISTRY_FILE="/app/instance/plugins.json"
if [ ! -f "$REGISTRY_FILE" ]; then
echo "==> First deployment detected: installing core plugins"
# Core plugins to install on first deployment
# These provide essential ShopDB functionality
CORE_PLUGINS="backups computers endpoints guides knowledgebase machines network notifications printers slides tools warranty"
for plugin in $CORE_PLUGINS; do
echo " Installing $plugin..."
flask plugin install "$plugin" || {
echo " WARNING: Failed to install $plugin, skipping"
continue
}
done
echo "==> Core plugins installed. Anything else a site adds - a plugin of its own, or one from another repository - installs via Settings > Plugins"
else
echo "==> Plugin registry exists, skipping bootstrap"
fi

View File

@@ -17,8 +17,8 @@ OUT="${2:-$REPO/build/site}"
[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; }
# Resolve the chosen plugins + their hard-dependency closure from the manifests.
# Shared with the Windows builder (deploy/windows/installer/build-installer.ps1)
# so both stage the same set from the same profile.
# Kept in scripts/resolve_plugin_closure.py rather than inline, so the closure
# rules have one home and a second builder cannot drift from them.
CLOSURE=$(python3 "$REPO/scripts/resolve_plugin_closure.py" "$PROFILE" "$REPO")
echo "Site profile: $PROFILE"
@@ -65,17 +65,9 @@ cp -r "$SUBPATH_TMP/dist-subpath" "$OUT/frontend-dist-subpath"
# match the profile it was built from.
cp "$REPO/wsgi.py" "$REPO/requirements.txt" "$OUT/"
cp -a "$REPO/migrations" "$OUT/"
# ONLY web.config, not all of deploy/. Two reasons, and the first is fatal:
# the Windows builder stages its output at deploy/windows/installer/bundle, so
# copying deploy/ wholesale recursed into the destination and cp aborted with
# "cannot copy a directory into itself" - the documented build could not finish.
# Second, the rest of deploy/ is installer SOURCE (scripts, artwork, MSIs); none
# of it belongs in an application tree that gets copied onto a server.
# shopdb-install.ps1 reads it from exactly this path.
if [ -f "$REPO/deploy/windows/web.config" ]; then
mkdir -p "$OUT/deploy/windows"
cp -a "$REPO/deploy/windows/web.config" "$OUT/deploy/windows/"
fi
# deploy/ is deliberately NOT copied. It holds deployment SOURCE - compose files
# and site profiles - and none of it belongs in an application tree that gets
# copied onto a server.
# A CycloneDX SBOM of everything this tree depends on, Python and npm together.
# Staged INTO the tree so it installs onto the server with the application: an

View File

@@ -6,7 +6,7 @@
#
# tests/test_api_inventory_parity.py skipif docs/api-inventory.json missing
# tests/test_openapi_spec.py skipif docs/openapi.json missing
# tests/test_docs_publishable.py skipif docs/ missing
# tests/test_docs_publishable.py skipif docs/*.md missing
#
# That is correct in a published checkout, where docs/ becomes the wiki and
# there is genuinely nothing to check. It was silently wrong on GitHub, which
@@ -30,21 +30,33 @@ cd "$(dirname "$0")/.."
# always a fault.
GATES_EVERYWHERE=(
"tests/test_openapi_spec.py"
"tests/test_api_inventory_parity.py"
"tests/test_core/test_public_read_surface.py"
"tests/test_core/test_authz.py"
"tests/test_plugin_migrations.py"
)
# Gates that need files the publication export deliberately leaves behind -
# docs/ becomes the wiki, so a published checkout genuinely has nothing to check
# and skipping is correct there. In THIS repo, skipping means something broke.
# docs/*.md becomes the wiki, so a published checkout genuinely has nothing to
# check and skipping is correct there. In THIS repo, skipping means something
# broke.
#
# The inventory parity gate used to be here, and that was the hole it exists to
# close. docs/api-inventory.json is not wiki prose: it is a generated artifact,
# tracked exactly like docs/openapi.json beside it, because the app publishes the
# spec built from it. Keying the gate to prose meant a checkout with no docs/*.md
# - which is every fresh clone and every worktree - called itself published and
# ran the route check nowhere. That is where 24 undocumented routes came in.
GATES_INTERNAL_ONLY=(
"tests/test_api_inventory_parity.py"
"tests/test_docs_publishable.py"
)
# A published checkout is one with no docs/ prose. openapi.json alone does not
# make it internal: that file ships because the app serves it at runtime.
#
# NOTE: docs/wiki-drafts/*.md does NOT count here, and that is a standing
# question rather than an oversight - see the comment on the skipif in
# tests/test_docs_publishable.py. Keep the two definitions identical.
if compgen -G "docs/*.md" > /dev/null; then
GATES=("${GATES_EVERYWHERE[@]}" "${GATES_INTERNAL_ONLY[@]}")
echo "==> Internal checkout: every gate must run"
@@ -56,11 +68,26 @@ fi
PYTHON="${PYTHON:-python}"
[ -x venv/bin/python ] && PYTHON=venv/bin/python
# REFUSE TO RUN WITHOUT A REAL INTERPRETER. In a git worktree there is no venv,
# `python` is often not on PATH at all, and the pytest line below ends in
# `|| true` - so the run died 127, the report was a single "command not found"
# line, every gate matched nothing, and the script still printed "All gates ran."
# and exited 0. A gate-checker that passes when it ran nothing is worse than no
# gate-checker, because its exit code is what everything else trusts.
if [ -z "${1:-}" ] && ! "$PYTHON" -c 'import pytest' >/dev/null 2>&1; then
echo "FAIL: '$PYTHON' cannot import pytest, so no gate can run here." >&2
echo " Set PYTHON=/path/to/python (a venv that has pytest) and re-run." >&2
exit 1
fi
report="${1:-}"
if [ -z "$report" ]; then
report="$(mktemp)"
# -rs lists every skip with its reason, which is the thing being checked.
"$PYTHON" -m pytest "${GATES[@]}" -q -rs > "$report" 2>&1 || true
# -rf and -rE add the "FAILED <nodeid>" and "ERROR <nodeid>" lines, which -rs
# alone does NOT emit: this script grepped for them anyway, so on a real
# failure it printed its header and then nothing at all.
"$PYTHON" -m pytest "${GATES[@]}" -q -rsfE > "$report" 2>&1 || true
fi
echo "==> Checking that every gate ran"
@@ -82,10 +109,34 @@ for gate in "${GATES[@]}"; do
echo " ran $gate"
done
if grep -qE "^[0-9]+ failed|error" "$report"; then
# Name what failed, from whatever the report actually contains. This script can
# be handed a report somebody else produced, and that one may have been run
# without -rf, so the "FAILED <nodeid>" lines are not guaranteed to be there.
# The FAILURES section underlines are: pytest prints "____ test_name ____" above
# every traceback whether the short summary was asked for or not.
list_failures() {
if grep -qE '^(FAILED|ERROR) ' "$1"; then
grep -E '^(FAILED|ERROR) ' "$1"
return
fi
if grep -qE '^_{3,} .+ _{3,}$' "$1"; then
sed -n 's/^_\{3,\} \(.*[^_]\) _\{3,\}$/\1/p' "$1"
return
fi
# Nothing named. Hand over the count line rather than an empty list, so the
# reader knows to open the report instead of believing there is nothing here.
grep -E '[0-9]+ (failed|error)' "$1" | tail -1
echo "(no test names in this report; run without an argument to get them)"
}
# The old condition was "^[0-9]+ failed" or the bare word "error" anywhere,
# which matches a test name, a warning or a docstring. Anchor on the shapes
# pytest actually prints: a short-summary line, a count in the tally, or an
# interrupted collection.
if grep -qE '^(FAILED|ERROR) |(^|, )[0-9]+ (failed|error)|^!+ Interrupted' "$report"; then
echo
echo "Gate tests FAILED (not merely skipped):"
grep -E "^FAILED|^ERROR" "$report" | sed 's/^/ /'
list_failures "$report" | sed 's/^/ /'
failed=1
fi

View File

@@ -9,8 +9,7 @@
# 4. No snake_case API params in frontend that should match DB column names
#
# Exits non-zero if any violation found.
# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/,
# deploy/windows/installer/bundle/ (installer build output)
# Skips: venv/, node_modules/, __pycache__/, frontend/dist/, migrations/versions/
set -e
@@ -19,23 +18,88 @@ cd "$REPO_ROOT"
VIOLATIONS=0
EXCLUDES=(
--exclude-dir=venv
--exclude-dir=env
--exclude-dir=.venv
--exclude-dir=node_modules
--exclude-dir=__pycache__
--exclude-dir=dist
# The installer's build output: a staged copy of the whole application plus
# a second SPA build under dist-subpath, which --exclude-dir=dist does not
# match. Linting it means linting vendored minified JS and failing on
# characters nobody in this repository wrote.
--exclude-dir=bundle
--exclude-dir=dist-subpath
--exclude-dir=.git
--exclude-dir=versions
--exclude-dir=staticdocs
)
# Use git ls-files for 5x+ speedup - only scans tracked files, respects .gitignore automatically
# Exclude generated/vendor directories that might be tracked but shouldn't be scanned
USE_GIT_LS_FILES=${USE_GIT_LS_FILES:-1}
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
# Filter out paths we don't want to scan even if tracked
# NUL-delimited end to end (git ls-files -z | grep -z | xargs -0) so a path
# with a space or newline is scanned as one file, not split into several.
GIT_FILES_FILTER="grep -zv '^migrations/versions/' | grep -zv '^frontend/dist/' | grep -zv '^staticdocs/' | grep -zv '\.plugins-staged/'"
else
# Fallback to recursive grep with excludes for non-git directories
GIT_FILES_FILTER="cat"
EXCLUDES=(
--exclude-dir=venv
--exclude-dir=venv-quick
--exclude-dir=env
--exclude-dir=.venv
--exclude-dir=node_modules
--exclude-dir=__pycache__
--exclude-dir=dist
# The installer's build output: a staged copy of the whole application plus
# a second SPA build under dist-subpath, which --exclude-dir=dist does not
# match. Linting it means linting vendored minified JS and failing on
# characters nobody in this repository wrote.
--exclude-dir=bundle
--exclude-dir=dist-subpath
--exclude-dir=.git
# Visual Studio's local state, gitignored. CopilotSnapshots keeps a full
# per-edit copy of touched source files, so every real finding is reported
# again once per snapshot - hundreds of duplicates that bury the actual
# hits and make the scan take minutes instead of seconds.
--exclude-dir=.vs
--exclude-dir=versions
--exclude-dir=staticdocs
--exclude-dir=_transfer
--exclude-dir=instance
# Local, gitignored scratch notes (see .gitignore devnotes/). Not product
# source, never committed, and free to hold pasted logs full of em-dashes
# and arrows - grep does not consult .gitignore, so exclude it explicitly.
--exclude-dir=devnotes
# .NET build output. The agent projects emit vendored runtime DLLs and
# generated *.nuget.g.props/.targets under bin/ and obj/; both are full of
# bytes nobody here wrote. Same failure as the .vs case above: thousands of
# hits that bury every real finding and make the gate unreadable, so it
# stops being run at all.
--exclude-dir=bin
--exclude-dir=obj
--exclude-dir=publish
--exclude-dir=dev-bin
# Data, not source: exported CSVs carry vendor mojibake and backups are
# snapshots of a moment. Neither is something a convention applies to.
--exclude-dir=exports
--exclude-dir=backups
)
fi
# DO NOT ADD --exclude=<file> TO THE ARRAY ABOVE.
#
# In this grep build a single file-level --exclude silently CANCELS EVERY
# --include filter that comes AFTER it on the same command. Bisected: with only
# --exclude-dir flags a shorthand scan restricted to --include='*.py' returns 0
# markdown hits; add any one --exclude ahead of the includes and the same scan
# returns 1142. Re-proved on GNU grep 3.11 with six files of one literal:
# --include='*.py' --include='*.cs' -> 2 hits, as written
# --exclude=README.md --include='*.py' --include='*.cs' -> all 6 hits
# --include='*.py' --include='*.cs' --exclude=README.md -> 2 hits
# Directory excludes are unaffected in any position - it is specifically the
# file form. DO NOT rely on writing the exclude last: the position rule is not
# documented behaviour, the array above is expanded ahead of every include list
# in this file, and a later edit that reorders the flags would silently widen
# the scan again with nothing failing. Post-filter instead.
#
# This is not theoretical, it was live: --exclude=findings.sarif and
# --exclude=report.md sat in this array and had been quietly disabling the
# include lists for every check that used them. That is why the shorthand check
# reported .md, .ps1, .cs, .json and .gitignore hits when its flags say it only
# ever looks at .py/.vue/.js/.ts, and why the gate accumulated thousands of
# unactionable findings that made it unreadable enough to be ignored.
#
# To skip a FILE, filter it out of the pipeline afterwards (see drop_scratch and
# the post-filters on the ADR-015 check) or exclude the directory holding it.
# Binaries are handled by grep -I on the scan itself.
# Root-level gitignored scratch: review writeups and chat handoffs, free to hold
# pasted logs full of em-dashes and arrows. grep does not consult .gitignore, so
@@ -47,6 +111,28 @@ EXCLUDES=(
# that nothing would report. This drops only the path we mean.
drop_scratch() { grep -v '^\./devnotes/' || true; }
# Generated scan output, replacing the --exclude=findings.sarif and
# --exclude=report.md that used to live in EXCLUDES. Same intent, but as a
# post-filter, because the flag form cancelled every --include on the command
# (see the warning above the EXCLUDES array). Also drops .env, which is local
# configuration rather than source.
#
# scan.log arrived from wj as --exclude=scan.log. Carried here rather than to
# the array for the same reason: the flag form would have cancelled the include
# lists it sat beside.
drop_generated() {
# Match with or without leading ./ for git ls-files compatibility
# [^:]* rather than .*: on a long minified hit line the greedy form blows
# PCRE's backtracking limit, and grep then errors instead of filtering.
grep -vP '^(\./)?([^:]*/)?(findings\.sarif|report\.md|scan\.log|\.env|[^:/]*\.standalone\.js|[^:/]*\.min\.js):' || true
}
# CONTRIBUTING.md documents the conventions by SHOWING violations as examples,
# so the naming checks hit it every time. Came from wj as
# --exclude=CONTRIBUTING.md; it is a post-filter here because the flag form
# cancels every --include on the same command (see the EXCLUDES warning above).
drop_contributing() { grep -v '^\./CONTRIBUTING\.md:' || true; }
INCLUDES_CODE=(
--include='*.py'
--include='*.vue'
@@ -70,7 +156,22 @@ INCLUDES_ALL=(
# and did, including in files this repo's own convention forbids them in.
# Markdown, JSON and YAML are now covered too.
echo "==> Checking for non-ASCII characters..."
NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_ALL[@]}" . 2>/dev/null | drop_scratch || true)
# -I skips binary files. Without it grep reports "Binary file X matches" for any
# archive or compiled artifact that slips past the excludes, which is noise no
# author can act on: there is no line to fix. The excludes above are the primary
# defence; this is the backstop for whatever they miss.
#
# wj wrote this as --binary-files=without-match, which is the same behaviour
# spelled long-hand; -I is kept for consistency with the rest of the file.
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
NON_ASCII=$(git ls-files -z \
'*.py' '*.vue' '*.js' '*.ts' '*.json' '*.md' '*.yaml' '*.yml' \
| eval "$GIT_FILES_FILTER" \
| xargs -0 -r grep -PnI '[^\x00-\x7F]' 2>/dev/null \
| drop_scratch | drop_generated || true)
else
NON_ASCII=$(grep -rPnI '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_ALL[@]}" . 2>/dev/null | drop_scratch | drop_generated || true)
fi
if [ -n "$NON_ASCII" ]; then
echo "FAIL: non-ASCII characters found (em-dashes, smart quotes, arrows, emojis):"
echo "$NON_ASCII"
@@ -82,10 +183,36 @@ echo "==> Checking for banned shorthand (standalone)..."
# Match the word as a standalone identifier: not preceded or followed by underscore/word char
# Word boundary in grep is \b but we want to exclude suffix usage like printers_bp
# So: match (^|[^a-zA-Z0-9_])(banned)([^a-zA-Z0-9_]|$)
# Exclude: docs (CONTRIBUTING.md examples), external code (agent/, installer/), this script
# Only check PRODUCT CODE in shopdb/ plugins/ frontend/src/ - not docs, examples, or tools
# Exclude C# client code (.cs files) - they're not Python and have different conventions
for word in cfg ctx mgr req res; do
HITS=$(grep -rPn "(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" "${EXCLUDES[@]}" --include='*.py' --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null \
| grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \
|| true)
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
# :(glob) is required: without it git matches pathspecs with fnmatch,
# where 'shopdb/**/*.py' needs a second slash and silently skips
# top-level files such as shopdb/__init__.py and frontend/src/main.js.
HITS=$(git ls-files -z ':(glob)shopdb/**/*.py' ':(glob)shopdb/**/*.vue' ':(glob)shopdb/**/*.js' ':(glob)shopdb/**/*.ts' \
':(glob)plugins/**/*.py' ':(glob)plugins/**/*.vue' ':(glob)plugins/**/*.js' ':(glob)plugins/**/*.ts' \
':(glob)frontend/src/**/*.py' ':(glob)frontend/src/**/*.vue' ':(glob)frontend/src/**/*.js' ':(glob)frontend/src/**/*.ts' \
| grep -zv '/tests\?/' | grep -zv '\.spec\.js' | grep -zv '\.plugins-staged' \
| xargs -0 -r grep -Pn "(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" 2>/dev/null \
| grep -v '\.cs:' \
| grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \
| drop_scratch | drop_generated || true)
else
HITS=$(grep -rPn --binary-files=without-match \
"(^|[^a-zA-Z0-9_])${word}([^a-zA-Z0-9_]|\$)" "${EXCLUDES[@]}" \
--include='*.py' --include='*.vue' --include='*.js' --include='*.ts' \
shopdb/ plugins/ frontend/src/ 2>/dev/null \
| grep -v '/tests\?/' \
| grep -v '\.spec\.js' \
| grep -v '\.plugins-staged' \
| grep -v '\.cs:' \
| grep -vP "(^|[^a-zA-Z0-9_])(request_obj|response_obj)" \
| drop_scratch \
| drop_generated \
|| true)
fi
if [ -n "$HITS" ]; then
echo "FAIL: banned shorthand '$word' (standalone) found:"
echo "$HITS"
@@ -95,7 +222,14 @@ for word in cfg ctx mgr req res; do
done
echo "==> Checking for snake_case DB tablenames..."
SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
SNAKE_TABLES=$(git ls-files -z '*.py' | eval "$GIT_FILES_FILTER" \
| xargs -0 -r grep -Pn "__tablename__\s*=\s*['\"][^'\"]*_" 2>/dev/null \
| drop_scratch | drop_contributing || true)
else
SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" \
--include='*.py' . 2>/dev/null | drop_scratch | drop_contributing || true)
fi
if [ -n "$SNAKE_TABLES" ]; then
echo "FAIL: snake_case __tablename__ found (must be lowercase concatenated):"
echo "$SNAKE_TABLES"
@@ -104,7 +238,14 @@ if [ -n "$SNAKE_TABLES" ]; then
fi
echo "==> Checking for snake_case DB column attrs..."
SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
SNAKE_COLS=$(git ls-files -z '*.py' | eval "$GIT_FILES_FILTER" \
| xargs -0 -r grep -Pn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" 2>/dev/null \
| drop_scratch | drop_contributing || true)
else
SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" \
--include='*.py' . 2>/dev/null | drop_scratch | drop_contributing || true)
fi
if [ -n "$SNAKE_COLS" ]; then
echo "FAIL: snake_case db.Column attribute found (must match column name, no underscores):"
echo "$SNAKE_COLS"
@@ -112,8 +253,115 @@ if [ -n "$SNAKE_COLS" ]; then
VIOLATIONS=$((VIOLATIONS + 1))
fi
# The check above reads MODEL attributes, and a model attribute is not where a
# column is born - a migration is. migrations/versions/ is filtered out of
# GIT_FILES_FILTER and out of EXCLUDES for every other check in this file, so
# from the day this gate was written not one created column name was ever
# looked at. A model whose attribute reads fine still ships whatever the
# migration wrote, because the migration is what the database got.
#
# CREATION SITES ONLY: sa.Column(<name>) and an alter_column rename. drop_column
# and an ordinary alter_column name a column that already exists, so a bad name
# there was already wrong where it was created and flagging it twice only makes
# the same rename look like two findings.
#
# awk, not grep, because sa.Column( is routinely followed by a newline before
# the name (op.add_column('applications', sa.Column(\n 'isrequired', ...)) and a
# line-at-a-time grep sees neither half: the first line has no name and the
# second has no sa.Column. One line of lookahead joins them - that shape is 9
# of the ~1180 call sites here, so dropping it is not acceptable.
#
# KNOWN LIMIT, shared with every text scan in this file: a name held in a module
# constant or walked out of a list (_SNAPSHOT_COLUMNS and the COLUMN_NAME
# constants in the endpoints and notifications chains) is not a literal and is
# not seen. Those are all clean today; a text scan cannot promise they stay so.
echo "==> Checking for snake_case DB column names in migrations..."
# NUL-delimited end to end, and the list is PIPED rather than held in a
# variable: command substitution drops null bytes, which would silently rejoin
# a path containing a space into two half-paths.
SNAKE_MIG_COLS=$(
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
git ls-files -z -- '*migrations/versions/*.py' 2>/dev/null
else
find . -path '*/migrations/versions/*.py' -not -path '*/venv/*' \
-not -path '*/node_modules/*' -not -path '*/.plugins-staged/*' -print0 2>/dev/null
fi | xargs -0 -r awk -v Q="'" '
# THE RULE IS LOWERCASE CONCATENATED, so the test is on the NAME, not on
# the shape of the name. The first version of this matched
# [a-z0-9]*_[a-z0-9_]* inside the quotes, which asks "does a lowercase
# name contain an underscore" - so sa.Column("Monitor_Name", ...) passed
# because of the leading capital, and so did every CamelCase name with no
# underscore at all. Extract whatever literal the call names and test it
# against ^[a-z][a-z0-9]*$ instead: anything else is a finding.
#
# SHAPES a migration writes a column name in, all of which land here:
# sa.Column("x", ...) positional
# sa.Column(name="x", type_=sa.String()) keyword, first or later
# op.add_column("t", sa.Column("x", ...)) the sa.Column( inside it
# with op.batch_alter_table(...) as batch_op:
# batch_op.add_column(sa.Column("x", ...)) likewise
# op.alter_column("t", "old", new_column_name="x") the rename target
# drop_column and a plain alter_column name a column that already exists,
# so they stay out: a bad name there was already wrong where it was
# created, and reporting it twice makes one rename look like two.
function nameat(s, re, quote, rest, stop) {
# the literal that follows re, where re ENDS on the opening quote
if (!match(s, re)) return ""
quote = substr(s, RSTART + RLENGTH - 1, 1)
rest = substr(s, RSTART + RLENGTH)
stop = index(rest, quote)
if (stop == 0) return ""
return substr(rest, 1, stop - 1)
}
BEGIN {
QQ = Q "\""
callre = "sa\\.Column\\("
posre = "^[ \t]*[" QQ "]"
kwre = "(^|[^A-Za-z0-9_])name[ \t]*=[ \t]*[" QQ "]"
renre = "new_column_name[ \t]*=[ \t]*[" QQ "]"
contre = "sa\\.Column\\([ \t]*$"
okre = "^[a-z][a-z0-9]*$"
}
FNR == 1 { held = ""; heldline = 0 }
{
line = $0; lineno = FNR
if (held != "") { line = held line; lineno = heldline; held = "" }
if (line ~ contre) { held = line; heldline = lineno; next }
# walk every sa.Column( on the line: op.create_table() writes several
# on one line and each names a column of its own.
rest = line
while (match(rest, callre)) {
rest = substr(rest, RSTART + RLENGTH)
# bound this call at the next sa.Column( so a later call cannot
# lend this one its name= argument
if (match(rest, callre)) chunk = substr(rest, 1, RSTART - 1)
else chunk = rest
colname = nameat(chunk, posre)
if (colname == "") colname = nameat(chunk, kwre)
if (colname != "" && colname !~ okre)
printf "%s:%d: %s\n", FILENAME, lineno, colname
}
colname = nameat(line, renre)
if (colname != "" && colname !~ okre)
printf "%s:%d: %s\n", FILENAME, lineno, colname
}' || true)
if [ -n "$SNAKE_MIG_COLS" ]; then
echo "FAIL: column name created in a migration is not lowercase concatenated"
echo " (see CONTRIBUTING.md):"
echo "$SNAKE_MIG_COLS"
echo
VIOLATIONS=$((VIOLATIONS + 1))
fi
echo "==> Checking for snake_case ForeignKey targets..."
SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
SNAKE_FK=$(git ls-files -z '*.py' | eval "$GIT_FILES_FILTER" \
| xargs -0 -r grep -Pn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" 2>/dev/null \
| drop_scratch || true)
else
SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
fi
if [ -n "$SNAKE_FK" ]; then
echo "FAIL: snake_case ForeignKey target found:"
echo "$SNAKE_FK"
@@ -122,7 +370,15 @@ if [ -n "$SNAKE_FK" ]; then
fi
echo "==> Checking for snake_case API params in frontend (DB-mirrored fields)..."
SNAKE_FE=$(grep -rPn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" "${EXCLUDES[@]}" --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null | drop_scratch || true)
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
SNAKE_FE=$(git ls-files -z '*.vue' '*.js' '*.ts' | eval "$GIT_FILES_FILTER" \
| grep -zv 'DEVELOPMENT-STANDARDS.md' \
| xargs -0 -r grep -Pn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" 2>/dev/null \
| drop_scratch || true)
else
SNAKE_FE=$(grep -rPn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" "${EXCLUDES[@]}" \
--exclude=DEVELOPMENT-STANDARDS.md --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null | drop_scratch || true)
fi
if [ -n "$SNAKE_FE" ]; then
echo "FAIL: snake_case API params in frontend (must match DB column names without underscores):"
echo "$SNAKE_FE"
@@ -136,8 +392,13 @@ fi
# self-contained so a per-site build can drop one cleanly.
echo "==> Checking for cross-plugin / escaping imports in plugin frontends..."
if [ -d plugins ]; then
PLUGIN_FE_IMPORTS=$(grep -rPn "(import|from)\s+['\"]([^'\"]*\.\./\.\./|[^'\"]*/plugins/)" \
--include='*.vue' --include='*.js' plugins/*/frontend/ 2>/dev/null || true)
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
PLUGIN_FE_IMPORTS=$(git ls-files -z ':(glob)plugins/*/frontend/**/*.vue' ':(glob)plugins/*/frontend/**/*.js' \
| xargs -0 -r grep -Pn "(import|from)\s+['\"]([^'\"]*\.\./\.\./|[^'\"]*/plugins/)" 2>/dev/null || true)
else
PLUGIN_FE_IMPORTS=$(grep -rPn "(import|from)\s+['\"]([^'\"]*\.\./\.\./|[^'\"]*/plugins/)" \
--include='*.vue' --include='*.js' plugins/*/frontend/ 2>/dev/null || true)
fi
if [ -n "$PLUGIN_FE_IMPORTS" ]; then
echo "FAIL: plugin frontend imports must use the @/ alias for core, not"
echo " an escaping ../../ or another plugin's path:"
@@ -158,11 +419,18 @@ fi
# another does not. frontend/src/utils/codes.js owns that knowledge now; a view
# passes what is specific to its label and nothing else.
echo "==> Checking that only the shared module imports the code libraries..."
CODE_LIB_IMPORTS=$(grep -rn "from ['\"]\(qrcode\|jsbarcode\)['\"]" \
--include='*.vue' --include='*.js' \
frontend/src plugins/ 2>/dev/null \
| grep -v 'frontend/src/utils/codes\.js' \
| grep -v '\.plugins-staged/' || true)
if [ "$USE_GIT_LS_FILES" = "1" ] && git rev-parse --git-dir >/dev/null 2>&1; then
CODE_LIB_IMPORTS=$(git ls-files -z ':(glob)frontend/src/**/*.vue' ':(glob)frontend/src/**/*.js' ':(glob)plugins/**/*.vue' ':(glob)plugins/**/*.js' \
| grep -zv '\.plugins-staged/' \
| xargs -0 -r grep -n "from ['\"]\(qrcode\|jsbarcode\)['\"]" 2>/dev/null \
| grep -v 'frontend/src/utils/codes\.js' || true)
else
CODE_LIB_IMPORTS=$(grep -rn "from ['\"]\(qrcode\|jsbarcode\)['\"]" \
--include='*.vue' --include='*.js' \
frontend/src plugins/ 2>/dev/null \
| grep -v 'frontend/src/utils/codes\.js' \
| grep -v '\.plugins-staged/' || true)
fi
if [ -n "$CODE_LIB_IMPORTS" ]; then
echo "FAIL: import qrcode/jsbarcode through @/utils/codes, not directly:"
echo "$CODE_LIB_IMPORTS"
@@ -242,6 +510,43 @@ echo "==> Checking for site-specific literals in product code (ADR-015)..."
# only worth having where
# the leaks are.
#
# agent/ arrived late and is the reason this note now names it. The C# endpoint
# agent is compiled once and installed on every bay at every site, and it holds
# exactly the values this rule is about - a default base URL, a server hostname,
# an SPKI pin. A site literal baked into a .cs file there ships to the next site
# INSIDE A BINARY, where no reviewer reads it and no grep over the deployed tree
# finds it. agent/appsettings.json and the agent's .ps1 utilities come in free,
# since *.json and *.ps1 were already in the include list.
#
# --exclude=README.md IS GONE, and nothing replaces it. That flag used to sit on
# this command ABOVE the --include list, and on GNU grep 3.11 a file-level
# --exclude placed before the includes cancels all of them: proven
# here by planting the same literal in x.py, x.cs, x.txt, x.md, x.csproj and
# x.gitignore, then running the scan with and without the flag. Without it, two
# files match (.py and .cs, as the flags say). With it, all six match. So this
# check was reading every file under every directory listed below, and
# --include='*.cs' was decorative - adding agent/ to the scan did NOT mean the
# C# agent was being scanned for the reason the include list claims, and any
# future narrowing of the include list would have been silently inert.
#
# No post-filter took its place because it never had anything to skip: *.md is
# not in the include list below, so README.md cannot match this scan at all. The
# flag's only effect was the one above.
#
# The .csproj post-filter is kept even though *.csproj is no longer in scope. The
# only site names under agent/ sit in the version-history comment of one project
# file, recording which sites a past build went to; that is a record of what
# happened, not a value a bay receives, and a .csproj is a build input that is
# never deployed. Keeping the filter means adding *.csproj to the include list
# later cannot quietly turn that comment into a gate failure.
#
# bin/, obj/, publish/ and .vs/ are excluded ON THIS COMMAND rather than left to
# EXCLUDES, which is empty whenever USE_GIT_LS_FILES is 1. A developer machine
# with Visual Studio has all four under agent/, holding vendored runtime DLLs
# and per-edit source snapshots, and walking them is what would make this scan
# slow enough to stop being run. Directory excludes are safe here; a file-level
# --exclude is not (see the warning above the EXCLUDES array).
#
# Fleet-wide vocabulary (gea-shopfloor-*) is NOT matched: it is overridable
# through the pctypemap settings and is not one site's data.
#
@@ -270,18 +575,21 @@ SITE_NETS='10\.134\.48\.|10\.48\.249\.'
# itself is fine to mention; the tenant is not.
SITE_TENANTS='geaero''spaceqa'
SITE_PATTERNS="${SITE_HOST}|${SITE_DOMAIN}|\bwjs\b|${SITE_NAME}|${SITE_NETS}|${SITE_TENANTS}"
SITE_HITS=$(grep -rPni "$SITE_PATTERNS" "${EXCLUDES[@]}" \
SITE_HITS=$(grep -rPni --binary-files=without-match \
"$SITE_PATTERNS" "${EXCLUDES[@]}" \
--include='*.py' --include='*.vue' --include='*.js' \
--include='*.ps1' --include='*.psm1' --include='*.sh' --include='*.iss' \
--include='*.json' --include='*.html' \
plugins/ shopdb/ scripts/ deploy/ frontend/src/ tools/ 2>/dev/null \
--include='*.json' --include='*.html' --include='*.cs' \
--exclude-dir=bin --exclude-dir=obj --exclude-dir=publish --exclude-dir=.vs \
plugins/ shopdb/ scripts/ deploy/ frontend/src/ tools/ agent/ 2>/dev/null \
| grep -v '/tests\?/' \
| grep -v '\.spec\.js:' \
| grep -v 'site_imports/' \
| grep -v 'installer/bundle/' \
| grep -v 'offline-packages/' \
| grep -v '\.plugins-staged/' \
| grep -v 'check-naming-and-style\.sh' \
| grep -v 'export-github\.sh' \
| grep -v '\.csproj:' \
| grep -v 'ADR-015-OK' || true)
if [ -n "$SITE_HITS" ]; then
COUNT=$(echo "$SITE_HITS" | wc -l)

View File

@@ -22,7 +22,7 @@ edge -> pulls the 'edge' image (built from main), red DEV badge.
no source compile behind the corporate proxy).
Windows-only by design: pre-release audience is Windows. Not a production
installer - see deploy/windows/installer/shopdb-install.ps1 for that.
installer. This branch ships the Docker stack; see docs/DEPLOY.md.
.PARAMETER Channel
'stable' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Docker container entrypoint for ShopDB Flask.
# Waits for the database to answer, then starts whatever it was given.
#
# This deliberately does NOT run migrations, plugin bootstrap or seeds. That
# sequence lives in the `migrate` service in docker-compose.yml (and its
# equivalents in the pull/airgap compose files) so there is exactly one place
# it happens. Running it here as well would mean every api restart re-attempted
# schema work, and a failing migration under `restart: unless-stopped` would
# crash-loop and bury the original error under retry noise.
#
# `up -d api` is still safe on its own: compose honours the
# service_completed_successfully dependency even when a single service is
# named, so the migrate service runs to completion first.
set -e
echo "==> ShopDB Flask Container Starting"
# Wait for database to be ready. Kept here because it is about this container's
# own readiness to serve, not about schema state. `flask db current` is a cheap
# round-trip that proves the connection works.
if [ -n "$DATABASE_URL" ]; then
echo "==> Waiting for database..."
for i in {1..30}; do
if flask db current &>/dev/null; then
echo "==> Database ready"
break
fi
echo " Attempt $i/30: database not ready, waiting..."
sleep 2
done
fi
echo "==> Starting application"
exec "$@"

View File

@@ -129,9 +129,6 @@ def export(connection):
# --- small generic vocabularies ---------------------------------------
# Each is a controlled list that means the same thing at any site.
if has('measuringtooltypes'):
data['measuringtooltypes'] = rows(
'SELECT name, description, color FROM measuringtooltypes ORDER BY name')
if has('notificationtypes'):
data['notificationtypes'] = rows(
'SELECT typename, typedescription, typecolor FROM notificationtypes ORDER BY typename')

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,22 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
OUTPUT = REPO / 'docs' / 'PROJECT-MAP.md'
HEADER = """<!-- GENERATED by scripts/gen_project_map.py - do not edit by hand.
# The docmeta block is part of the generated output, not something a human
# re-adds afterwards. This script rewrites the page whole, so anything hand-added
# above the GENERATED comment survives exactly until the next regeneration - and
# tests/test_docs_index.py requires the block to be the FIRST thing in the file,
# so losing it turns a routine regeneration into a red docs gate. Emitting it
# here is the only arrangement where "regenerate" and "the gate is green" are
# the same action. Keep the summary under the 120-character limit that test
# enforces, and keep the field vocabulary in step with it.
HEADER = """<!-- docmeta
purpose: reference
audience: developer, adopter
status: current
summary: Generated inventory - versions, plugins, Alembic heads, ADR index and codebase size.
loadwith: none
-->
<!-- GENERATED by scripts/gen_project_map.py - do not edit by hand.
Regenerate with: venv/bin/python scripts/gen_project_map.py -->
# Project map

View File

@@ -27,8 +27,8 @@ Dependency edges are real, not flat: uv writes '# via <parent>' comments into
requirements.txt, and package-lock records each package's dependencies.
NOT COVERED: the application's own source, which git records, and the operating
system. See deploy/windows/installer/bundle-lock.json for the third-party
binaries (Python installer, IIS MSIs) that ship alongside this.
system. A deployment that ships third-party binaries alongside the application
is responsible for recording those separately.
Usage:
generate_sbom.py <repo-root> [-o out.json] [--timestamp ISO8601] [--appversion X]

View File

@@ -0,0 +1,9 @@
# Sizing-simulation overlay. NOT part of the shipped image.
#
# Layers gevent onto the built production image so the gevent worker model can
# be measured against the gthread baseline under identical application code.
# Delete with the rest of scripts/loadtest once sizing questions are settled.
FROM shopdb-flask-wilm-api:latest
USER root
RUN pip install --no-cache-dir "gevent==25.9.1"

View File

@@ -0,0 +1,219 @@
"""Patch-day burst simulator: synchronized cold start of an endpoint fleet.
WHY this is separate from mixed_workload.py: that harness RAMPS agents in over
~20s and measures steady state, which deliberately avoids a thundering herd.
Patch day is the opposite case and stresses different machinery:
- a fresh TLS handshake per agent, all at once (handshakes are CPU-heavy)
- _resolve_asset_for_hostname runs on every arrival, so the DB pool is hit by
a synchronized burst rather than the smooth churn of steady state
- no warm connections in the pool, no warm caches
Modelled on the real agent (verified in agent/ShopDbEndpointAgent.Core/):
- CommandWorker.ExecuteAsync polls IMMEDIATELY, no startup delay, no jitter
- TelemetryWorker also reports immediately; its jitter applies only BETWEEN
runs, so first contact is synchronized too. Patch day is therefore TWO
simultaneous bursts per endpoint, not one.
- transient failures retry at a FIXED 10s (TransientRetryDelay), not
exponential-with-jitter, so a herd that fails together re-arrives together
--arrivalseconds is the spread of the reboot window. 0 means every agent hits at
once (single maintenance window, worst case); 60 approximates a staged ring.
Stdlib asyncio only, so it needs no dependency install in the container.
Usage:
python burst_arrival.py --fleet 700 --arrivalseconds 0 --seconds 120
"""
import argparse
import asyncio
import json
import os
import random
import ssl
import statistics
import time
STOP = False
def percentile(values, fraction):
if not values:
return 0.0
ordered = sorted(values)
index = min(int(len(ordered) * fraction), len(ordered) - 1)
return ordered[index]
async def request(host, port, context, method, path, timeout,
api_key=None, body=None):
"""One HTTP/1.1 request over a FRESH connection, so each call pays a full
TLS handshake - which is the point on patch day."""
started = time.monotonic()
writer = None
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port, ssl=context), timeout=timeout)
handshake = time.monotonic() - started
headers = [
'%s %s HTTP/1.1' % (method, path),
'Host: %s' % host,
'Accept: application/json',
'Connection: close',
]
if api_key:
headers.append('X-API-Key: %s' % api_key)
payload = b''
if body is not None:
payload = json.dumps(body).encode()
headers.append('Content-Type: application/json')
headers.append('Content-Length: %d' % len(payload))
writer.write(('\r\n'.join(headers) + '\r\n\r\n').encode() + payload)
await writer.drain()
header = await asyncio.wait_for(reader.readuntil(b'\r\n'), timeout=timeout)
status = int(header.split()[1])
await asyncio.wait_for(reader.read(), timeout=timeout)
return status, time.monotonic() - started, handshake
except Exception as error:
return type(error).__name__, time.monotonic() - started, None
finally:
if writer is not None:
try:
writer.close()
except Exception:
pass
def record(results, bucket, status, elapsed, handshake=None, first=False):
entry = results.setdefault(bucket, {
'times': [], 'status': {}, 'handshakes': [], 'firsttimes': []})
entry['times'].append(elapsed * 1000.0)
if handshake is not None:
entry['handshakes'].append(handshake * 1000.0)
if first:
entry['firsttimes'].append(elapsed * 1000.0)
key = str(status)
entry['status'][key] = entry['status'].get(key, 0) + 1
async def agent(index, host, port, context, api_key, results, arrivalseconds):
"""One endpoint coming back from a patch reboot.
Fires its telemetry report and its first command poll at the same instant,
mirroring the two workers both starting with no delay.
"""
if arrivalseconds > 0:
await asyncio.sleep(random.uniform(0, arrivalseconds))
hostname = 'LOADTEST%04d' % (index + 1)
async def telemetry():
status, elapsed, handshake = await request(
host, port, context, 'POST', '/api/collector/heartbeat',
timeout=120.0, api_key=api_key, body={'hostname': hostname})
record(results, 'telemetry_firstcontact', status, elapsed, handshake,
first=True)
telemetry_task = asyncio.ensure_future(telemetry())
path = '/api/endpoints/commands/next?hostname=%s' % hostname
first = True
while not STOP:
status, elapsed, handshake = await request(
host, port, context, 'GET', path, timeout=120.0, api_key=api_key)
record(results, 'fleet', status, elapsed, handshake, first=first)
if first:
first = False
if not isinstance(status, int):
# Mirrors CommandWorker TransientRetryDelay: fixed 10s, no jitter,
# so a herd that fails together re-arrives together.
await asyncio.sleep(10.0)
else:
await asyncio.sleep(0.05)
await telemetry_task
async def ui_user(host, port, context, results, think_seconds):
while not STOP:
status, elapsed, handshake = await request(
host, port, context, 'GET', '/api/dashboard/health', timeout=120.0)
record(results, 'ui', status, elapsed, handshake)
await asyncio.sleep(think_seconds)
async def main():
global STOP
parser = argparse.ArgumentParser()
parser.add_argument('--fleet', type=int, default=700)
parser.add_argument('--users', type=int, default=5)
parser.add_argument('--arrivalseconds', type=float, default=0.0)
parser.add_argument('--seconds', type=int, default=120)
parser.add_argument('--host', default='api')
parser.add_argument('--port', type=int, default=5001)
parser.add_argument('--think', type=float, default=3.0)
arguments = parser.parse_args()
api_key = os.environ.get('LOADTEST_TOKEN', '')
if not api_key:
raise SystemExit('LOADTEST_TOKEN not set')
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
results = {}
tasks = []
for _ in range(arguments.users):
tasks.append(asyncio.ensure_future(ui_user(
arguments.host, arguments.port, context, results,
arguments.think)))
# Let the UI baseline establish before the storm hits.
await asyncio.sleep(5.0)
burst_started = time.monotonic()
for index in range(arguments.fleet):
tasks.append(asyncio.ensure_future(agent(
index, arguments.host, arguments.port, context, api_key, results,
arguments.arrivalseconds)))
await asyncio.sleep(arguments.seconds)
STOP = True
await asyncio.sleep(2.0)
for task in tasks:
task.cancel()
summary = {
'fleet': arguments.fleet,
'arrivalseconds': arguments.arrivalseconds,
'users': arguments.users,
'seconds': arguments.seconds,
'classes': {},
}
for bucket, entry in sorted(results.items()):
times = entry['times']
block = {
'samples': len(times),
'status': entry['status'],
'median_ms': round(statistics.median(times), 1) if times else 0.0,
'p95_ms': round(percentile(times, 0.95), 1),
'max_ms': round(max(times), 1) if times else 0.0,
}
if entry['handshakes']:
block['handshake_median_ms'] = round(
statistics.median(entry['handshakes']), 1)
block['handshake_p95_ms'] = round(
percentile(entry['handshakes'], 0.95), 1)
block['handshake_max_ms'] = round(max(entry['handshakes']), 1)
if entry['firsttimes']:
block['firstcontact_median_ms'] = round(
statistics.median(entry['firsttimes']), 1)
block['firstcontact_p95_ms'] = round(
percentile(entry['firsttimes'], 0.95), 1)
block['firstcontact_max_ms'] = round(max(entry['firsttimes']), 1)
summary['classes'][bucket] = block
print(json.dumps(summary, indent=2))
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -0,0 +1,22 @@
"""Force SQLAlchemy pool sizing for a pool-comparison run.
WHY this exists: shopdb/config.py does not read pool size from the environment,
so a test container cannot be pointed at a different pool without a code change.
This patches Config.SQLALCHEMY_ENGINE_OPTIONS at import time, before create_app
builds the engine, purely so a bigger pool can be MEASURED before deciding
whether to make it permanent.
Not for production use. Mounted into a throwaway container as sitecustomize.py,
which Python imports automatically at startup.
"""
import os
from shopdb.config import Config
_size = int(os.environ.get('FORCE_POOL_SIZE', '5'))
_overflow = int(os.environ.get('FORCE_MAX_OVERFLOW', '10'))
_options = dict(Config.SQLALCHEMY_ENGINE_OPTIONS)
_options['pool_size'] = _size
_options['max_overflow'] = _overflow
Config.SQLALCHEMY_ENGINE_OPTIONS = _options

View File

@@ -0,0 +1,62 @@
"""Mint a temporary collector token for the load test, or revoke it after.
WHY a minted token: plugin.endpoints.envkeyfallback is false, which is the
agent kill switch working as intended - the env key no longer authorizes. The
harness therefore needs a real managed token scoped collector.ingest.endpoints,
exactly like a live agent.
The token is named LOADTEST so revoke() removes only this one and never touches
the real agent keys.
Usage (inside the api container, with PYTHONPATH=/app and cwd /app):
python loadtest_token.py mint # prints the secret once
python loadtest_token.py revoke
"""
import sys
from shopdb import create_app, db
from shopdb.core.models.apitoken import ApiToken
TOKEN_NAME = 'LOADTEST temporary collector token'
SCOPE = 'collector.ingest.endpoints'
def mint():
existing = ApiToken.query.filter_by(name=TOKEN_NAME).first()
if existing is not None:
db.session.delete(existing)
db.session.commit()
owner = ApiToken.query.filter(
ApiToken.scopes.like('%' + SCOPE + '%')).first()
if owner is None:
raise SystemExit('no existing collector token to copy ownership from')
secret = ApiToken.generate_secret()
token = ApiToken(
userid=owner.userid,
name=TOKEN_NAME,
tokenprefix=ApiToken.prefix_of(secret),
tokenhash=ApiToken.hash_secret(secret),
)
token.scopelist = [SCOPE]
db.session.add(token)
db.session.commit()
print(secret)
def revoke():
removed = ApiToken.query.filter_by(name=TOKEN_NAME).delete()
db.session.commit()
print('revoked %d loadtest token(s)' % removed)
if __name__ == '__main__':
action = sys.argv[1] if len(sys.argv) > 1 else ''
app = create_app()
with app.app_context():
if action == 'mint':
mint()
elif action == 'revoke':
revoke()
else:
print(__doc__)
sys.exit(2)

View File

@@ -0,0 +1,218 @@
"""Mixed-workload generator for sizing simulations.
WHY this shape: sizing cannot be judged from long-poll agents alone. The four
traffic classes have different concurrency costs, and only their SUM decides
whether a worker model holds:
fleet long-poll agents, one continuously-held connection each
ui human users clicking, latency-sensitive, the actual symptom
integration Maximo / ServiceNow style collector clients on a fixed interval
kiosk unauthenticated display endpoints, public routes, low rate
The fleet class is what saturates a thread pool; the ui class is what a person
notices. Measuring them together is the point - a model that keeps agents happy
while starving the UI has failed even though every agent request succeeded.
Runs INSIDE a container on the compose network. Generating from the Windows host
would put host ephemeral-port and TLS-handshake limits in the measurement path,
which would look like server saturation but would not be.
Stdlib asyncio only, so it needs no dependency install in the container.
Usage:
python mixed_workload.py --fleet 300 --users 5 --seconds 120
"""
import argparse
import asyncio
import json
import os
import ssl
import statistics
import time
STOP = False
def percentile(values, fraction):
if not values:
return 0.0
ordered = sorted(values)
index = min(int(len(ordered) * fraction), len(ordered) - 1)
return ordered[index]
async def request(host, port, context, method, path, timeout,
api_key=None, bearer=None, body=None):
"""One HTTP/1.1 request over a fresh connection. Returns (status, seconds)."""
started = time.monotonic()
writer = None
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port, ssl=context), timeout=timeout)
headers = [
'%s %s HTTP/1.1' % (method, path),
'Host: %s' % host,
'Accept: application/json',
'Connection: close',
]
if api_key:
headers.append('X-API-Key: %s' % api_key)
if bearer:
headers.append('Authorization: Bearer %s' % bearer)
payload = b''
if body is not None:
payload = json.dumps(body).encode()
headers.append('Content-Type: application/json')
headers.append('Content-Length: %d' % len(payload))
blob = ('\r\n'.join(headers) + '\r\n\r\n').encode() + payload
writer.write(blob)
await writer.drain()
header = await asyncio.wait_for(reader.readuntil(b'\r\n'), timeout=timeout)
status = int(header.split()[1])
await asyncio.wait_for(reader.read(), timeout=timeout)
return status, time.monotonic() - started
except Exception as error:
return type(error).__name__, time.monotonic() - started
finally:
if writer is not None:
try:
writer.close()
except Exception:
pass
def record(results, bucket, status, elapsed):
entry = results.setdefault(bucket, {'times': [], 'status': {}})
entry['times'].append(elapsed * 1000.0)
key = str(status)
entry['status'][key] = entry['status'].get(key, 0) + 1
async def fleet_agent(index, host, port, context, api_key, results):
"""Mirrors CommandWorker.cs: re-poll immediately after each response."""
hostname = 'LOADTEST%04d' % (index + 1)
path = '/api/endpoints/commands/next?hostname=%s' % hostname
while not STOP:
status, elapsed = await request(
host, port, context, 'GET', path, timeout=120.0, api_key=api_key)
record(results, 'fleet', status, elapsed)
await asyncio.sleep(0.05)
async def ui_user(host, port, context, bearer, results, think_seconds):
"""A human clicking around. Latency here is the user-visible symptom."""
paths = ['/api/dashboard/health', '/api/endpoints?page=1&per_page=25',
'/api/assets?page=1&per_page=25']
cursor = 0
while not STOP:
path = paths[cursor % len(paths)]
cursor += 1
# Unauthenticated probe still measures queueing; auth only changes the
# handler cost, not whether a worker slot was available to accept it.
status, elapsed = await request(
host, port, context, 'GET', path, timeout=120.0, bearer=bearer)
record(results, 'ui', status, elapsed)
await asyncio.sleep(think_seconds)
async def integration_client(name, host, port, context, api_key, results,
interval):
"""Maximo / ServiceNow style poller on a fixed interval."""
while not STOP:
status, elapsed = await request(
host, port, context, 'GET', '/api/collector/status',
timeout=120.0, api_key=api_key)
record(results, 'integration', status, elapsed)
await asyncio.sleep(interval)
async def kiosk_endpoint(index, host, port, context, results, interval):
"""Unauthenticated display screen. Public route, low rate."""
path = '/api/dashboarddefaults/display-role'
while not STOP:
status, elapsed = await request(
host, port, context, 'GET', path, timeout=120.0)
record(results, 'kiosk', status, elapsed)
await asyncio.sleep(interval)
async def main():
global STOP
parser = argparse.ArgumentParser()
parser.add_argument('--fleet', type=int, default=300)
parser.add_argument('--users', type=int, default=5)
parser.add_argument('--integrations', type=int, default=2)
parser.add_argument('--kiosks', type=int, default=8)
parser.add_argument('--seconds', type=int, default=120)
parser.add_argument('--host', default='api')
parser.add_argument('--port', type=int, default=5001)
parser.add_argument('--think', type=float, default=3.0)
parser.add_argument('--integration-interval', type=float, default=30.0)
parser.add_argument('--kiosk-interval', type=float, default=15.0)
parser.add_argument('--rampseconds', type=float, default=20.0)
arguments = parser.parse_args()
api_key = os.environ.get('LOADTEST_TOKEN', '')
if not api_key:
raise SystemExit('LOADTEST_TOKEN not set')
bearer = os.environ.get('LOADTEST_JWT') or None
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
results = {}
tasks = []
# Ramp the fleet in rather than opening every connection at once. A
# thundering herd measures handshake burst capacity, not steady state.
stagger = arguments.rampseconds / max(arguments.fleet, 1)
async def staggered(index):
await asyncio.sleep(index * stagger)
await fleet_agent(index, arguments.host, arguments.port, context,
api_key, results)
for index in range(arguments.fleet):
tasks.append(asyncio.ensure_future(staggered(index)))
for _ in range(arguments.users):
tasks.append(asyncio.ensure_future(ui_user(
arguments.host, arguments.port, context, bearer, results,
arguments.think)))
for index in range(arguments.integrations):
tasks.append(asyncio.ensure_future(integration_client(
'integration%d' % index, arguments.host, arguments.port, context,
api_key, results, arguments.integration_interval)))
for index in range(arguments.kiosks):
tasks.append(asyncio.ensure_future(kiosk_endpoint(
index, arguments.host, arguments.port, context, results,
arguments.kiosk_interval)))
await asyncio.sleep(arguments.seconds)
STOP = True
await asyncio.sleep(2.0)
for task in tasks:
task.cancel()
summary = {
'fleet': arguments.fleet,
'users': arguments.users,
'integrations': arguments.integrations,
'kiosks': arguments.kiosks,
'seconds': arguments.seconds,
'classes': {},
}
for bucket, entry in sorted(results.items()):
times = entry['times']
summary['classes'][bucket] = {
'samples': len(times),
'status': entry['status'],
'median_ms': round(statistics.median(times), 1) if times else 0.0,
'p95_ms': round(percentile(times, 0.95), 1),
'max_ms': round(max(times), 1) if times else 0.0,
}
print(json.dumps(summary, indent=2))
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -0,0 +1,141 @@
"""Async load generator for the endpoint command long-poll channel.
WHY this shape: the real agent (agent/ShopDbEndpointAgent.Core/CommandWorker.cs)
re-polls immediately on a 204, so every agent holds a connection essentially
continuously and fleet size IS the concurrency. This mirrors that loop rather
than issuing independent requests, so the measured concurrency matches a real
fleet of the same size.
Runs INSIDE a container on the compose network. Generating from the Windows host
would put host ephemeral-port and TLS-handshake limits in the measurement path,
which would look like server saturation but would not be.
While the fleet polls, a separate probe measures /api/dashboard/health - the
user-visible UI latency - because that, not agent latency, is the symptom that
matters for sizing.
Stdlib asyncio only, so it needs no dependency install in the container.
Usage:
python poll_fleet.py --agents 300 --seconds 90 --base https://api:5001
"""
import argparse
import asyncio
import json
import os
import ssl
import statistics
import time
STOP = False
def percentile(values, fraction):
if not values:
return 0.0
ordered = sorted(values)
index = min(int(len(ordered) * fraction), len(ordered) - 1)
return ordered[index]
async def request(host, port, context, path, api_key, timeout):
"""One HTTP/1.1 GET over a fresh connection. Returns (status, seconds)."""
started = time.monotonic()
writer = None
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port, ssl=context), timeout=timeout)
request_line = (
'GET %s HTTP/1.1\r\nHost: %s\r\nX-API-Key: %s\r\n'
'Accept: application/json\r\nConnection: close\r\n\r\n'
% (path, host, api_key))
writer.write(request_line.encode())
await writer.drain()
header = await asyncio.wait_for(reader.readuntil(b'\r\n'), timeout=timeout)
status = int(header.split()[1])
await asyncio.wait_for(reader.read(), timeout=timeout)
return status, time.monotonic() - started
except Exception as error:
return type(error).__name__, time.monotonic() - started
finally:
if writer is not None:
try:
writer.close()
except Exception:
pass
async def agent_loop(index, host, port, context, api_key, results, timeout):
"""Imitate one agent: poll, and on completion immediately poll again."""
hostname = 'LOADTEST%04d' % (index + 1)
path = '/api/endpoints/commands/next?hostname=' + hostname
while not STOP:
status, elapsed = await request(
host, port, context, path, api_key, timeout)
results.append((status, elapsed))
await asyncio.sleep(0)
async def main():
global STOP
parser = argparse.ArgumentParser()
parser.add_argument('--agents', type=int, required=True)
parser.add_argument('--seconds', type=int, default=90)
parser.add_argument('--host', default='api')
parser.add_argument('--port', type=int, default=5001)
parser.add_argument('--timeout', type=float, default=120.0)
parser.add_argument('--ramp', type=float, default=0.005)
arguments = parser.parse_args()
# A managed collector token, not the env key: the endpoints plugin has
# plugin.endpoints.envkeyfallback=false (the agent kill switch), so the env
# key no longer authorizes. See scripts/loadtest/loadtest_token.py.
api_key = os.environ.get('LOADTEST_TOKEN')
if not api_key:
raise SystemExit('LOADTEST_TOKEN not present in environment')
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
results = []
tasks = []
started = time.monotonic()
for index in range(arguments.agents):
tasks.append(asyncio.create_task(agent_loop(
index, arguments.host, arguments.port, context, api_key,
results, arguments.timeout)))
if arguments.ramp:
await asyncio.sleep(arguments.ramp)
ramp_seconds = time.monotonic() - started
await asyncio.sleep(arguments.seconds)
STOP = True
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
poll_ok = [d for s, d in results if s == 204]
poll_bad = [s for s, _ in results if s != 204]
failures = {}
for status in poll_bad:
failures[str(status)] = failures.get(str(status), 0) + 1
print(json.dumps({
'agents': arguments.agents,
'ramp_seconds': round(ramp_seconds, 1),
'duration_seconds': arguments.seconds,
'poll_completed': len(results),
'poll_ok_204': len(poll_ok),
'poll_failures': failures,
'poll_hold_median': round(statistics.median(poll_ok), 2) if poll_ok else None,
'poll_hold_p95': round(percentile(poll_ok, 0.95), 2) if poll_ok else None,
'poll_hold_max': round(max(poll_ok), 2) if poll_ok else None,
}, indent=2))
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -0,0 +1,42 @@
"""Report SQLAlchemy pool saturation from inside the API container.
WHY: MySQL's max_connections is NOT the ceiling that binds first. The effective
limit is per-process: pool_size + max_overflow, multiplied by worker count.
With the defaults (5 + 10) and 4 workers that is 60, not 151. A peak of 52
therefore means 87 percent of the real ceiling, not 34 percent.
Prints the computed ceiling so a measured high-water mark can be compared
against the right number.
Usage (inside the api container, PYTHONPATH=/app):
python pool_status.py
"""
import json
import os
from shopdb import create_app
from shopdb.api import db
def main():
app = create_app(os.environ.get('FLASK_ENV', 'production'))
with app.app_context():
engine = db.engine
pool = engine.pool
size = pool.size()
overflow = getattr(pool, '_max_overflow', 0)
workers = int(os.environ.get('GUNICORN_WORKERS', '4'))
print(json.dumps({
'pool_class': type(pool).__name__,
'pool_size': size,
'max_overflow': overflow,
'per_worker_ceiling': size + overflow,
'workers': workers,
'effective_ceiling': (size + overflow) * workers,
'checkedout': pool.checkedout(),
'status': pool.status(),
}, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,68 @@
"""Sample live MySQL connection counts at high frequency.
WHY: pool pressure from a long-poll fleet is BURSTY. The route releases its
connection before sleeping and re-acquires on wake, so the interesting number is
the peak concurrent checkout during a wake burst, not the steady-state count. A
slow sampler misses it entirely.
Runs on the HOST (needs the docker CLI).
Usage:
python sample_dbconns.py --seconds 120 --interval 0.25
"""
import argparse
import json
import statistics
import subprocess
import time
QUERY = ("SELECT COUNT(*), SUM(command<>'Sleep') "
"FROM information_schema.processlist WHERE user='shopdb';")
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--container', default='shopdb-flask-wilm-db-1')
parser.add_argument('--seconds', type=int, default=120)
parser.add_argument('--interval', type=float, default=0.25)
arguments = parser.parse_args()
total_samples = []
active_samples = []
deadline = time.monotonic() + arguments.seconds
command = ('mysql -u root -p$MYSQL_ROOT_PASSWORD -N -B -e "%s"' % QUERY)
while time.monotonic() < deadline:
try:
output = subprocess.check_output(
['docker', 'exec', arguments.container, 'sh', '-c', command],
stderr=subprocess.DEVNULL, text=True).strip()
except subprocess.CalledProcessError:
break
if not output:
break
parts = output.split()
total_samples.append(int(parts[0]))
active_samples.append(int(parts[1]) if parts[1] != 'NULL' else 0)
time.sleep(arguments.interval)
def summarize(values):
if not values:
return {'samples': 0}
ordered = sorted(values)
index = min(int(len(ordered) * 0.95), len(ordered) - 1)
return {
'samples': len(values),
'median': statistics.median(values),
'p95': ordered[index],
'max': max(values),
}
print(json.dumps({
'connections': summarize(total_samples),
'active_queries': summarize(active_samples),
}, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,80 @@
"""Sample container CPU and memory during a sizing run.
WHY: the sizing table claims specific vCPU and RAM figures. Those can only be
confirmed by watching the container under load, not by reasoning about greenlet
size. This records peak RSS and CPU so a claimed "4 GB is enough" is either
demonstrated or falsified.
Runs on the HOST (needs the docker CLI), unlike the generators.
Usage:
python sample_resources.py --container shopdb-sizing-api --seconds 120
"""
import argparse
import json
import re
import statistics
import subprocess
import time
def parse_size(text):
"""Docker stats prints like '1.234GiB' / '512MiB'. Return megabytes."""
match = re.match(r'^([0-9.]+)\s*([KMGT]?i?B)$', text.strip())
if not match:
return 0.0
value = float(match.group(1))
unit = match.group(2).upper().replace('I', '')
factors = {'B': 1.0 / (1024 * 1024), 'KB': 1.0 / 1024, 'MB': 1.0,
'GB': 1024.0, 'TB': 1024.0 * 1024.0}
return value * factors.get(unit, 1.0)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--container', required=True)
parser.add_argument('--seconds', type=int, default=120)
parser.add_argument('--interval', type=float, default=2.0)
arguments = parser.parse_args()
cpu_samples = []
memory_samples = []
deadline = time.monotonic() + arguments.seconds
while time.monotonic() < deadline:
try:
output = subprocess.check_output(
['docker', 'stats', '--no-stream', '--format',
'{{.CPUPerc}}|{{.MemUsage}}', arguments.container],
stderr=subprocess.DEVNULL, text=True).strip()
except subprocess.CalledProcessError:
break
if not output:
break
cpu_text, memory_text = output.split('|', 1)
cpu_samples.append(float(cpu_text.replace('%', '').strip()))
used = memory_text.split('/')[0]
memory_samples.append(parse_size(used))
time.sleep(arguments.interval)
def summarize(values):
if not values:
return {'samples': 0}
ordered = sorted(values)
index = min(int(len(ordered) * 0.95), len(ordered) - 1)
return {
'samples': len(values),
'median': round(statistics.median(values), 1),
'p95': round(ordered[index], 1),
'max': round(max(values), 1),
}
print(json.dumps({
'container': arguments.container,
'cpu_percent': summarize(cpu_samples),
'memory_mb': summarize(memory_samples),
}, indent=2))
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,78 @@
"""Measure UI latency against the API, independent of any load generator.
WHY separate from poll_fleet.py: when the probe shares an event loop with the
simulated fleet, generator-side scheduling can starve the probe and produce UI
latencies that are an artifact of the harness rather than of the server. Run
this in its own container, concurrently with the fleet, so the two cannot
interfere.
Usage:
python ui_probe.py --seconds 90 --interval 1.0
"""
import argparse
import json
import ssl
import statistics
import time
import urllib.request
def percentile(values, fraction):
if not values:
return 0.0
ordered = sorted(values)
index = min(int(len(ordered) * fraction), len(ordered) - 1)
return ordered[index]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--seconds', type=int, default=90)
parser.add_argument('--interval', type=float, default=1.0)
parser.add_argument('--url',
default='https://api:5001/api/dashboard/health')
parser.add_argument('--timeout', type=float, default=120.0)
arguments = parser.parse_args()
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
durations = []
failures = {}
deadline = time.monotonic() + arguments.seconds
while time.monotonic() < deadline:
started = time.monotonic()
try:
request = urllib.request.Request(arguments.url)
with urllib.request.urlopen(
request, timeout=arguments.timeout,
context=context) as response:
response.read()
if response.status == 200:
durations.append(time.monotonic() - started)
else:
key = str(response.status)
failures[key] = failures.get(key, 0) + 1
except Exception as error:
key = type(error).__name__
failures[key] = failures.get(key, 0) + 1
remaining = arguments.interval - (time.monotonic() - started)
if remaining > 0:
time.sleep(remaining)
print(json.dumps({
'ui_samples': len(durations) + sum(failures.values()),
'ui_ok_200': len(durations),
'ui_failures': failures,
'ui_median_ms': round(statistics.median(durations) * 1000, 1)
if durations else None,
'ui_p95_ms': round(percentile(durations, 0.95) * 1000, 1)
if durations else None,
'ui_max_ms': round(max(durations) * 1000, 1) if durations else None,
}, indent=2))
if __name__ == '__main__':
main()

View File

@@ -14,8 +14,9 @@ the IMPORT-API page in the project wiki (create through real endpoints
with an admin PAT + `X-Import-Mode`). Each adopting site writes a thin loader
that reads its own source database and drives that API.
- Reference implementation: [`scripts/site_imports/wjf/`](../site_imports/wjf/)
(West Jefferson classic-ASP -> flask).
- Shared machinery a loader builds on: `scripts/site_imports/` (the reversible
journal, the approval packet builder, and the resolvers), which the core WINC
adoption import uses too.
- Adoption playbook: the IMPORT-ADOPTION page in the project wiki.
## What's kept here

View File

@@ -4,11 +4,11 @@
Printed as a comma-separated list, dependencies before dependants, so the caller
can stage them in order.
This lives in its own file because TWO builders need the same answer:
scripts/build-site.sh (Linux) and deploy/windows/installer/build-installer.ps1
(Windows). It was inline in build-site.sh; a PowerShell reimplementation would
have been a second copy of the closure rules, free to drift from this one and
produce a bundle whose plugin set did not match the profile it was built from.
This lives in its own file rather than inline in its one caller
(scripts/build-site.sh) because the closure rules decide what a built site
CONTAINS. A second copy anywhere - another builder, a CI step - is free to drift
from this one and produce a tree whose plugin set does not match the profile it
was built from.
Usage: resolve_plugin_closure.py <site-profile.json> <repo-root>
"""

View File

@@ -1,219 +0,0 @@
# Production pilot runbook (West Jefferson)
Goal: stand up a real shopdb-flask instance loaded with WJ's classic-ASP data,
run it **in parallel** with the classic app for a validation window, then cut
over. This runbook adds the legacy-data import + verification + cutover on top of
the generic stand-up in [`DEPLOY.md`](DEPLOY.md). Read that first; this only
calls out the pilot-specific steps.
Related: [`IMPORT-ADOPTION.md`](IMPORT-ADOPTION.md) (import model),
[`IMPORT-API.md`](IMPORT-API.md) (the contract), [`BACKUP-RESTORE.md`](BACKUP-RESTORE.md),
`scripts/site_imports/wjf/` (the loader).
---
## 0. Pre-flight checklist
- [ ] Host provisioned (Docker + compose, or a VM with Python 3 + MySQL 8).
- [ ] Three current classic dumps in hand: `shopdb` (main), `cmmc_usb`,
`wjf_employees`. Take fresh dumps at import time - the classic app is live.
- [ ] Target MySQL 8, utf8mb4 (charset is contract, ADR-004). Old MySQL <5.7
needs `innodb_large_prefix=ON` + Barracuda.
- [ ] Decide the pilot URL (e.g. `shopdb-pilot.example.net`) - separate
from the classic app; do not reuse its hostname yet.
- [ ] Confirm the import decisions still hold (see the loader README / the
import plan): assetnumber fallback + skip-dups, metrology routing,
cmmc-only USB, warranties = Dell, occurrences parked.
## 1. Stand up the pilot instance
Follow `DEPLOY.md` steps 1-6 against a NEW empty database (name it clearly, e.g.
`shopdb_flask_pilot`):
```bash
flask db upgrade
flask plugin upgrade-all # applies every plugin's chain
flask seed permissions
flask seed settings
flask seed reference-data # seeds communicationtypes (IP) + the rest
```
**Enable every bundled plugin the site tracks - including usb**, which ships
disabled. A plugin's routes only register when it is enabled at app start, and
the importer needs them:
```bash
# apply-profile installs AND enables, in dependency order, and runs each
# plugin's own migration chain. `plugin enable` alone refuses a plugin that is
# not installed yet, so on a fresh database the loop below exited 1 on every
# iteration and enabled nothing (ADR-013 records the same defect).
flask plugin apply-profile deploy/site-profile-universal.json
flask plugin upgrade-all
```
Do **not** run the setup wizard yet - the import fills the data the wizard would
otherwise ask you to seed.
## 2. Load the classic data
The loader (`scripts/site_imports/wjf/`) reads the classic dumps and drives the
import API. It is site glue, not product code.
1. Load the three dumps into scratch source DBs the loader can read (strip the
`CREATE DATABASE`/`USE` lines so they land under scratch names, no clobber):
```bash
for pair in "shopdb_src:shopdb_dump.sql" "cmmc_usb_src:cmmc_usb_dump.sql" \
"wjf_employees_src:wjf_employees_dump.sql"; do
db="${pair%%:*}"; f="${pair##*:}"
mysql -h HOST -u root -p -e "CREATE DATABASE $db CHARACTER SET utf8mb4;"
sed -E '/^CREATE DATABASE/d; /^USE `/d' "$f" | mysql -h HOST -u root -p "$db"
done
```
2. Point the loader at the PILOT database and run all stages:
```bash
DATABASE_URL='mysql+pymysql://USER:PW@HOST:3306/shopdb_flask_pilot?charset=utf8mb4' \
venv/bin/python -m scripts.site_imports.wjf.run
```
The 17 stages run in order (reference -> employees -> catalog -> assets hub
-> locations -> printers -> metrology -> communications -> applications ->
warranties -> notifications -> knowledgebase -> relationships ->
defaultprinters -> subnets -> usb -> verify).
**Pointing it at a different source.** The three source databases are
named logically in the loader and resolved from the environment, so a
restore under another name needs no code edit:
```
SOURCE_DB_MAIN=prodscratch \
SOURCE_DB_USB=cmmc_usb_src \
SOURCE_DB_EMPLOYEES=wjf_employees_src \
DATABASE_URL=... venv/bin/python -m scripts.site_imports.wjf.run
```
Also `SOURCE_DB_HOST`, `SOURCE_DB_PORT`, `SOURCE_DB_USER`,
`SOURCE_DB_PASSWORD`. The run prints every source it redirected, so
"it imported nothing" can be told apart from "it read the wrong database".
**Resume is per target database.** A crashed run resumes from the crosswalk
in `idmap.json`, which maps this site's legacy ids to assetids **in one
specific database**. The loader stamps that database's identity into the
file and refuses a crosswalk built against a different one, because reusing
it attaches warranties and printer assignments to whatever asset now holds
that id - wrong data, no error. If you rebuild the target, delete
`idmap.json` or pass `--idmap` a fresh path.
The exit code means something: non-zero if any endpoint errored, so a run
that logged 200 failures cannot read as a success in a deploy log.
3. Reclassify servers into network devices. The classic DB stored servers as
PCs, so the import lands them as `computer` assets. Re-point them in place:
```
DATABASE_URL=... venv/bin/python scripts/reclassify_servers_to_network.py # dry run, prints matches
DATABASE_URL=... venv/bin/python scripts/reclassify_servers_to_network.py --commit # apply
# match on an exact computer type instead of the SVR- name prefix:
... --type "Server" --commit
```
The assetid does not change: communications, relationships, map position, and
audit history carry over. Only the extension row is swapped (computers ->
networkdevices) and the asset type flipped; reclassified devices get the
`Server` networkdevicetype. Run the dry run, eyeball the list, then commit.
Re-running is safe (already-moved assets no longer match).
Expected magnitude (from the WJ dumps used in development - your fresh dumps will
differ slightly):
| entity | count |
|---|---|
| assets [*] | ~983 (computer ~663, machine ~76, network ~58, measuring-tool ~136, printer ~50) |
| locations | ~24 |
| employees | ~415 |
| installs | ~850 |
| primary IPs | ~461 |
| warranties | ~464 |
| notifications | ~261 |
| knowledge base | ~341 |
| relationships | ~93 |
| subnets | ~37 |
| USB devices / events | ~18 / ~232 |
[*] Counts taken AFTER `scripts/reclassify_servers_to_network.py --commit`.
Servers imported as computers are re-pointed to network devices, so the computer
count drops and network rises by the same amount versus a raw import.
> PLACEHOLDER - re-measure before publishing. The computer/network split shown
> in the assets row above still reflects a RAW import (pre-reclassify). Re-run
> the counts on the current prodscratch AFTER the reclassify step above and drop
> in the actual numbers; do not carry these development figures forward as if
> they already account for the reclassify.
The `verify` stage prints a source-vs-target row-count audit; the gaps are the
documented skips (inactive rows, duplicate machinenumbers, LocationOnly, the
9999 placeholder).
## 3. Verify the import
- [ ] Read the `verify` stage output - source vs target counts line up modulo
the documented skips.
- [ ] Create the admin: `flask seed admin --username ... --email ...` (password
printed once). Mark setup done so the app does not force the wizard:
set `setup_complete=true` in settings (or click through the wizard,
skipping the seed steps).
- [ ] UI spot-check (log in): Computers list paginates the full fleet; the Shop
Floor Map plots assets, color-coded by type (positions came from
mapleft/maptop); open a PC detail (installs), a printer (IP + share), an
application (installed-on list), a KB article; check the employee
directory; check a couple of asset relationships.
- [ ] Branding: upload the site logo + floor-plan blueprint under Settings, set
facility name (Settings drive these per `CONFIG.md`).
- [ ] Photos are deferred - employees show initials until a photo batch is run.
## 4. Parallel-run window
- Keep the classic app authoritative during the window. The pilot is read-mostly
for validation; do not dual-write.
- Have a few real users (IT + a floor lead) work the pilot and log gaps.
- Re-import is cheap: fix a loader mapping, drop + rebuild the pilot DB,
**delete `idmap.json`**, re-run. Nothing you do to the pilot touches classic.
The crosswalk is bound to the database it was built against and the loader
refuses a stale one, but deleting it is the habit to have - a refusal costs
you a restart, and before the guard existed a stale file silently attached
rows to the wrong assets.
- Point the **collector** (GE-Enforce fleet ingest) at the pilot in parallel to
confirm live PC check-ins land (see `COLLECTOR-INTEGRATION.md`), using a
scoped service token.
## 5. Cutover
When the window is clean:
1. Freeze classic writes (announce a short read-only window).
2. Take final fresh dumps; re-run the loader into a clean pilot DB so the
cutover data is current. A clean DB means a fresh `idmap.json` too.
3. Verify counts + a fast UI spot-check.
4. Repoint the production hostname/DNS (or the reverse proxy) at the pilot.
5. Retire the classic app to read-only standby (do not delete - keep it as the
rollback for the agreed period).
## 6. Rollback
- Pre-cutover: trivially point back at classic (it never stopped being
authoritative).
- Post-cutover, within the standby window: repoint DNS/proxy back at classic;
investigate; re-cut when fixed. Because the loader is deterministic and the
classic DB is untouched, a re-run reproduces the flask DB exactly.
## 7. Post-cutover
- [ ] Backups on a schedule (`BACKUP-RESTORE.md`) - mysqldump + the `instance/`
dir (uploaded logos, floor plans, tokens).
- [ ] Run the employee-photo batch.
- [ ] GE-Enforce: publish manifests + cut the fleet over to the flask endpoints
when ready (`GE-ENFORCE-DEPLOY.md`) - independent of this pilot.
- [ ] Schedule the deferred data (occurrences, full communications fidelity)
only if a real need appears.

View File

@@ -1,66 +0,0 @@
# WJ classic-ASP import loader (reference implementation)
This is **site-specific reference glue**, not product code. It maps West
Jefferson's classic-ASP `shopdb` / `cmmc_usb` / `wjf_employees` schema onto the
maintained, schema-agnostic import contract in the IMPORT-API wiki page.
**Every adopting site has its own source database.** Nobody else runs this
loader. Instead, copy the pattern:
1. Point the harness at your source DB(s) (edit `harness.Source`).
2. Write per-entity stages that read your tables and POST to the same
the IMPORT-API wiki page endpoints with `Authorization: Bearer <admin PAT>` and
`X-Import-Mode: true`.
3. Persist legacy-id -> new-id crosswalks (see `harness.IdMap`) so later stages
resolve foreign keys and a crashed run resumes.
The import API is the stable contract; loaders are per-site. The mapping itself
is the bulk of the work: enumerate the source tables against this contract and
write one stage per entity (this loader is the worked example).
## Running (against a THROWAWAY import database)
```bash
DATABASE_URL='mysql+pymysql://root:PW@127.0.0.1:3306/shopdb_flask_import?charset=utf8mb4' \
venv/bin/python -m scripts.site_imports.wjf.run --stages reference,employees
```
Prereqs: a fresh target DB built with `flask db upgrade` + `flask plugin
upgrade-all` + `flask seed permissions/settings/reference-data`, and the three
source dumps loaded into scratch DBs (`shopdb_src`, `cmmc_usb_src`,
`wjf_employees_src`). See `scratchpad/IMPORT-PLAN.md` for the full mapping,
resolved decisions, and remaining stages.
## Status
All 17 stages built: `reference`, `employees`, `catalog`, `assets` (the hub +
machineid->assetid crosswalk), `locations`, `printers`, `metrology`,
`communications`, `applications` (+ installs), `warranties`, `notifications`,
`knowledgebase`, `relationships`, `defaultprinters`, `subnets`, `usb`, `verify`.
**The counts below are from the 2026-07-13 run and are STALE.** Two routing
changes landed after them - the `isactive=1` filter and the metrology routing
rework - so the asset mix has moved substantially. They are kept only as a
shape, not as a checklist: re-measure from the next full run before quoting
them anywhere, and do not verify a fresh import against them.
Last full run (2026-07-13, superseded): 983 assets (computer 663, machine 76,
network 58, measuring-tool 136, printer 50), 24 locations, 415 employees, 850
installs, 461 primary IPs, 464 warranties, 261 notifications, 341 KB, 93
relationships, 37 subnets, 18 USB devices + 232 events.
Known gaps, none of them blocking a scratch run:
- `warranties`, `notifications` and `knowledgebase` POST without a duplicate
check on the API side, so resuming ACROSS one of those stages doubles its
rows. Resuming at or after them is safe. Re-run into a clean target when in
doubt.
- There is no `--dry-run` and no guard on the target: the harness mints an
admin PAT and sets `setup_complete` in whatever `DATABASE_URL` points at.
Check the target before running.
- The `verify` stage counts target `assets` against source `machines`, which is
apples to oranges - the target also holds printers and synthesized metrology
tools.
Known follow-ups: general asset `locationid` is null (no source column outside
printers); the ~146 duplicate machinenumbers are first-wins-skipped by decision.

View File

@@ -1,306 +0,0 @@
"""Import harness for the WJ classic-ASP -> flask reference loader.
This is SITE-SPECIFIC reference glue, NOT product code. It maps West Jefferson's
classic schema onto the maintained, schema-agnostic import contract in
the IMPORT-API wiki page. Other sites copy this pattern against their own source DB;
they do not run this loader.
The harness builds the flask app against the current DATABASE_URL (point it at a
throwaway import database), mints an unscoped admin PAT in-process, and drives
the REAL import endpoints through the app test client with the X-Import-Mode
header - so it exercises the same routes/authz/validation an HTTP client would,
without needing a running server. Legacy-id -> new-id maps persist to JSON so a
crashed run resumes and later stages resolve foreign keys.
"""
import json
import uuid
import os
import pymysql
from shopdb import create_app
from shopdb.extensions import db
def _source_password():
"""Root password for the scratch source DBs: reuse the dev DATABASE_URL in
.env unless SOURCE_DB_PASSWORD overrides."""
if os.environ.get('SOURCE_DB_PASSWORD'):
return os.environ['SOURCE_DB_PASSWORD']
env = os.path.join(os.path.dirname(__file__), '..', '..', '..', '.env')
with open(os.path.abspath(env)) as handle:
for line in handle:
if line.startswith('DATABASE_URL='):
# Not every server runs as root, and 'root:' then simply is not
# there: split()[1] raised IndexError, which read as a crash
# rather than the clear message two lines down.
if 'root:' not in line:
continue
after = line.split('root:', 1)[1]
return after.split('@', 1)[0]
raise RuntimeError('could not resolve source DB password')
class Source:
"""Read-only access to the three source databases.
The stage functions name a source by its LOGICAL name - 'shopdb_src',
'cmmc_usb_src', 'wjf_employees_src' - and this class resolves each to a
real database. That indirection is the point: the names were hardcoded at
29 call sites, so pointing the loader at a differently-named copy (the
prodscratch restore, or a site's own dump) meant editing run.py, which is
not something anybody should do to aim a loader. The logical names stay in
the stages where they read as documentation; the mapping lives here.
Everything is environment-overridable, defaulting to the scratch layout:
SOURCE_DB_MAIN default shopdb_src
SOURCE_DB_USB default cmmc_usb_src
SOURCE_DB_EMPLOYEES default wjf_employees_src
SOURCE_DB_HOST/PORT/USER/PASSWORD
"""
# logical name -> (env var, default real name)
ALIASES = {
'shopdb_src': ('SOURCE_DB_MAIN', 'shopdb_src'),
'cmmc_usb_src': ('SOURCE_DB_USB', 'cmmc_usb_src'),
'wjf_employees_src': ('SOURCE_DB_EMPLOYEES', 'wjf_employees_src'),
}
def __init__(self, host=None, port=None, user=None, password=None):
self.password = password or _source_password()
self.host = host or os.environ.get('SOURCE_DB_HOST', '127.0.0.1')
self.port = int(port or os.environ.get('SOURCE_DB_PORT', 3306))
self.user = user or os.environ.get('SOURCE_DB_USER', 'root')
self._conns = {}
@classmethod
def resolve(cls, name):
"""The real database behind a logical source name."""
env, default = cls.ALIASES.get(name, (None, name))
return os.environ.get(env, default) if env else name
def db(self, name):
real = self.resolve(name)
if real not in self._conns:
self._conns[real] = pymysql.connect(
host=self.host, port=self.port, user=self.user,
password=self.password, database=real, charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
return self._conns[real]
def rows(self, dbname, sql, args=None):
with self.db(dbname).cursor() as cur:
cur.execute(sql, args or ())
return cur.fetchall()
class StaleIdMapError(RuntimeError):
"""The crosswalk on disk was built against a different target database."""
class IdMap:
"""Persisted legacy-id -> new-id crosswalks, one namespace per entity.
BOUND TO ONE TARGET DATABASE. The crosswalk holds this site's legacy ids
mapped to assetids in a specific database, and those assetids mean nothing
anywhere else. Two ways that used to go wrong, both silent:
- Delete idmap.json and re-run: every create 409s, nothing resolves into
the crosswalk, and warranties, installs, relationships and printer
assignments all quietly import as ~0 rows.
- Keep idmap.json across a drop-and-rebuild of the target: the legacy ids
now point at assetids belonging to the OLD database, so warranties and
printer assignments attach to whatever asset happens to hold that id
now. Wrong data, no error, and the runbook's two "drop and rebuild"
steps never said to delete the file.
The bind is a marker row in the target's own settings table, so it dies
with the data: a rebuild produces a new marker, and the mismatch is caught
before a single row is written. The database NAME is not enough on its own,
because a rebuild keeps it.
"""
MARKER_KEY = 'siteimport_targetinstance'
def __init__(self, path, target=None):
self.path = path
self.target = target
self._data = {}
if os.path.exists(path):
with open(path) as handle:
self._data = json.load(handle)
self._check_target()
def _check_target(self):
if not self.target:
return
seen = self._data.get('_target')
if seen is None:
# Written before this check existed. Adopt it rather than refuse:
# refusing would strand a crosswalk that is probably fine, and the
# next run is bound properly.
self._data['_target'] = self.target
self.save()
return
if seen != self.target:
raise StaleIdMapError(
'%s was built against target %s, but this run targets %s.\n'
'Those assetids belong to a different database and using them '
'would attach imported rows to the wrong assets.\n'
'Delete the file to start a fresh crosswalk, or point '
'--idmap at the one for this target.'
% (self.path, seen, self.target))
def put(self, namespace, legacy, new):
self._data.setdefault(namespace, {})[str(legacy)] = new
self.save()
def get(self, namespace, legacy, default=None):
return self._data.get(namespace, {}).get(str(legacy), default)
def has(self, namespace, legacy):
return str(legacy) in self._data.get(namespace, {})
def count(self, namespace):
entry = self._data.get(namespace, {})
return len(entry) if isinstance(entry, dict) else 0
def save(self):
with open(self.path, 'w') as handle:
json.dump(self._data, handle, indent=1)
class Harness:
"""App + admin PAT + import-mode test client + id maps."""
def __init__(self, idmap_path=None):
self.app = create_app()
self.appcontext = self.app.app_context()
self.appcontext.push()
self._silence_sql_logging()
self.client = self.app.test_client()
self._ensure_asset_types()
self.secret = self._mint_admin_pat()
default = os.path.join(os.path.dirname(__file__), 'idmap.json')
self.ids = IdMap(idmap_path or default, target=self._target_instance())
self.source = Source()
self.errors = []
self._defaultlevelid = None
def _target_instance(self):
"""A stable id for THIS target database, created once and stored in it.
Kept in the target's own settings table so it dies when the data does.
A drop-and-rebuild yields a new id, which is what lets IdMap catch a
crosswalk carried over from the previous database - the failure that
silently attaches imported rows to whatever asset now holds that id.
The database name alone would not do: a rebuild keeps the name.
"""
from shopdb.core.models import Setting
existing = Setting.query.filter_by(key=IdMap.MARKER_KEY).first()
if existing and existing.value:
return existing.value
marker = uuid.uuid4().hex
if existing:
existing.value = marker
else:
db.session.add(Setting(
key=IdMap.MARKER_KEY, value=marker, valuetype='string',
category='import',
description='Identifies this database to the site-import '
'crosswalk. Created by the loader; do not copy it '
'between databases.'))
db.session.commit()
return marker
@property
def defaultlevelid(self):
"""The level imported map positions belong to (ADR-017).
The legacy schema predates levels: it has ONE floor plan, so every
mapleft/maptop it carries is a coordinate on this site's default level.
Importing them without a level produces markers the map refuses to draw
- it will not guess a drawing for coordinates that do not name one.
"""
if self._defaultlevelid is None:
from shopdb.core.models import MapLevel
level = MapLevel.default_level()
self._defaultlevelid = level.levelid if level else None
return self._defaultlevelid
def _silence_sql_logging(self):
import logging
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
def _ensure_asset_types(self):
"""Register each plugin's AssetType + seeded subtypes. on_install (which
does this) is skipped when a DB is built with `flask plugin upgrade-all`
rather than a fresh `install`, so run it idempotently here - the create
routes 500 without the AssetType row."""
manager = self.app.extensions.get('plugin_manager')
if not manager:
return
for _name, plugin in manager.get_all_plugins().items():
try:
plugin.on_install(self.app)
except Exception:
pass
# A full import IS the setup, so mark it complete - otherwise the admin
# is bounced to the first-run wizard on every fresh import DB.
from shopdb.core.models import Setting
Setting.set('setup_complete', 'true', valuetype='boolean', category='site')
db.session.commit()
def _mint_admin_pat(self):
"""Create (or reuse) an admin user and an unscoped admin PAT. Unscoped
because import mode + role gates require full owner authority
(the IMPORT-API wiki page). Returns the clear secret."""
from werkzeug.security import generate_password_hash
from shopdb.core.models import User, Role, ApiToken
admin = User.query.join(User.roles).filter(Role.rolename == 'admin').first()
if not admin:
role = Role.query.filter_by(rolename='admin').first() \
or Role(rolename='admin', description='Administrator')
db.session.add(role)
admin = User(username='importer', email='importer@localhost',
passwordhash=generate_password_hash('x'), isactive=True)
admin.roles.append(role)
db.session.add(admin)
db.session.commit()
secret = ApiToken.generate_secret()
token = ApiToken(userid=admin.userid, name='wjf legacy import',
tokenprefix=ApiToken.prefix_of(secret),
tokenhash=ApiToken.hash_secret(secret), scopes=None)
db.session.add(token)
db.session.commit()
return secret
def _headers(self, import_mode):
# A PAT authenticates as Authorization: Bearer shopdb_pat_... (the
# before_request shim resolves it into a JWT identity). X-API-Key is for
# collector/service tokens, not personal tokens.
headers = {'Authorization': f'Bearer {self.secret}'}
if import_mode:
headers['X-Import-Mode'] = 'true'
return headers
def post(self, path, payload, import_mode=True):
"""POST to an import endpoint. Returns (status, data). 409s are returned
(idempotency handled by the caller), other 4xx/5xx are recorded."""
resp = self.client.post(path, json=payload, headers=self._headers(import_mode))
body = resp.get_json() or {}
data = body.get('data', body)
if resp.status_code >= 400 and resp.status_code != 409:
self.errors.append((path, resp.status_code, payload, data))
return resp.status_code, data
def get(self, path):
resp = self.client.get(path, headers=self._headers(False))
body = resp.get_json() or {}
return resp.status_code, body.get('data', body)
def close(self):
self.ids.save()
self.appcontext.pop()

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
# uninstall-shopdbagent.ps1
# ShopDB Endpoint Agent uninstaller. Run from an ELEVATED (admin) PowerShell on
# the endpoint. Reverses everything install-shopdbagent.ps1 and the site agent
# generator's install.ps1 create:
# the endpoint. Reverses everything the site agent generator's install.ps1
# creates:
# - stops and deletes the Windows service
# - kills any running agent/tray processes
# - removes the tray logon Run key (64-bit and 32-bit views)
# - unregisters the ShopDB user-context probe scheduled task
# - removes the credential hive HKLM\SOFTWARE\GE\ShopDB (both views)
# - removes the Defender path exclusion for the install directory
# - deletes the install directory and the ProgramData data/log tree
@@ -20,6 +21,10 @@ param(
[string] $ServiceName = "ShopDbEndpointAgent",
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
# Scheduled task that runs the user-context probe in the operator's session.
# Must match the installer's -UserContextTaskName.
[string] $UserContextTaskName = "ShopDB user-context probe",
# Also remove any server TLS certificate the site-agent installer imported
# into the machine Trusted Root store. Off by default because a root cert is
# shared trust material: only pull it when you are sure no other service on
@@ -113,6 +118,34 @@ Start-Sleep -Seconds 1
# 3. Remove the tray logon Run key entry (both views) and the credential hive.
Write-Host "[3/6] Removing registry entries ..."
Remove-HklmBothViews -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "ShopDbEndpointTray"
# Unregister the probe task. Left behind it would keep firing at every logon
# against a deleted exe, which shows up as a recurring task-failure history on a
# machine nobody believes has the agent on it any more.
try {
if (Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $UserContextTaskName -Confirm:$false -ErrorAction Stop
Write-Host " removed scheduled task '$UserContextTaskName'"
}
else {
Write-Host " scheduled task '$UserContextTaskName' not present."
}
}
catch {
Write-Warning " could not remove scheduled task '$UserContextTaskName': $($_.Exception.Message)"
}
# The install's one-shot tray launcher is transient and normally removes itself,
# but an install that failed partway can leave it behind.
try {
if (Get-ScheduledTask -TaskName "ShopDB tray first launch" -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName "ShopDB tray first launch" -Confirm:$false -ErrorAction Stop
Write-Host " removed leftover scheduled task 'ShopDB tray first launch'"
}
}
catch {
Write-Warning " could not remove scheduled task 'ShopDB tray first launch': $($_.Exception.Message)"
}
Remove-HklmBothViews -SubKey "SOFTWARE\GE\ShopDB"
# Remove the All-Users Start Menu shortcut the site-agent installer created.