Files
shopdb-flask/scripts/import_ntlars_backups.py
cproudlock 7d7caba56b Let a maintenance script find the database the site actually uses
Running `python scripts/export_catalog.py` on a deployed site died with
1045 "Access denied for user 'root'@'localhost'", naming a user that site
does not have.

Nothing loads .env for a plain script run. wsgi.py calls load_dotenv() for the
served app and the flask CLI loads .env for itself, so both reach the real
database; a bare `python scripts/...` gets neither. DATABASE_URL then stays
unset, FLASK_ENV defaults to development, and config.py falls back to a
hardcoded root@localhost URL.

The read-only failure above is the harmless version. Six of these seven
scripts WRITE - backfill, reclassify, retype, collapse, refile, import - and
on any box where those fallback root credentials happen to resolve, they would
have silently rewritten the wrong database with no error at all. That is also
why this survived: the dev .env points at root, so the fallback matched what
the developer expected and nothing ever looked wrong here.

All seven now load .env from the repo root, resolved from __file__ rather than
the working directory, since these are run by hand during a deployment and
rarely from the repo root.

tests/test_maintenance_scripts.py pins it for any future script: every script
that calls create_app must call load_dotenv, before create_app rather than
after it, and with an explicit path rather than a cwd-relative search.
2026-08-24 14:32:11 -04:00

151 lines
6.1 KiB
Python

#!/usr/bin/env python
"""Seed backup history from a directory of existing NTLARS .reg files.
A site's file share typically already holds per-machine backups collected by
hand over years, named by machine number. Importing them gives every machine a
baseline revision on day one, so the feature is useful before the fleet
collector has run even once.
Files are keyed by machine number in the FILENAME, which is how the share names
them. The MachineNo embedded in the file is compared against it and any
disagreement is reported: in the curated set the two always agree, so a
mismatch means the file is misfiled and should not be trusted as that machine's
baseline.
Dry run by default. Nothing is written without --commit.
venv/bin/python scripts/import_ntlars_backups.py /path/to/regs
venv/bin/python scripts/import_ntlars_backups.py /path/to/regs --commit
"""
import argparse
import os
import sys
from pathlib import Path
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
# Load .env before anything reads config. wsgi.py does this for the served app
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
# and config falls back to a hardcoded root@localhost URL. That fails on a real
# deployment with a bare 1045 Access denied for 'root', naming a user the site
# does not use - and on a box where those root credentials happen to work, it
# is worse, because the script silently reads or WRITES the wrong database.
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
from shopdb import create_app # noqa: E402
from shopdb.api import db # noqa: E402
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('directory', help='directory holding <machinenumber>.reg files')
parser.add_argument('--commit', action='store_true',
help='actually write (default is a dry run)')
parser.add_argument('--hostname', default='imported',
help="sourcehostname to record (default 'imported')")
args = parser.parse_args()
if not os.path.isdir(args.directory):
parser.error('not a directory: {}'.format(args.directory))
regs = sorted(f for f in os.listdir(args.directory) if f.lower().endswith('.reg'))
if not regs:
parser.error('no .reg files in {}'.format(args.directory))
app = create_app()
with app.app_context():
from shopdb.core.models import Asset
from plugins.backups.models import BackupRevision
from plugins.backups.services.registry import getkind, canonicalhash
kind = getkind('ntlars')
created = skipped = unresolved = rejected = duplicate = 0
mismatches = []
for filename in regs:
path = os.path.join(args.directory, filename)
machinenumber = os.path.splitext(filename)[0]
with open(path, 'rb') as handle:
raw = handle.read()
try:
projection = kind.parse(raw)
except ValueError as exc:
# Unconfigured NTLARS installs are in the corpus; they are not
# a usable baseline for anyone.
print(' REJECT {:<12} {}'.format(machinenumber, exc))
rejected += 1
continue
embedded = kind.embeddedmachineno(projection)
if embedded and embedded != machinenumber:
mismatches.append((machinenumber, embedded))
asset = db.session.query(Asset).filter(
Asset.assetnumber == machinenumber).first()
if asset is None:
print(' NOASSET {:<12} no asset with that assetnumber'.format(
machinenumber))
unresolved += 1
continue
contenthash = canonicalhash(projection)
latest = (db.session.query(BackupRevision)
.filter(BackupRevision.assetid == asset.assetid,
BackupRevision.backupkind == 'ntlars')
.order_by(BackupRevision.backuprevisionid.desc())
.first())
if latest is not None and latest.contenthash == contenthash:
duplicate += 1
continue
if args.commit:
revision = BackupRevision(
assetid=asset.assetid,
backupkind='ntlars',
storagebackend='shopdb',
contenthash=contenthash,
sourcefilename=filename,
bytesize=len(raw),
sourcehostname=args.hostname,
# The file's mtime is the closest thing to when the config
# was actually captured; createdat records the import.
collectedat=datetime.utcfromtimestamp(os.path.getmtime(path)),
)
revision.payload = projection
db.session.add(revision)
created += 1
if args.commit:
db.session.commit()
print()
print('files : {}'.format(len(regs)))
print('would create : {}'.format(created) if not args.commit
else 'created : {}'.format(created))
print('already current : {}'.format(duplicate))
print('no matching asset: {}'.format(unresolved))
print('rejected (blank) : {}'.format(rejected))
if mismatches:
print()
print('filename/embedded MachineNo disagreements ({}):'.format(
len(mismatches)))
for filename_no, embedded_no in mismatches[:20]:
print(' file={:<10} embedded={}'.format(filename_no, embedded_no))
print(' These are likely misfiled; review before trusting them.')
if not args.commit:
print()
print('DRY RUN - nothing written. Re-run with --commit to apply.')
if __name__ == '__main__':
main()