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:
251
scripts/backfill_app_fingerprints.py
Normal file
251
scripts/backfill_app_fingerprints.py
Normal 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
4
scripts/backup/.gitattributes
vendored
Normal 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
|
||||
25
scripts/backup/Dockerfile.backup
Normal file
25
scripts/backup/Dockerfile.backup
Normal 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
97
scripts/backup/README.md
Normal 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
|
||||
44
scripts/backup/SIMULATE-RESTORE.README.md
Normal file
44
scripts/backup/SIMULATE-RESTORE.README.md
Normal 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
171
scripts/backup/backup.sh
Executable 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
132
scripts/backup/manifest.py
Normal 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())
|
||||
BIN
scripts/backup/offline-packages/restic_0.17.3_linux_amd64.bz2
Normal file
BIN
scripts/backup/offline-packages/restic_0.17.3_linux_amd64.bz2
Normal file
Binary file not shown.
133
scripts/backup/restore.sh
Executable file
133
scripts/backup/restore.sh
Executable 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
|
||||
16
scripts/backup/simulate-restore-results.txt
Normal file
16
scripts/backup/simulate-restore-results.txt
Normal 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)
|
||||
235
scripts/backup/simulate-restore.sh
Executable file
235
scripts/backup/simulate-restore.sh
Executable 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
100
scripts/backup/status.sh
Executable 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
|
||||
}
|
||||
172
scripts/backup/test-restore-datarev.sh
Executable file
172
scripts/backup/test-restore-datarev.sh
Executable 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
61
scripts/backup/watch.sh
Executable 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
|
||||
28
scripts/bootstrap-plugins.sh
Normal file
28
scripts/bootstrap-plugins.sh
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'.
|
||||
|
||||
36
scripts/docker-entrypoint.sh
Normal file
36
scripts/docker-entrypoint.sh
Normal 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 "$@"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
9
scripts/loadtest/Dockerfile.gevent
Normal file
9
scripts/loadtest/Dockerfile.gevent
Normal 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"
|
||||
219
scripts/loadtest/burst_arrival.py
Normal file
219
scripts/loadtest/burst_arrival.py
Normal 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())
|
||||
22
scripts/loadtest/force_pool.py
Normal file
22
scripts/loadtest/force_pool.py
Normal 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
|
||||
62
scripts/loadtest/loadtest_token.py
Normal file
62
scripts/loadtest/loadtest_token.py
Normal 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)
|
||||
218
scripts/loadtest/mixed_workload.py
Normal file
218
scripts/loadtest/mixed_workload.py
Normal 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())
|
||||
141
scripts/loadtest/poll_fleet.py
Normal file
141
scripts/loadtest/poll_fleet.py
Normal 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())
|
||||
42
scripts/loadtest/pool_status.py
Normal file
42
scripts/loadtest/pool_status.py
Normal 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()
|
||||
68
scripts/loadtest/sample_dbconns.py
Normal file
68
scripts/loadtest/sample_dbconns.py
Normal 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()
|
||||
80
scripts/loadtest/sample_resources.py
Normal file
80
scripts/loadtest/sample_resources.py
Normal 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()
|
||||
78
scripts/loadtest/ui_probe.py
Normal file
78
scripts/loadtest/ui_probe.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user