Make main the lean product a site receives
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.
This commit is contained in:
@@ -16,13 +16,23 @@ Two shapes, one per concern:
|
||||
crash) so this stays a readable security/stability history
|
||||
rather than a raw event firehose.
|
||||
|
||||
EndpointLogCollection
|
||||
A plaintext log file collected from an endpoint on operator
|
||||
demand. Kept out of EndpointCommand.resultjson because it is
|
||||
bulky, needs its own per-asset retention cap, and has to be
|
||||
addressable by id for the download route.
|
||||
|
||||
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
|
||||
is the same physical thing as its Computer/Asset - no duplicate identity. The
|
||||
agent reports a hostname; the plugin resolves it to the existing Computer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
@@ -80,6 +90,65 @@ class EndpointState(db.Model):
|
||||
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Hardware and OS inventory, same newest-snapshot rule as above. These
|
||||
# arrived on the wire well before this and were logged as unknown keys, so
|
||||
# the shapes are already proven against real endpoint traffic.
|
||||
disksjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
biosjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
memoryjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
processorjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
osinfojson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Session-scoped data collected by a probe running as the signed-in user
|
||||
# (mapped drives, user-owned listeners). Null when nobody is signed in,
|
||||
# which is a normal unattended state rather than a fault.
|
||||
usercontextjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Windows domain or workgroup membership. A scalar, not JSON: it is a single
|
||||
# value the fleet view filters and groups on.
|
||||
domain = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# True when the endpoint is holding a reboot. Null = never reported, which
|
||||
# is deliberately distinct from a confident False.
|
||||
pendingreboot = db.Column(db.Boolean, nullable=True)
|
||||
|
||||
# Per-drive health from the agent's two disk sources. JSON because a machine
|
||||
# has a variable number of drives and each carries its own verdict. Kept
|
||||
# separate from disksjson: that is capacity, this is whether the hardware is
|
||||
# dying. Null = never reported.
|
||||
diskhealthjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Which signal(s) drove pendingreboot. That column is a single bool OR-ed
|
||||
# from three sources and so cannot say WHY, and the difference matters: a
|
||||
# pendingfilerename means files are staged for replacement on next boot, so
|
||||
# the box is mid-servicing, while a lone servicing key can sit set for weeks
|
||||
# on a machine nobody rebooted. Empty list = checked, nothing pending.
|
||||
# Null = never reported or the check failed.
|
||||
rebootreasonsjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Fast startup configuration and what the last boot actually did. Kept
|
||||
# because uptime is not trustworthy under fast startup: a box can report
|
||||
# days of uptime after what the operator experienced as a shutdown, and a
|
||||
# "reboot" that was a hybrid resume does not clear the state a support call
|
||||
# is usually trying to clear.
|
||||
bootinfojson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Defender health. Persisted but deliberately NOT surfaced anywhere yet:
|
||||
# this environment produces known false positives, and wiring it to a card
|
||||
# now would train operators to ignore that card. Stored so the history is
|
||||
# already accumulating when someone is ready to interpret it.
|
||||
defenderjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Pending Dell Command | Update items, produced by the shopdb-dellupdates
|
||||
# agent utility from the log the \ShopDB-DellCommandScan task writes. Only
|
||||
# the count drives the status card; the short list is stored so the panel
|
||||
# can show what is actually pending without a second round trip.
|
||||
#
|
||||
# A null column and a stored payload with status 'unknown' both mean "never
|
||||
# scanned", which stays distinct from a scan that found nothing pending. The
|
||||
# status card relies on that distinction - see _dell_updates_field.
|
||||
dellupdatesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Warn-only reconciler output, refreshed on every agent check-in in the same
|
||||
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
|
||||
# the endpoint is expected to be running but the latest snapshot does not
|
||||
@@ -116,6 +185,8 @@ class EndpointState(db.Model):
|
||||
'idleseconds': self.idleseconds,
|
||||
'cpupercent': self.cpupercent,
|
||||
'memorypercent': self.memorypercent,
|
||||
'domain': self.domain,
|
||||
'pendingreboot': self.pendingreboot,
|
||||
'mtconnectrunning': self.mtconnectrunning,
|
||||
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
|
||||
'lastreportedat': _utciso(self.lastreportedat),
|
||||
@@ -214,8 +285,16 @@ class EndpointCommand(db.Model):
|
||||
def __repr__(self):
|
||||
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
def to_dict(self, includedetail=False):
|
||||
"""Serialise a command for the operator views.
|
||||
|
||||
resultjson is opt-in. It is agent-reported structured detail, and for a
|
||||
script it carries the evidence that a green badge alone does not: the
|
||||
exit code, the context it ran as and the directory it really ran in. On
|
||||
failure the agent also puts the captured output in there, so it is not
|
||||
served to every list that only needs a status.
|
||||
"""
|
||||
data = {
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'assetid': self.assetid,
|
||||
'action': self.action,
|
||||
@@ -228,6 +307,24 @@ class EndpointCommand(db.Model):
|
||||
'claimedat': _utciso(self.claimedat),
|
||||
'completedat': _utciso(self.completedat),
|
||||
}
|
||||
if includedetail:
|
||||
data['resultdetail'] = self._resultdetail()
|
||||
return data
|
||||
|
||||
def _resultdetail(self):
|
||||
"""resultjson as a dict, or None.
|
||||
|
||||
Stored as text, so a malformed or truncated payload is possible. A
|
||||
detail panel is diagnostics: failing to parse it must not break the
|
||||
command list that surrounds it.
|
||||
"""
|
||||
if not self.resultjson:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(self.resultjson)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
class EndpointEvent(db.Model):
|
||||
@@ -255,6 +352,16 @@ class EndpointEvent(db.Model):
|
||||
eventchannel = db.Column(db.String(50), nullable=True)
|
||||
eventsource = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# The Windows event record id: monotonic and unique within its channel on a
|
||||
# given machine. This is the dedup key. The agent also uses it as its
|
||||
# harvest watermark, so in normal operation a record is sent once; the
|
||||
# unique index below is what guarantees it even when an agent is reinstalled
|
||||
# (losing its watermark) or two reports overlap. Nullable because an agent
|
||||
# older than this field still reports, and MySQL permits repeated NULLs in a
|
||||
# unique index, so those degrade to the previous append-only behavior rather
|
||||
# than failing.
|
||||
eventrecordid = db.Column(db.BigInteger, nullable=True)
|
||||
|
||||
# When the event happened on the endpoint (from the record), not when it
|
||||
# reached ShopDB.
|
||||
eventtime = db.Column(db.DateTime, nullable=True, index=True)
|
||||
@@ -269,6 +376,8 @@ class EndpointEvent(db.Model):
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
|
||||
db.Index('idx_endpointevents_assetrecord',
|
||||
'assetid', 'eventchannel', 'eventrecordid', unique=True),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -279,6 +388,7 @@ class EndpointEvent(db.Model):
|
||||
'endpointeventid': self.endpointeventid,
|
||||
'assetid': self.assetid,
|
||||
'eventid': self.eventid,
|
||||
'eventrecordid': self.eventrecordid,
|
||||
'eventchannel': self.eventchannel,
|
||||
'eventsource': self.eventsource,
|
||||
'eventtime': _utciso(self.eventtime),
|
||||
@@ -354,8 +464,15 @@ class EndpointApplication(db.Model):
|
||||
# When this snapshot row was written (the report that produced it).
|
||||
recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# SHA256 fingerprint for delta-based updates: hash of displayname, displayversion,
|
||||
# publisher. Allows efficient comparison to detect changes without comparing all
|
||||
# columns. Computed by agent and sent in payload. Nullable for backward compatibility
|
||||
# during migration - backfill script will populate existing rows.
|
||||
appfingerprint = db.Column(db.String(64), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointapplications_assetname', 'assetid', 'displayname'),
|
||||
db.Index('idx_endpointapplications_fingerprint', 'assetid', 'appfingerprint', unique=True),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -371,3 +488,216 @@ class EndpointApplication(db.Model):
|
||||
'appid': self.appid,
|
||||
'recordedat': _utciso(self.recordedat),
|
||||
}
|
||||
|
||||
|
||||
class EndpointLock(db.Model):
|
||||
"""An admin-only quarantine refusing operator commands on one endpoint.
|
||||
|
||||
Deliberately NOT the same thing as a PolicyActionHold, despite the similar
|
||||
name. The two sit on different axes and collapsing them would recreate the
|
||||
axis-mixing this refactor exists to undo:
|
||||
|
||||
PolicyActionHold policy-resolution axis. Suppresses ONE desired-state
|
||||
action so reconciliation stops fighting an admin.
|
||||
Per-action, several may stand at once, and it EXPIRES.
|
||||
EndpointLock caller-authority axis. Refuses commands from non-admins
|
||||
on this endpoint regardless of policy or permission.
|
||||
Per-endpoint, one at a time, and INDEFINITE.
|
||||
|
||||
Indefinite is the point. A lock represents a physical-world condition - a
|
||||
machine mid-repair, a validated part running, a box that must not reboot
|
||||
during a customer audit - and those do not end on a timer. A TTL here would
|
||||
quietly unlock a machine someone is still working inside, which is the exact
|
||||
accident the lock exists to prevent. Stale locks are surfaced in the UI
|
||||
instead of being auto-cleared.
|
||||
|
||||
Released rows are kept and marked rather than deleted: the record of who
|
||||
quarantined a machine, why, and who lifted it is the audit trail, and it is
|
||||
most wanted precisely when the lock is no longer there to explain itself.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointlocks'
|
||||
|
||||
endpointlockid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Not unique at the schema level: released rows accumulate per asset and a
|
||||
# unique constraint would make the second lock in an endpoint's life fail.
|
||||
# The one-active-lock invariant is enforced on write in PLACE_ENDPOINT_LOCK.
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
reason = db.Column(db.Text, nullable=False)
|
||||
|
||||
# Who set it. The id goes null if the account is deleted, but the username
|
||||
# snapshot survives: a quarantine that forgets who imposed it is unusable at
|
||||
# review time, which is the only time anyone reads it.
|
||||
lockedbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
lockedbyusername = db.Column(db.String(100), nullable=True)
|
||||
lockedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
# Null while the lock stands. Set on release, which is what makes this row
|
||||
# inactive - there is no isactive flag to drift out of step with them.
|
||||
releasedat = db.Column(db.DateTime, nullable=True)
|
||||
releasedbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
releasedbyusername = db.Column(db.String(100), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Serves the hot path: "is there an active lock on this asset", asked on
|
||||
# every enqueue and every detail page load.
|
||||
db.Index('idx_endpointlocks_assetactive', 'assetid', 'releasedat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
state = 'released' if self.releasedat else 'active'
|
||||
return f"<EndpointLock asset={self.assetid} {state}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointlockid': self.endpointlockid,
|
||||
'assetid': self.assetid,
|
||||
'reason': self.reason,
|
||||
'lockedbyuserid': self.lockedbyuserid,
|
||||
'lockedbyusername': self.lockedbyusername,
|
||||
'lockedat': _utciso(self.lockedat),
|
||||
'releasedat': _utciso(self.releasedat),
|
||||
'releasedbyusername': self.releasedbyusername,
|
||||
}
|
||||
|
||||
|
||||
class EndpointLogCollection(db.Model):
|
||||
"""One plaintext log file collected from an endpoint by an operator.
|
||||
|
||||
Collected content lives here rather than in EndpointCommand.resultjson for
|
||||
three reasons. It is bulky and would be dragged along by every command
|
||||
history list that only wants a status; it needs its own retention rule
|
||||
(RETENTION_PER_ASSET), which a command row cannot express without deleting
|
||||
the audit record of the command itself; and the download route needs to
|
||||
serve bytes, which means it needs a row it can address by id rather than a
|
||||
JSON field it has to re-parse.
|
||||
|
||||
Retention is deliberately PER ASSET. A global cap would let one busy machine
|
||||
evict another machine's history, and the history is read per machine on the
|
||||
PC detail page, so a global cap would make the panel unreliable exactly
|
||||
where it is used. Pruning happens on insert in the result route.
|
||||
|
||||
The content stored here is a TAIL, not necessarily the whole file - see
|
||||
istruncated. A log that has run for months is not something to load into a
|
||||
browser pane, and the agent bounds what it sends.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointlogcollections'
|
||||
|
||||
# How many collections to keep per asset. The operator asked for 20: enough
|
||||
# to compare a handful of runs against each other, few enough that stored
|
||||
# log text does not grow without bound across a fleet.
|
||||
RETENTION_PER_ASSET = 20
|
||||
|
||||
endpointlogcollectionid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# The command that produced this, kept for audit: it ties the collected
|
||||
# bytes back to who queued the collection and when it ran. SET NULL rather
|
||||
# than CASCADE so pruning or expiring a command never silently destroys a
|
||||
# log an operator is still looking at.
|
||||
endpointcommandid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('endpointcommands.endpointcommandid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# The path as the AGENT resolved it, which is not always the path the
|
||||
# operator typed (environment variables get expanded endpoint-side). Storing
|
||||
# the resolved one is what makes the history honest about what was read.
|
||||
sourcepath = db.Column(db.String(512), nullable=False)
|
||||
|
||||
content = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Size of the file ON DISK, not the size of content. When the tail is
|
||||
# truncated these differ, and that difference is the whole point: it tells
|
||||
# the operator they are looking at the end of something larger.
|
||||
filesizebytes = db.Column(db.BigInteger, nullable=True)
|
||||
contentbytes = db.Column(db.Integer, nullable=True)
|
||||
linecount = db.Column(db.Integer, nullable=True)
|
||||
istruncated = db.Column(db.Boolean, nullable=False,
|
||||
default=False, server_default=sa.false())
|
||||
|
||||
# Last-write time reported by the endpoint. A log that has not changed since
|
||||
# the previous collection is the fastest way to see an app is not logging.
|
||||
filemodifiedat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Username snapshot alongside the id: the id alone becomes unreadable if the
|
||||
# user is later removed, and this history is read long after collection.
|
||||
collectedbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
collectedbyusername = db.Column(db.String(100), nullable=True)
|
||||
collectedat = db.Column(db.DateTime, nullable=False,
|
||||
default=datetime.utcnow, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Serves both hot paths: listing an asset's history newest-first, and
|
||||
# finding the oldest rows to prune on insert.
|
||||
db.Index('idx_endpointlogcollections_assetcollected',
|
||||
'assetid', 'collectedat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f"<EndpointLogCollection asset={self.assetid} "
|
||||
f"{self.sourcepath}>")
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""A download filename derived from the collected path."""
|
||||
raw = (self.sourcepath or '').replace('/', '\\')
|
||||
base = raw.rsplit('\\', 1)[-1].strip() or 'collected.log'
|
||||
# Keep it to characters that survive a Content-Disposition header and a
|
||||
# Windows save dialog without quoting games.
|
||||
safe = re.sub(r'[^A-Za-z0-9._-]', '_', base)[:100]
|
||||
stamp = (self.collectedat or datetime.utcnow()).strftime('%Y%m%d-%H%M%S')
|
||||
return f'{stamp}_{safe}'
|
||||
|
||||
def to_dict(self, includecontent=False):
|
||||
"""Serialise a collection.
|
||||
|
||||
Content is opt-in for the same reason resultjson is on EndpointCommand:
|
||||
the history list renders 20 rows and none of them need the log body,
|
||||
which may be hundreds of kilobytes each.
|
||||
"""
|
||||
data = {
|
||||
'endpointlogcollectionid': self.endpointlogcollectionid,
|
||||
'assetid': self.assetid,
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'sourcepath': self.sourcepath,
|
||||
'filesizebytes': self.filesizebytes,
|
||||
'contentbytes': self.contentbytes,
|
||||
'linecount': self.linecount,
|
||||
'istruncated': bool(self.istruncated),
|
||||
'filemodifiedat': _utciso(self.filemodifiedat),
|
||||
'collectedbyuserid': self.collectedbyuserid,
|
||||
'collectedbyusername': self.collectedbyusername,
|
||||
'collectedat': _utciso(self.collectedat),
|
||||
'filename': self.filename,
|
||||
}
|
||||
if includecontent:
|
||||
data['content'] = self.content or ''
|
||||
return data
|
||||
|
||||
Reference in New Issue
Block a user