Files
shopdb-flask/scripts/backup/manifest.py
cproudlock e2705d732a
Some checks failed
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Failing after 15s
CI / backend (push) Failing after 4m56s
Gate a restore on every schema chain, not just the core one
The compatibility gate read alembic_version and nothing else, but plugin schemas
move independently of core. Roll the code back to an older plugin revision and
the core head can be identical, so a dump carrying plugin tables the code has
never heard of passed the gate, loaded - destroying what was there - and only
then failed in 'flask plugin upgrade-all' with "Can't locate revision". A failed
restore that has already replaced what it was restoring over is the worst
outcome this feature can produce, and it was reachable from an ordinary rollback.

Backups now stamp the applied revision of every alembic_version_<plugin> table
alongside the core one, read from the dumped database the same way. restore.sh
checks each against that plugin's own versions directory and refuses before
touching anything, naming the plugin and the revision to deploy past. A plugin
in the dump that this build does not have at all is refused for the same reason.
The list endpoint's compatibility badge asks the same question, so the UI cannot
offer a set the restore will reject.

Sets taken before the stamp existed carry no dataplugins and keep their old
core-only verdict. Their plugin revisions are unknowable, and making every
existing set unrestorable to close this would be a worse trade than the gap.

Proven against a real stack, reproducing the exact case: core head identical,
endpoints data stamped at a revision the build does not have. The restore
refused, the live row was still there afterwards, and it stopped early enough
that the pre-restore safety copy was never needed. The same set with its true
revision restored normally.

Suite 2605 passed / 9 skipped, with the gate covered both ways plus the legacy
fallback.

Scope: product
2026-09-06 18:02:39 -04:00

164 lines
6.3 KiB
Python

#!/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 CORE 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.
- dataplugins: the same thing for every PLUGIN chain - the applied revision of
each alembic_version_<plugin> table. The core chain alone cannot
answer compatibility: plugin schemas move independently, so code
rolled back to an older plugin revision can sit at an identical
core head while the dump carries plugin tables it has never heard
of. Gating on core only let that pass, load, and then fail during
the plugin upgrade with the data already replaced.
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 _parse_dataplugins(raw):
"""The applied per-plugin revisions, as a {plugin: revision} map.
Bad or absent JSON becomes an empty map rather than an error: a manifest
that cannot say what the plugin chains were at is exactly the legacy case
the restore gate already knows how to treat conservatively, and failing the
backup here would lose the set over a stamp.
"""
if not raw:
return {}
try:
value = json.loads(raw)
except ValueError:
return {}
if not isinstance(value, dict):
return {}
return {str(k): str(v) for k, v in value.items() if k and v}
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 '',
'dataplugins': _parse_dataplugins(args.dataplugins),
'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
if args.dataplugins is not None:
manifest['dataplugins'] = _parse_dataplugins(args.dataplugins)
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('--dataplugins',
help='JSON object of {plugin: applied revision}')
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())