Files
shopdb-flask/docs/BACKUP-KINDS.md
cproudlock 90c3c3102b
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
Docs: guides in the indexes, the stalest-kind rule, and the changelog
Guides was shipped without an entry in PLUGINS.md or START-HERE, so a site
adopting the product had no way to find out it exists.

BACKUP-KINDS.md described a host's badge as coming from the most recently
confirmed kind. It has not worked that way since the fleet report was reworked:
the freshest revision per (host, kind) is taken, and then the STALEST of those
kinds decides, because reporting the freshest would let a healthy NTLARS backup
mask a part-marker capture that stopped a month ago. Stalest among kinds that
can be JUDGED - a kind never confirmed is unmeasurable, not stale, and treating
it as the worst value made a host with a working backup show no badge at all.

The backups row also still advertised UDC and CMM kinds that do not exist.

api-inventory, openapi.json and PROJECT-MAP regenerated.
2026-08-26 15:06:20 -04:00

7.7 KiB

Fleet config backups: adding a kind, and how health is judged

This is about the backups plugin - the per-PC CONFIG backups the collector takes off shopfloor machines (NTLARS settings, part-marker configs). It is not about backing up the ShopDB database itself; that is docs/BACKUP-RESTORE.md.

Two things this covers:

  1. Adding a new backup kind
  2. How the Backup badge decides good vs stale

Adding a new backup kind

A kind is one class in plugins/backups/services/registry.py, added to REGISTRY at the bottom of that file:

REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())}

Subclass BackupKind and override what applies. NtlarsKind is the fullest example (parseable, renderable, has an info panel); PartMarkerKind is the lean one.

Member What it is
key Wire value. This is what lands in backuprevisions.backupkind and what the fleet table shows on the badge. Keep it short and lowercase.
displayname Human label for the UI.
storagebackend 'shopdb' (bytes live in the DB, deduped on a semantic hash) or 'share' (file lives on the SMB share, deduped on a raw byte hash).
assettypes Which core asset types this kind can attach to; ['*'] for any.
emptytext Text when an asset has no revisions of this kind. None HIDES the panel, which is the right default - a kind applies to an asset TYPE, but whether a given machine ever has that backup is per-machine. A part-marker panel on all 144 machines is noise.
parse(raw) Opaque kinds return None; parseable kinds return a projection dict.
formats() / render(...) Download formats, for shopdb kinds.
resolveassetid(payload) Map a collector payload to the asset the backup belongs to.
infopanel() / buildinfo(...) Optional at-a-glance card (ADR-010). Declared by the KIND, not hardcoded in the plugin, so a successor technology ships its own card by adding a class.
sharedir(...) Conventional UNC directory for a share kind. Advisory - the authoritative path is whatever the collector reported, because the PC is what actually wrote the file.

What you do NOT have to touch

The GE-Enforce Enforcement Reports Backup column needs no change. It reads backuprevisions generically: newest revision per host, whatever the kind, and shows backupkind on the badge. A new kind inherits the badge, the colour and the tooltip with no work.

Same for the staleness rule below - it is time-based and kind-agnostic.


How the Backup badge decides good vs stale

The trap this is built around

A revision is only written when the config CHANGES. Dedup compares against the latest revision for the chain (asset, kind, sourcehostname), so a machine whose config has been stable for six months has a six-month-old newest revision and is perfectly healthy. The question worth answering is not "when was the last backup taken" - it is "is this still being checked".

So the timestamp the fleet table uses is backuprevisions.lastseenat: the last time the collector CONFIRMED this config, whether or not anything changed. It moves on every successful collection; the revision does not.

That was shown as a raw date at first, and it read as neglect. At the default backups_intervalhours of 24, the collector only attempts once a day, so a day-old confirmation IS the healthy steady state. The date made a working system look like a stalled one, and made the reader do arithmetic against a setting they would have to go and look up.

What it does now

GET /api/geenforce/reports returns, per host:

Field Meaning
backupkind The kind the verdict is about - the STALEST measurable one, not the freshest. null = no backup at all.
backuplastseen When it was last CONFIRMED still current (ISO). Tooltip only.
backupcollectedat When the config was last CAPTURED (ISO). Only moves on a real change. Tooltip only.
backupok true good, false stale, null nothing to judge.
backupstaleafterdays The threshold in force, so the UI can explain itself.

Both dates are reported because they answer different questions and one cannot stand for both. The hover reads "ntlars: checked 13 Aug 1:20 PM. Verified the backup taken 12 Aug 5:20 PM is still current." - the first date proves the check is running, the second says what is actually stored. Collapsing them was what made a healthy machine look neglected.

A host with several kinds is judged by its WORST one

A marker bay runs both an NTLARS backup and a part-marker backup. Reporting the freshest of them would let a healthy kind mask a dead one: the part-marker capture could stop for a month and the host would stay green on the strength of its NTLARS row. So the row is reduced twice - the freshest revision per (host, kind), and then the stalest of those kinds decides the verdict.

Stalest among kinds that can be judged. A kind whose newest revision has never been confirmed - lastseenat NULL, which means a backfilled row or one written before the column existed - is not stale, it is UNMEASURABLE, and it must not win the comparison. Treating NULL as the worst value looked right and was wrong: a host with a healthy NTLARS backup plus one never-confirmed row showed NO BADGE at all, because the verdict for an unconfirmed kind is None. A working backup rendering as nothing is the one outcome the badge must never produce. When EVERY kind is unmeasurable the host still reports which kind it has, with a null verdict and no badge.

This is the same rule staleness.py applies per chain: a chain becomes measurable the first time its PC posts, and says nothing until then.

Known consequence: a device retired from a PC leaves its last revision behind, so that host stays stale until the revision is pruned. That is the honest failure direction - it complains about a backup that genuinely is not running any more, rather than going quiet about one.

backupok is deliberately tri-state. null means there is no revision for that host, or the check is disabled - and it renders as no badge, never green. "Never seen" must not read as healthy.

The threshold is the backups plugin's own backups_staledays setting (default 3), read through plugins.backups.services.staleness.staledays() rather than re-derived, so there is ONE definition of stale. 0 disables the check. The import is guarded, so a lean site build without the backups plugin returns null instead of failing.

Why time-based rather than per-kind

Every kind answers the same question the same way: something confirmed this recently, or it did not. Making the rule per-kind would mean each new kind has to define health before it can show a badge, for no gain. If a kind ever needs its own window - a weekly backup that should not be judged on a 3-day rule - add a threshold override on BackupKind and have _backup_stale_cutoff prefer it; the tri-state contract stays as it is.

Where the code lives

  • plugins/geenforce/api/routes.py - _attach_backup_state (newest revision per host), _backup_stale_cutoff (threshold), _backup_ok (verdict)
  • plugins/geenforce/frontend/views/EnforcementReports.vue - backupClass (green / red / none), backupTitle (the hover text)
  • plugins/backups/services/staleness.py - the shared threshold, also behind the dashboard's stale-backups card

Tests: tests/test_plugins/test_geenforce_reporting.py, the backup-verdict block - recent-is-good, older-than-threshold-is-stale, and none-is-not-green.


See also

  • docs/BACKUP-RESTORE.md - backing up the ShopDB database itself
  • docs/geenforce-api-cutover.md - the fleet reporting path these fields ride on