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.
180 lines
7.6 KiB
Python
180 lines
7.6 KiB
Python
"""Re-file existing backup revisions from an operation onto the part marker.
|
|
|
|
Backups collected before part markers became assets were filed against the
|
|
operation number the PC reported - several markers' configs landing on one
|
|
asset, where they overwrote each other. The collector now files a marker PC's
|
|
backup against that PC's own marker asset, but only for backups collected since
|
|
the change. Everything already stored stays where it was put, so the new marker
|
|
assets show an empty history while the operation shows a mixed one.
|
|
|
|
This moves each historical revision to the marker it actually came from. The
|
|
attribution is exact, not a guess: a revision records the PC it was read from
|
|
(sourcehostname), and the collector records which marker each PC drives (an
|
|
active `collector:partmarker` relationship). The move is that mapping applied.
|
|
|
|
WHAT IT MOVES: a revision whose source hostname resolves to a PC that has a
|
|
marker, and which is currently filed against an asset that is NOT that marker.
|
|
|
|
WHAT IT LEAVES: revisions with no source hostname, since nothing says which
|
|
marker they came from; revisions from PCs that drive no marker, which is every
|
|
ordinary machine; and anything already filed correctly.
|
|
|
|
Moving a revision can make it a duplicate of one already on the marker - the
|
|
same config counted twice, once under the operation and once under the marker.
|
|
Those are collapsed as part of the move, keeping the earliest, so the marker's
|
|
history reads as one clean chain.
|
|
|
|
Usage (on the target instance, in the app dir):
|
|
venv\\Scripts\\python scripts\\refile_partmarker_backups.py # dry run
|
|
venv\\Scripts\\python scripts\\refile_partmarker_backups.py --commit # apply
|
|
... --hostname FBYTNCX3 # one PC
|
|
|
|
Dry run by default. Take a database backup before --commit.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from collections import defaultdict
|
|
|
|
# Run from anywhere: put the repo root (parent of scripts/) on the path.
|
|
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
|
|
from shopdb.api import db, Asset, AssetRelationship, RelationshipType
|
|
from plugins.backups.models import BackupRevision
|
|
from plugins.computers.models import Computer
|
|
from plugins.computers.plugin import PARTMARKER_LINK_ORIGIN
|
|
|
|
|
|
def _markerbyhostname():
|
|
"""hostname (lowercased) -> marker asset, for every PC that drives one."""
|
|
controls = RelationshipType.query.filter_by(
|
|
relationshiptype='controls').first()
|
|
if controls is None:
|
|
raise SystemExit("no 'controls' relationship type; "
|
|
'run flask seed reference-data')
|
|
|
|
rows = (db.session.query(Computer, AssetRelationship)
|
|
.join(AssetRelationship,
|
|
AssetRelationship.sourceassetid == Computer.assetid)
|
|
.filter(AssetRelationship.relationshiptypeid ==
|
|
controls.relationshiptypeid,
|
|
AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
|
|
AssetRelationship.isactive.is_(True))
|
|
.all())
|
|
|
|
markers = {}
|
|
for computer, link in rows:
|
|
marker = db.session.get(Asset, link.targetassetid)
|
|
if marker is not None and computer.hostname:
|
|
markers[computer.hostname.strip().lower()] = marker
|
|
return markers
|
|
|
|
|
|
def _dedupe(revisions):
|
|
"""Within one chain, drop a revision repeating the hash before it.
|
|
|
|
Applied after a move, because the same config can arrive twice: once as the
|
|
row filed under the operation and once as a row the collector has already
|
|
written against the marker. Keeps the earliest of each run, so the first
|
|
time a config was seen is the date that survives.
|
|
"""
|
|
doomed = []
|
|
previoushash = None
|
|
for revision in sorted(revisions, key=lambda r: r.backuprevisionid):
|
|
if previoushash is not None and revision.contenthash == previoushash:
|
|
doomed.append(revision)
|
|
else:
|
|
previoushash = revision.contenthash
|
|
return doomed
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--commit', action='store_true',
|
|
help='apply the moves (default is a dry run)')
|
|
parser.add_argument('--hostname', help='limit to one source PC')
|
|
args = parser.parse_args()
|
|
|
|
app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
|
with app.app_context():
|
|
markers = _markerbyhostname()
|
|
if not markers:
|
|
print('No PC drives a part marker. Nothing to re-file.')
|
|
print('If that is unexpected, the marker assets are minted by the '
|
|
'computers collector when a PC reports with '
|
|
'pctype=gea-shopfloor-partmarker.')
|
|
return
|
|
|
|
print('{} PC(s) drive a marker.'.format(len(markers)))
|
|
|
|
query = db.session.query(BackupRevision).filter(
|
|
BackupRevision.sourcehostname.isnot(None))
|
|
if args.hostname:
|
|
query = query.filter(
|
|
BackupRevision.sourcehostname.ilike(args.hostname))
|
|
|
|
moved = 0
|
|
touchedchains = defaultdict(list)
|
|
|
|
for revision in query.order_by(BackupRevision.backuprevisionid).all():
|
|
marker = markers.get((revision.sourcehostname or '').strip().lower())
|
|
if marker is None or revision.assetid == marker.assetid:
|
|
continue
|
|
fromasset = db.session.get(Asset, revision.assetid)
|
|
print(' {:<12} {:<10} rev {:<6} {} -> {}'.format(
|
|
revision.sourcehostname, revision.backupkind,
|
|
revision.backuprevisionid,
|
|
fromasset.assetnumber if fromasset else revision.assetid,
|
|
marker.assetnumber))
|
|
revision.assetid = marker.assetid
|
|
moved += 1
|
|
touchedchains[(marker.assetid, revision.backupkind,
|
|
revision.sourcehostname)].append(revision)
|
|
|
|
# Collapse duplicates the move created, counting rows already on the
|
|
# marker so an existing revision is compared against, not ignored.
|
|
collapsed = 0
|
|
for (assetid, kind, source) in list(touchedchains):
|
|
chain = (db.session.query(BackupRevision)
|
|
.filter(BackupRevision.assetid == assetid,
|
|
BackupRevision.backupkind == kind,
|
|
BackupRevision.sourcehostname == source)
|
|
.all())
|
|
for revision in _dedupe(chain):
|
|
db.session.delete(revision)
|
|
collapsed += 1
|
|
|
|
print()
|
|
print('{} revision(s) re-filed, {} duplicate(s) collapsed.'.format(
|
|
moved, collapsed))
|
|
|
|
if not moved:
|
|
print('Nothing to do.')
|
|
db.session.rollback()
|
|
return
|
|
if args.commit:
|
|
db.session.commit()
|
|
print('APPLIED.')
|
|
else:
|
|
db.session.rollback()
|
|
print('Dry run - nothing changed. Re-run with --commit to apply.')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|