main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
174 lines
7.9 KiB
Python
174 lines
7.9 KiB
Python
"""Backup revision model.
|
|
|
|
One row per DISTINCT configuration snapshot of an asset. The collector runs
|
|
every GE-Enforce cycle across the whole fleet, so the write path dedupes on
|
|
contenthash: a row appears only when a setting actually changed. That is what
|
|
turns a high-frequency collector into a readable revision history.
|
|
|
|
Two storage backends, chosen by the kind (see services/registry.py):
|
|
|
|
'shopdb' Parsed structured config lives in payloadjson. The original file
|
|
is not kept because it re-renders exactly from the projection
|
|
(NTLARS .reg is the motivating case), which also lets one stored
|
|
revision render into more than one dialect on download.
|
|
|
|
'share' Opaque vendor formats that have no useful JSON representation
|
|
(part marker files and similar). Bytes stay on the SFLD share and
|
|
the row carries sharepath plus enough metadata to list, dedupe and
|
|
link to them. ShopDB never needs to parse these.
|
|
|
|
contenthash is sha256 over the canonical form of whatever is authoritative for
|
|
the backend: the canonical JSON for 'shopdb', the raw file bytes for 'share'.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
from shopdb.api import db
|
|
|
|
|
|
def _utciso(value):
|
|
"""Naive-UTC datetime -> ISO string marked as UTC, or None.
|
|
|
|
Every datetime this plugin stores is naive UTC. Serialising it without the
|
|
Z leaves the receiver to guess, and JavaScript guesses browser-local.
|
|
"""
|
|
return value.isoformat() + 'Z' if value else None
|
|
|
|
|
|
class BackupRevision(db.Model):
|
|
"""A single point-in-time configuration snapshot of an asset."""
|
|
|
|
__tablename__ = 'backuprevisions'
|
|
|
|
backuprevisionid = db.Column(db.Integer, primary_key=True)
|
|
|
|
# The asset the config BELONGS to, which is not always the asset it was
|
|
# collected from. NTLARS settings live in the controlling PC's registry but
|
|
# describe the machine, so the collector reports a machine number and the
|
|
# kind resolves it to the machine's asset. sourcehostname records the PC it
|
|
# actually came off.
|
|
assetid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
# Read-only view of the owning asset, so a revision can name its own
|
|
# download <machinenumber>.reg. No cascade or backref: the asset side must
|
|
# not gain a dependency on this plugin (ADR-014 lean builds).
|
|
asset = db.relationship('Asset', lazy='joined', viewonly=True)
|
|
|
|
backupkind = db.Column(db.String(50), nullable=False, index=True)
|
|
storagebackend = db.Column(db.String(20), nullable=False, default='shopdb')
|
|
|
|
# sha256 of the canonical authoritative form. Dedup key together with
|
|
# (assetid, backupkind).
|
|
contenthash = db.Column(db.String(64), nullable=False, index=True)
|
|
|
|
# Populated for storagebackend='shopdb', and optionally for 'share' kinds
|
|
# that send a manifest describing what their snapshot contains (the bytes
|
|
# still live on the share; this is the index, not the content).
|
|
# MEDIUMTEXT holding serialized
|
|
# JSON rather than a native JSON column, matching guides.blocksjson.
|
|
# Text also means the bytes come back exactly as written, so the canonical
|
|
# key ordering the codec produces survives the round trip - a native JSON
|
|
# column would renormalize it and make diffs between revisions unstable.
|
|
payloadjson = db.Column(db.Text(length=16777215), nullable=True)
|
|
|
|
# Populated for storagebackend='share' only. Full UNC path.
|
|
sharepath = db.Column(db.String(500), nullable=True)
|
|
|
|
# Original filename incl. extension. Vendor tools reject a renamed file, so
|
|
# downloads hand back exactly this name.
|
|
sourcefilename = db.Column(db.String(255), nullable=True)
|
|
bytesize = db.Column(db.Integer, nullable=True)
|
|
|
|
# Which PC produced it, and when it was read off that PC (not when ShopDB
|
|
# stored it - a share-drop fallback can arrive much later).
|
|
sourcehostname = db.Column(db.String(255), nullable=True)
|
|
collectedat = db.Column(db.DateTime, nullable=True)
|
|
|
|
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
|
|
|
# When this configuration was last CONFIRMED still current, which is not the
|
|
# same as when it last changed. Dedup means an unchanged config writes no
|
|
# revision, so collectedat only ever moves on a change - a machine whose
|
|
# settings have been stable for six months has a six-month-old newest
|
|
# revision and is perfectly healthy. Without this column ShopDB cannot tell
|
|
# that machine from one whose backup stopped running six months ago, which
|
|
# is the one question a backup system has to be able to answer. Touched on
|
|
# every matching post, including the no-op that writes nothing else.
|
|
lastseenat = db.Column(db.DateTime, nullable=True)
|
|
|
|
__table_args__ = (
|
|
db.Index('ixbackuprevisionsassetkind', 'assetid', 'backupkind'),
|
|
)
|
|
|
|
@property
|
|
def payload(self):
|
|
"""Decoded payloadjson, or None when the row stores nothing.
|
|
|
|
A 'shopdb' kind always has one: the parsed projection ShopDB is
|
|
authoritative for. A 'share' kind has one only when its client sends a
|
|
manifest describing the snapshot's contents, and None otherwise - which
|
|
includes every share revision written before manifests existed.
|
|
|
|
Note the manifest is NOT covered by contenthash: the hash attests the
|
|
bytes on the share, so two posts with identical content but different
|
|
manifests dedup to one row. apply_collector_payload backfills a missing
|
|
manifest on a no-op for that reason.
|
|
"""
|
|
if not self.payloadjson:
|
|
return None
|
|
try:
|
|
return json.loads(self.payloadjson)
|
|
except (ValueError, TypeError) as exc:
|
|
# Do NOT return None here. A corrupt row would then look identical
|
|
# to an opaque share-backed revision, and the download route would
|
|
# fail somewhere further along with an unrelated error. Name the
|
|
# actual problem and the row it is in.
|
|
raise ValueError(
|
|
'backuprevision {} has unreadable payloadjson: {}'.format(
|
|
self.backuprevisionid, exc))
|
|
|
|
@payload.setter
|
|
def payload(self, value):
|
|
if value is None:
|
|
self.payloadjson = None
|
|
else:
|
|
self.payloadjson = json.dumps(value, sort_keys=True,
|
|
separators=(',', ':'))
|
|
|
|
def to_dict(self, includepayload=False):
|
|
data = {
|
|
'backuprevisionid': self.backuprevisionid,
|
|
'assetid': self.assetid,
|
|
'backupkind': self.backupkind,
|
|
'storagebackend': self.storagebackend,
|
|
'contenthash': self.contenthash,
|
|
'shorthash': (self.contenthash or '')[:12],
|
|
'sharepath': self.sharepath,
|
|
'sourcefilename': self.sourcefilename,
|
|
'bytesize': self.bytesize,
|
|
# The machine number. Carried on the revision so the UI can name a
|
|
# download <machinenumber>.reg without a second round trip.
|
|
'assetnumber': self.asset.assetnumber if self.asset else None,
|
|
'sourcehostname': self.sourcehostname,
|
|
# Both columns hold NAIVE UTC (see collectedat above), so the wire
|
|
# format says so with a trailing Z. Without it JavaScript's
|
|
# `new Date('2026-08-07T12:00:00')` parses the string as
|
|
# BROWSER-LOCAL and the timestamp silently shifts by the viewer's
|
|
# offset before any site-timezone formatting is applied.
|
|
'collectedat': _utciso(self.collectedat),
|
|
# When the config was last CONFIRMED current, versus when it last
|
|
# changed. The history view needs both or a stable machine looks
|
|
# abandoned.
|
|
'lastseenat': _utciso(self.lastseenat),
|
|
'createdat': _utciso(self.createdat),
|
|
}
|
|
if includepayload:
|
|
data['payloadjson'] = self.payload
|
|
return data
|