diff --git a/scripts/fix_drive_letter_sharepaths.py b/scripts/fix_drive_letter_sharepaths.py new file mode 100644 index 00000000..95a3245f --- /dev/null +++ b/scripts/fix_drive_letter_sharepaths.py @@ -0,0 +1,123 @@ +"""Rewrite backup revisions whose sharepath is a mapped drive, not a UNC path. + +WHY THESE EXIST + +The part-marker client writes its snapshot through the drive letter GE-Enforce +mapped (W:), because that is the connection it authenticated - reaching the same +server by another name costs a second authentication, or fails outright with +error 1219. For a while it also RECORDED that path. + +A drive letter is not an answer to "where is this backup". W: exists only inside +the SYSTEM logon session, only while the enforcement cycle runs, and only on the +bay that wrote it. A tech opening the marker's page in ShopDB got a path that +resolves to nothing anywhere. The client now records the UNC; this repairs the +rows written before it did. + +WHAT IT CHANGES: backuprevisions.sharepath only. No revision is created, +deleted, re-filed or re-hashed, and no file on the share is touched - the bytes +are already where the UNC will point. + +Usage (on the target instance, in the app dir): + + venv\\Scripts\\python scripts\\fix_drive_letter_sharepaths.py \\ + --drive W: --unc "\\\\fileserver\\share\\path" + ... --commit # apply; dry run by default + +The --unc value is what that drive was mapped to - the SHARE root, without the +directory the snapshots live in, since that part is already in the stored path. +Check it against GE-Enforce.ps1's shopfloorShareRoot, or `net use` on a bay +mid-cycle. Take a database backup before --commit. +""" + +import argparse +import os +import re +import sys +from pathlib import Path + +# 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. Without it DATABASE_URL stays unset +# and config falls back to a hardcoded root@localhost URL: on a real deployment +# that fails with a bare 1045 Access denied naming a user the site does not use, +# and on a box where those credentials happen to work it is worse, because the +# script would WRITE to the wrong database. +load_dotenv(Path(__file__).resolve().parent.parent / '.env') + + +from shopdb import create_app +from shopdb.api import db +from plugins.backups.models import BackupRevision + + +DRIVEPATH = re.compile(r'^([A-Za-z]):(\\.*)$') + + +def rewrite(sharepath, drive, unc): + """The UNC form of one stored path, or None if it needs no change. + + Matches on the drive letter alone. A path that is already UNC, or sits on + some other drive, is left exactly as it is - this repairs a known mistake + rather than normalising everything it can reach. + """ + if not sharepath: + return None + match = DRIVEPATH.match(sharepath) + if not match: + return None + if match.group(1).upper() != drive.rstrip(':').upper(): + return None + return unc.rstrip('\\') + match.group(2) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--drive', default='W:', + help='Drive letter to rewrite (default W:)') + parser.add_argument('--unc', required=True, + help=r'Share root that drive pointed at, e.g. \\server\share') + parser.add_argument('--kind', default=None, + help='Limit to one backup kind (default: every kind)') + parser.add_argument('--commit', action='store_true', + help='Apply the changes. Dry run by default.') + args = parser.parse_args() + + if not args.unc.startswith('\\\\'): + raise SystemExit('--unc must be a UNC path starting with two backslashes') + + app = create_app() + with app.app_context(): + query = db.session.query(BackupRevision) + if args.kind: + query = query.filter(BackupRevision.backupkind == args.kind) + + changed = 0 + for revision in query.order_by(BackupRevision.backuprevisionid).all(): + newpath = rewrite(revision.sharepath, args.drive, args.unc) + if newpath is None: + continue + changed += 1 + print('revision {} {}\n{}-> {}'.format( + revision.backuprevisionid, revision.sharepath, + ' ' * (len(str(revision.backuprevisionid)) + 11), newpath)) + if args.commit: + revision.sharepath = newpath + + if not changed: + print('Nothing to fix: no revision stores a {} path.'.format(args.drive)) + return + + if args.commit: + db.session.commit() + print('\n{} revision(s) updated.'.format(changed)) + else: + print('\n{} revision(s) WOULD be updated. Re-run with --commit to apply.' + .format(changed)) + + +if __name__ == '__main__': + main() diff --git a/tests/test_fix_drive_letter_sharepaths.py b/tests/test_fix_drive_letter_sharepaths.py new file mode 100644 index 00000000..46c25412 --- /dev/null +++ b/tests/test_fix_drive_letter_sharepaths.py @@ -0,0 +1,55 @@ +"""The path rewrite, exercised without a database. + +rewrite() is pure on purpose: the risky part of this script is deciding WHICH +paths to touch, and that judgement should be testable without standing up an +app context. +""" + +import importlib.util +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / 'scripts' / 'fix_drive_letter_sharepaths.py' + + +def _rewrite(*args, **kwargs): + # Imported lazily and by path: scripts/ is not a package, and importing the + # module runs load_dotenv + create_app imports at module scope. + spec = importlib.util.spec_from_file_location('fixsharepaths', SCRIPT) + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except Exception as exc: # pragma: no cover - env-dependent + pytest.skip('script imports need an app environment: {}'.format(exc)) + return module.rewrite(*args, **kwargs) + + +UNC = r'\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor' + + +def test_a_mapped_drive_path_becomes_its_unc_equivalent(): + got = _rewrite(r'W:\backup\partmarker\WJPRT\20260825-144500', 'W:', UNC) + assert got == UNC + r'\backup\partmarker\WJPRT\20260825-144500' + + +def test_a_path_that_is_already_unc_is_left_alone(): + """The script repairs a known mistake; it does not normalise what it finds.""" + assert _rewrite(UNC + r'\backup\partmarker\WJPRT\x', 'W:', UNC) is None + + +def test_another_drive_is_left_alone(): + assert _rewrite(r'S:\DT\Shopfloor\backup\x', 'W:', UNC) is None + + +def test_an_empty_or_missing_path_is_left_alone(): + assert _rewrite('', 'W:', UNC) is None + assert _rewrite(None, 'W:', UNC) is None + + +def test_the_drive_letter_match_is_case_insensitive(): + assert _rewrite(r'w:\backup\x', 'W:', UNC) == UNC + r'\backup\x' + + +def test_a_trailing_backslash_on_the_unc_does_not_double_up(): + assert _rewrite(r'W:\backup\x', 'W:', UNC + '\\') == UNC + r'\backup\x'