Let a share-backed backup say what is in it
This commit is contained in:
@@ -207,10 +207,21 @@ def test_partmarker_kind_is_share_backed_and_not_renderable():
|
||||
|
||||
|
||||
def test_partmarker_sharedir_is_under_the_configured_backups_root():
|
||||
"""The marker kind keys its tree on the MARKER, not the machine type.
|
||||
|
||||
It overrides the base layout (<root>\\<machinetype>\\<identifier>\\<kind>)
|
||||
because the site files marker snapshots as <root>\\partmarker\\<asset>\\,
|
||||
with one timestamped snapshot directory per capture below that. The
|
||||
override exists so what ShopDB displays matches what the collector writes.
|
||||
"""
|
||||
root = r'\\fileserver\shopfloor\backups'
|
||||
path = registry.getkind('partmarker').sharedir('lathe', '3204', shareroot=root)
|
||||
assert path.startswith(root)
|
||||
assert path.endswith(r'lathe\3204\partmarker')
|
||||
assert path.endswith(r'partmarker\3204')
|
||||
# The machine type is deliberately NOT in the path: a marker is identified
|
||||
# by its own asset, and the type of the machine it serves is irrelevant to
|
||||
# where its patterns live.
|
||||
assert 'lathe' not in path
|
||||
|
||||
|
||||
def test_sharedir_is_empty_when_no_root_is_configured():
|
||||
@@ -608,8 +619,19 @@ def test_infopanel_is_kind_owned_so_a_successor_ships_its_own_card():
|
||||
panel = ntlarskind.infopanel()
|
||||
assert panel['id'] == 'backups-dncinfo'
|
||||
assert 'kind=ntlars' in panel['endpoint']
|
||||
# A kind with nothing to summarise contributes no panel at all.
|
||||
assert registry.getkind('partmarker').infopanel() is None
|
||||
|
||||
# The part marker kind now ships a card of its own, built from the manifest
|
||||
# the collector sends beside the hash. It could not before: a share kind
|
||||
# stored no description of its contents, and reading the share from the app
|
||||
# server is refused on purpose.
|
||||
markerpanel = registry.getkind('partmarker').infopanel()
|
||||
assert markerpanel['id'] == 'backups-partmarker-contents'
|
||||
assert 'kind=partmarker' in markerpanel['endpoint']
|
||||
# Marker assets are core type 'machine' like the other 144, and the panel
|
||||
# is kept off those by having no rows and no empty text rather than by any
|
||||
# special-casing.
|
||||
assert markerpanel['assettypes'] == ['machine']
|
||||
assert 'empty' not in markerpanel
|
||||
|
||||
|
||||
def test_base_kind_buildinfo_is_an_empty_card():
|
||||
@@ -1006,3 +1028,356 @@ def test_retention_reads_the_key_the_settings_page_writes(app, db):
|
||||
# The helper cannot see it, which is the whole defect: it looks under
|
||||
# plugin.backups.backups_retentioncount, which nothing writes.
|
||||
assert int(BackupsPlugin().get_setting('backups_retentioncount', 0) or 0) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Share kinds that describe themselves
|
||||
#
|
||||
# A 'share' kind keeps its bytes on SFLD, so ShopDB knew a snapshot existed and
|
||||
# where, but nothing about what was in it. The collector now accepts an optional
|
||||
# manifest - the index, not the content - which is what lets a marker's asset
|
||||
# page list its patterns and name the one that disappeared. Reading the share
|
||||
# from the app server is refused on purpose, so this is the only route.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _markermanifest(names, sourcehost='FBYTNCX3', family='TMC470'):
|
||||
return {
|
||||
'capturedat': '2026-08-25T12:00:00Z',
|
||||
'machine': '3204',
|
||||
'sourcehost': sourcehost,
|
||||
'family': family,
|
||||
'systems': ['TMP1700/470'],
|
||||
'files': [{'name': n, 'bytes': 266, 'sha256': 'ab' * 32,
|
||||
'system': 'TMP1700/470'} for n in names],
|
||||
}
|
||||
|
||||
|
||||
def test_a_share_kind_may_send_a_manifest_and_it_is_stored(bk_app, bk_plugin):
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
result = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204',
|
||||
'backupkind': 'partmarker',
|
||||
'contenthash': 'a' * 64,
|
||||
'sharepath': r'\\host\share\partmarker\3204\20260825-120000',
|
||||
'manifest': _markermanifest(['TESTPAT.PAT', 'NET.DAT']),
|
||||
})
|
||||
assert result['action'] == 'created'
|
||||
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
||||
# Bytes still live on the share; only the index is in the database.
|
||||
assert revision.storagebackend == 'share'
|
||||
assert revision.sharepath.endswith('20260825-120000')
|
||||
assert [f['name'] for f in revision.payload['files']] == \
|
||||
['TESTPAT.PAT', 'NET.DAT']
|
||||
|
||||
|
||||
def test_a_share_kind_without_a_manifest_still_works(bk_app, bk_plugin):
|
||||
"""The manifest is advisory. A client that sends none behaves as before."""
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
result = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204',
|
||||
'backupkind': 'partmarker',
|
||||
'contenthash': 'b' * 64,
|
||||
'sharepath': r'\\host\share\partmarker\3204\20260825-130000',
|
||||
})
|
||||
assert result['action'] == 'created'
|
||||
assert _db.session.get(
|
||||
BackupRevision, result['backuprevisionid']).payload is None
|
||||
|
||||
|
||||
def test_a_manifest_that_is_not_an_object_is_rejected(bk_app, bk_plugin):
|
||||
with bk_app.app_context():
|
||||
with pytest.raises(ValueError):
|
||||
bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204',
|
||||
'backupkind': 'partmarker',
|
||||
'contenthash': 'c' * 64,
|
||||
'sharepath': r'\\host\share\x',
|
||||
'manifest': 'not an object',
|
||||
})
|
||||
|
||||
|
||||
def test_marker_card_counts_by_kind_and_names_what_was_removed(bk_app, bk_plugin):
|
||||
"""The removed line is the reason the snapshot layout exists.
|
||||
|
||||
A pattern deleted from a controller is invisible everywhere else until
|
||||
somebody needs it. It is NAMED rather than counted - "1 removed" would send
|
||||
a tech to the share to find out which one.
|
||||
"""
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
first = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': 'd' * 64, 'sharepath': r'\\host\s\1',
|
||||
'sourcehostname': 'FBYTNCX3',
|
||||
'manifest': _markermanifest(['A.PAT', 'B.PAT', 'NET.DAT']),
|
||||
})
|
||||
_db.session.commit()
|
||||
second = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': 'e' * 64, 'sharepath': r'\\host\s\2',
|
||||
'sourcehostname': 'FBYTNCX3',
|
||||
'manifest': _markermanifest(['A.PAT', 'C.PAT', 'NET.DAT']),
|
||||
})
|
||||
_db.session.commit()
|
||||
assert first['action'] == 'created' and second['action'] == 'created'
|
||||
|
||||
revision = _db.session.get(BackupRevision, second['backuprevisionid'])
|
||||
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
||||
revisionid=revision.backuprevisionid)
|
||||
fields = {f['label']: f['value'] for f in card['fields']}
|
||||
|
||||
assert fields['Patterns'] == '2'
|
||||
assert fields['Settings files'] == '1'
|
||||
assert fields['Controller'] == 'TMC470'
|
||||
assert fields['Added'] == 'C.PAT'
|
||||
assert fields['Removed'] == 'B.PAT'
|
||||
|
||||
|
||||
def test_the_first_snapshot_has_nothing_to_diff_against(bk_app, bk_plugin):
|
||||
"""Every file would otherwise read as 'added', which says nothing."""
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
result = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': 'f' * 64, 'sharepath': r'\\host\s\1',
|
||||
'manifest': _markermanifest(['A.PAT']),
|
||||
})
|
||||
_db.session.commit()
|
||||
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
||||
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
||||
revisionid=revision.backuprevisionid)
|
||||
labels = [f['label'] for f in card['fields']]
|
||||
assert 'Added' not in labels and 'Removed' not in labels
|
||||
|
||||
|
||||
def test_a_revision_with_no_manifest_renders_no_card():
|
||||
"""Older revisions predate manifests; they must not error the panel."""
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
assert partmarkerinfo.build(None, 1)['sectioncount'] == 0
|
||||
assert partmarkerinfo.build({}, 1)['fields'] == []
|
||||
|
||||
|
||||
def test_a_noop_backfills_a_manifest_the_row_never_had(bk_app, bk_plugin):
|
||||
"""The hash attests the BYTES, not the manifest.
|
||||
|
||||
A client that starts describing itself posts the same contenthash as
|
||||
before, so without this the manifest would never be stored on exactly the
|
||||
markers that are healthy and unchanging - which is most of them. The panel
|
||||
would then be visible only on markers that had recently changed.
|
||||
"""
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
bare = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '1' * 64, 'sharepath': r'\\host\s\old',
|
||||
'sourcehostname': 'FBYTNCX3',
|
||||
})
|
||||
_db.session.commit()
|
||||
assert _db.session.get(BackupRevision, bare['backuprevisionid']).payload is None
|
||||
|
||||
described = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '1' * 64, 'sharepath': r'\\host\s\new',
|
||||
'sourcehostname': 'FBYTNCX3',
|
||||
'manifest': _markermanifest(['A.PAT']),
|
||||
})
|
||||
_db.session.commit()
|
||||
|
||||
# Still a no-op: nothing changed on the controller, so no new history.
|
||||
assert described['action'] == 'noop'
|
||||
assert described['backuprevisionid'] == bare['backuprevisionid']
|
||||
|
||||
revision = _db.session.get(BackupRevision, bare['backuprevisionid'])
|
||||
assert [f['name'] for f in revision.payload['files']] == ['A.PAT']
|
||||
# And the pointer follows a snapshot that moved on the share.
|
||||
assert revision.sharepath == r'\\host\s\new'
|
||||
|
||||
|
||||
def test_a_noop_does_not_overwrite_a_manifest_that_is_already_there(bk_app, bk_plugin):
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
first = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '2' * 64, 'sharepath': r'\\host\s\1',
|
||||
'sourcehostname': 'FBYTNCX3',
|
||||
'manifest': _markermanifest(['ORIGINAL.PAT']),
|
||||
})
|
||||
_db.session.commit()
|
||||
bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '2' * 64, 'sharepath': r'\\host\s\1',
|
||||
'sourcehostname': 'FBYTNCX3',
|
||||
'manifest': _markermanifest(['REPLACEMENT.PAT']),
|
||||
})
|
||||
_db.session.commit()
|
||||
revision = _db.session.get(BackupRevision, first['backuprevisionid'])
|
||||
assert [f['name'] for f in revision.payload['files']] == ['ORIGINAL.PAT']
|
||||
|
||||
|
||||
def test_a_file_edited_in_place_is_named_as_changed(bk_app, bk_plugin):
|
||||
"""Same file set, different bytes.
|
||||
|
||||
A revision exists only because the hash moved, so reporting "no files added
|
||||
or removed" here would contradict the reason the row is there at all.
|
||||
"""
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
before = _markermanifest(['A.PAT', 'B.PAT'])
|
||||
after = _markermanifest(['A.PAT', 'B.PAT'])
|
||||
after['files'][1]['sha256'] = 'cd' * 32
|
||||
|
||||
bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '3' * 64, 'sharepath': r'\\host\s\1',
|
||||
'sourcehostname': 'FBYTNCX3', 'manifest': before})
|
||||
_db.session.commit()
|
||||
second = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '4' * 64, 'sharepath': r'\\host\s\2',
|
||||
'sourcehostname': 'FBYTNCX3', 'manifest': after})
|
||||
_db.session.commit()
|
||||
|
||||
revision = _db.session.get(BackupRevision, second['backuprevisionid'])
|
||||
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
||||
revisionid=revision.backuprevisionid)
|
||||
fields = {f['label']: f['value'] for f in card['fields']}
|
||||
assert fields['Changed'] == 'B.PAT'
|
||||
assert 'Added' not in fields and 'Removed' not in fields
|
||||
|
||||
|
||||
def test_two_pcs_reporting_one_marker_do_not_cross_diff(bk_app, bk_plugin):
|
||||
"""Dedup chains per source host, and the diff must follow the same chain.
|
||||
|
||||
Diffing across chains would report every file as added and removed at once
|
||||
the moment a second PC reported the same marker.
|
||||
"""
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '5' * 64, 'sharepath': r'\\host\s\a',
|
||||
'sourcehostname': 'PC-A',
|
||||
'manifest': _markermanifest(['ONLY-ON-A.PAT'])})
|
||||
_db.session.commit()
|
||||
onb = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '6' * 64, 'sharepath': r'\\host\s\b',
|
||||
'sourcehostname': 'PC-B',
|
||||
'manifest': _markermanifest(['ONLY-ON-B.PAT'])})
|
||||
_db.session.commit()
|
||||
|
||||
revision = _db.session.get(BackupRevision, onb['backuprevisionid'])
|
||||
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
||||
revisionid=revision.backuprevisionid)
|
||||
fields = {f['label']: f['value'] for f in card['fields']}
|
||||
# PC-B's first snapshot has no predecessor OF ITS OWN, so no diff at all
|
||||
# rather than a diff against PC-A's unrelated history.
|
||||
assert 'Added' not in fields and 'Removed' not in fields
|
||||
|
||||
|
||||
def test_a_predecessor_without_a_manifest_yields_no_diff(bk_app, bk_plugin):
|
||||
"""Mixed rollout: the previous snapshot predates manifests."""
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '7' * 64, 'sharepath': r'\\host\s\1',
|
||||
'sourcehostname': 'FBYTNCX3'})
|
||||
_db.session.commit()
|
||||
second = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '8' * 64, 'sharepath': r'\\host\s\2',
|
||||
'sourcehostname': 'FBYTNCX3',
|
||||
'manifest': _markermanifest(['A.PAT'])})
|
||||
_db.session.commit()
|
||||
|
||||
revision = _db.session.get(BackupRevision, second['backuprevisionid'])
|
||||
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
||||
revisionid=revision.backuprevisionid)
|
||||
labels = [f['label'] for f in card['fields']]
|
||||
assert 'Added' not in labels and 'Removed' not in labels
|
||||
assert 'Patterns' in labels # the card itself still renders
|
||||
|
||||
|
||||
def test_a_wiped_controller_still_renders_and_reports_the_loss(bk_app, bk_plugin):
|
||||
"""files: [] is a fact, not an absence.
|
||||
|
||||
A controller wiped or swapped is the disaster the Removed line exists for,
|
||||
so it must not render as no panel at all.
|
||||
"""
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
with bk_app.app_context():
|
||||
bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': '9' * 64, 'sharepath': r'\\host\s\1',
|
||||
'sourcehostname': 'FBYTNCX3',
|
||||
'manifest': _markermanifest(['GONE.PAT'])})
|
||||
_db.session.commit()
|
||||
empty = _markermanifest([])
|
||||
wiped = bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': 'a1' * 32, 'sharepath': r'\\host\s\2',
|
||||
'sourcehostname': 'FBYTNCX3', 'manifest': empty})
|
||||
_db.session.commit()
|
||||
|
||||
revision = _db.session.get(BackupRevision, wiped['backuprevisionid'])
|
||||
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
||||
revisionid=revision.backuprevisionid)
|
||||
fields = {f['label']: f['value'] for f in card['fields']}
|
||||
assert card['sectioncount'] == 1
|
||||
assert fields['Removed'] == 'GONE.PAT'
|
||||
|
||||
|
||||
def test_a_malformed_manifest_is_refused_before_it_can_break_a_panel(bk_app, bk_plugin):
|
||||
"""A client bug must not become a page that fails until content changes."""
|
||||
bad = [
|
||||
{'files': ['A.PAT']}, # not objects
|
||||
{'files': [{'bytes': 10}]}, # nameless
|
||||
{'files': [{'name': 'A.PAT', 'bytes': '1,024'}]}, # size not an int
|
||||
{'files': 'A.PAT'}, # not a list
|
||||
]
|
||||
with bk_app.app_context():
|
||||
for manifest in bad:
|
||||
with pytest.raises(ValueError):
|
||||
bk_plugin.apply_collector_payload({
|
||||
'machinenumber': '3204', 'backupkind': 'partmarker',
|
||||
'contenthash': 'b2' * 32, 'sharepath': r'\\host\s\x',
|
||||
'manifest': manifest})
|
||||
|
||||
|
||||
def test_buildinfo_kwargs_do_not_disturb_the_live_ntlars_card(bk_app, bk_plugin):
|
||||
"""The endpoint passes revisionid and sourcehostname to EVERY kind.
|
||||
|
||||
NTLARS is live in production; a new keyword reaching its buildinfo must be
|
||||
absorbed by **options rather than raising.
|
||||
"""
|
||||
with bk_app.app_context():
|
||||
result = bk_plugin.apply_collector_payload(_payload())
|
||||
_db.session.commit()
|
||||
from plugins.backups.models import BackupRevision
|
||||
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
||||
# The assertion is that this does not raise TypeError on the new
|
||||
# keywords, not that this particular fixture produces sections.
|
||||
card = registry.getkind('ntlars').buildinfo(
|
||||
revision.payload, revision.assetid,
|
||||
partmarkertypes=None, revisionid=revision.backuprevisionid,
|
||||
sourcehostname=revision.sourcehostname)
|
||||
assert 'sectioncount' in card
|
||||
|
||||
Reference in New Issue
Block a user