Endpoint telemetry, PC Detail rework and a checked backup/restore path

This commit is contained in:
2026-09-06 18:53:11 -04:00
parent 32b8745d10
commit 993353dfc0
97 changed files with 13340 additions and 756 deletions

View File

@@ -0,0 +1,44 @@
"""The server-side panel group vocabulary must admit every group a plugin uses.
`asset_panels()` resolves a panel's group BEFORE the client sees it, coercing
any unrecognised value to 'details'. That makes ASSET_PANEL_GROUPS a third copy
of the tab vocabulary, alongside RENDERED_GROUPS in PluginAssetPanels.vue and
the tab list in PCDetail.vue. Phase 3 added 'serviceprocess' to the two
frontend copies and missed this one, so both Service/Process panels were
silently relabelled 'details' on the wire and the tab never appeared.
The failure is invisible: no error, no log line, and the panels still render -
just in the wrong tab, underneath the Details cards.
"""
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
from plugins.endpoints.plugin import EndpointsPlugin
def test_every_group_the_endpoints_plugin_declares_survives_resolution():
declared = {
str(panel.get('group') or '').strip().lower()
for panel in EndpointsPlugin().get_asset_panels()
}
declared.discard('')
unknown = declared - set(ASSET_PANEL_GROUPS)
assert not unknown, (
f'panels declare {sorted(unknown)}, which the server rewrites to '
"'details' - the tab will not appear"
)
def test_serviceprocess_is_admitted():
# Named explicitly: the generic test above would also pass if the
# serviceprocess panels were deleted outright.
assert 'serviceprocess' in ASSET_PANEL_GROUPS
def test_reports_is_admitted():
# Same reasoning as serviceprocess above. Operator Reports was moved out of
# System Monitors into a tab of its own, which is the same three-copy
# change that Phase 3 got wrong.
assert 'reports' in ASSET_PANEL_GROUPS
def test_details_remains_the_fallback():
assert ASSET_PANEL_GROUPS[0] == 'details'

View File

@@ -0,0 +1,242 @@
"""The post-restore schema upgrade: who runs it, and what happens when it fails.
A restore loads the dump in the Flask-less backup sidecar and leaves a sentinel,
because only the api container can run the migrations. These tests pin the two
properties that failure taught us to care about: the upgrade must not depend on
a browser staying open, and it must never run twice at once.
"""
import os
import pytest
from shopdb.core.api import shopdbbackups
@pytest.fixture
def backups_root(tmp_path, monkeypatch):
"""Point the backup routes at a real, empty directory."""
root = tmp_path / 'backups'
root.mkdir()
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV, str(root))
return root
def _arm(root, setname='wj-2026-09-06T120000Z'):
(root / shopdbbackups.UPGRADE_SENTINEL).write_text(setname, encoding='utf-8')
return setname
def test_only_one_claimant_wins_a_pending_upgrade(app, backups_root):
"""Two workers racing the same sentinel must not both run Alembic."""
setname = _arm(backups_root)
with app.app_context():
first = shopdbbackups._claim_upgrade()
second = shopdbbackups._claim_upgrade()
assert first == setname
assert second is None, 'a second worker claimed an upgrade already in flight'
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists()
assert (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
def test_claiming_nothing_is_not_an_error(app, backups_root):
with app.app_context():
assert shopdbbackups._claim_upgrade() is None
def test_a_successful_finalize_clears_the_claim_and_says_done(app, backups_root,
monkeypatch):
setname = _arm(backups_root)
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', lambda: None)
with app.app_context():
claimed = shopdbbackups._claim_upgrade()
assert shopdbbackups._finalize_claimed(claimed) is True
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
with app.app_context():
status = shopdbbackups._read_status('restore')
assert status and status['phase'] == 'done'
assert status['setname'] == setname
def test_a_failed_upgrade_parks_itself_instead_of_retrying_forever(app,
backups_root,
monkeypatch):
"""A failing migration must not be re-armed automatically.
Putting the sentinel back would run the same broken chain on every poll for
as long as the container lives.
"""
_arm(backups_root)
def _boom():
raise RuntimeError('Can\'t locate revision identified by abc123')
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', _boom)
with app.app_context():
claimed = shopdbbackups._claim_upgrade()
assert shopdbbackups._finalize_claimed(claimed) is False
assert (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
'a failed upgrade re-armed itself and will retry forever'
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
with app.app_context():
status = shopdbbackups._read_status('restore')
assert status and status['phase'] == 'failed'
def test_the_endpoint_does_not_run_the_upgrade_itself(app, client, auth_headers,
backups_root, monkeypatch):
"""Alembic in a request can outrun the worker timeout and be killed mid-chain."""
_arm(backups_root)
ran = []
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade',
lambda: ran.append(True))
response = client.post('/api/shopdbbackups/finalize-restore',
headers=auth_headers)
assert response.status_code == 200
assert ran == [], 'the request ran the schema upgrade inline'
assert response.get_json()['data']['queued'] is True
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
'the endpoint consumed the sentinel the watcher needs to claim'
def test_the_endpoint_re_arms_a_failed_upgrade(app, client, auth_headers,
backups_root):
"""Retrying a failed upgrade is what this endpoint is for now."""
(backups_root / shopdbbackups.UPGRADE_FAILED).write_text('wj-set',
encoding='utf-8')
response = client.post('/api/shopdbbackups/finalize-restore',
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['retried'] is True
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).read_text() == 'wj-set'
assert not (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
def test_nothing_pending_reports_nothing_pending(app, client, auth_headers,
backups_root):
response = client.post('/api/shopdbbackups/finalize-restore',
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['queued'] is False
def test_an_unconfigured_deployment_refuses(app, client, auth_headers,
tmp_path, monkeypatch):
"""No store behind the feature means say so, not 500 on a missing path."""
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV,
str(tmp_path / 'nothing-here'))
response = client.post('/api/shopdbbackups/finalize-restore',
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
class TestDatarevEvidence:
"""An unreadable data revision must not be badged restorable."""
def _real_core_rev(self, app):
with app.app_context():
known = shopdbbackups._known_core_revisions()
return sorted(known)[0]
def test_an_empty_datarev_is_no_evidence_not_a_legacy_set(self, app):
"""backup.sh discards the mysql error, so datarev can be ''.
Falling back to the code-to-code head check there would badge the set
compatible using the very comparison datarev was introduced to replace.
"""
core = self._real_core_rev(app)
manifest = {'datarev': '', 'schemarev': {'core': [core]}}
with app.app_context():
assert shopdbbackups._is_compatible(
manifest, {'core': [core]}) is False
def test_a_manifest_with_no_datarev_key_still_falls_back(self, app):
"""A genuine legacy set predates the field entirely and keeps working."""
core = self._real_core_rev(app)
manifest = {'schemarev': {'core': [core]}}
with app.app_context():
assert shopdbbackups._is_compatible(
manifest, {'core': [core]}) is True