Gate a restore on every schema chain, not just the core one
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

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
This commit is contained in:
cproudlock
2026-09-06 18:02:39 -04:00
parent fa6b99bf91
commit e2705d732a
6 changed files with 246 additions and 6 deletions

View File

@@ -140,3 +140,72 @@ def test_an_unconfigured_deployment_refuses(app, client, auth_headers,
headers=auth_headers)
assert response.status_code == 404
class TestPluginChainCompatibility:
"""The restore gate has to see plugin chains, not just the core one.
The failure it exists for: roll code back to an older plugin revision and
the CORE head can be identical, so a core-only gate passes a dump whose
plugin tables the code has never heard of. It loads - destroying what was
there - and only then dies in 'flask plugin upgrade-all'.
"""
def _manifest(self, core_rev, plugins=None):
manifest = {'datarev': core_rev, 'schemarev': {'core': [core_rev]}}
if plugins is not None:
manifest['dataplugins'] = plugins
return manifest
def _real_core_rev(self, app):
with app.app_context():
known = shopdbbackups._known_core_revisions()
assert known, 'expected this build to know its own core revisions'
return sorted(known)[0]
def _real_plugin_rev(self, app):
with app.app_context():
known = shopdbbackups._known_plugin_revisions()
assert known, 'expected this build to have plugin migration chains'
name = sorted(known)[0]
return name, sorted(known[name])[0]
def test_a_set_from_a_newer_plugin_chain_is_refused(self, app):
core = self._real_core_rev(app)
name, _ = self._real_plugin_rev(app)
manifest = self._manifest(core, {name: 'thisrevisiondoesnotexist'})
with app.app_context():
assert shopdbbackups._is_compatible(manifest, {}) is False, \
'a dump carrying unknown plugin data was called restorable'
def test_a_set_whose_plugin_chains_are_known_is_allowed(self, app):
core = self._real_core_rev(app)
name, rev = self._real_plugin_rev(app)
manifest = self._manifest(core, {name: rev})
with app.app_context():
assert shopdbbackups._is_compatible(manifest, {}) is True
def test_a_plugin_this_build_does_not_have_is_refused(self, app):
core = self._real_core_rev(app)
manifest = self._manifest(core, {'nosuchplugin': 'somerevision'})
with app.app_context():
assert shopdbbackups._is_compatible(manifest, {}) is False
def test_a_legacy_set_without_the_stamp_keeps_its_old_verdict(self, app):
"""Sets taken before dataplugins existed must not become unrestorable."""
core = self._real_core_rev(app)
with app.app_context():
assert shopdbbackups._is_compatible(self._manifest(core), {}) is True
assert shopdbbackups._is_compatible(
self._manifest(core, {}), {}) is True
def test_the_core_gate_still_refuses_an_unknown_core_revision(self, app):
name, rev = self._real_plugin_rev(app)
manifest = self._manifest('unknowncorerevision', {name: rev})
with app.app_context():
assert shopdbbackups._is_compatible(manifest, {}) is False