Merge the wilm delivery work into main
Ben Baker's installed-software collection, software delivery (run_script and
update_app), agentless adoption tool and command-queue hardening, with his
authorship preserved.
Reviewed before promotion. Fixed: the endpoints plugin tests were never collected
by pytest, the SBOM snapshot table's key would exhaust on MySQL, ingest did not
truncate two columns strict MySQL would reject, and update_app's version branch
pinned a digest for a file that route never serves. Agent packaging moved off
require_role('admin') onto a new endpoints.agentpackage permission.
Contract 0.27.0. Full suite 2231, frontend 314.
This commit is contained in:
@@ -124,11 +124,24 @@ class EndpointCommand(db.Model):
|
||||
|
||||
# Lifecycle: pending -> running -> succeeded|failed. A command is claimed
|
||||
# (pending -> running) by exactly one agent poll, then finalized by its
|
||||
# result post.
|
||||
# result post. A command that is claimed but whose agent never reports back
|
||||
# (agent killed/uninstalled/offline mid-flight) would otherwise pin the
|
||||
# at-most-one guard forever; the enqueue path reaps such rows to STATUS_EXPIRED
|
||||
# so a dead in-flight command cannot wedge the endpoint's command queue.
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_RUNNING = 'running'
|
||||
STATUS_SUCCEEDED = 'succeeded'
|
||||
STATUS_FAILED = 'failed'
|
||||
STATUS_EXPIRED = 'expired'
|
||||
|
||||
# In-flight staleness ceilings. A RUNNING command is expected to finish in
|
||||
# seconds to a couple of minutes; the generous ceiling tolerates a slow
|
||||
# app install while still freeing the queue when the agent is gone. A
|
||||
# PENDING command is claimed within one poll (~25s) by a live agent, so a
|
||||
# long-unclaimed row means the agent is offline and the queue should not
|
||||
# stay wedged behind it.
|
||||
RUNNING_STALE_SECONDS = 15 * 60
|
||||
PENDING_STALE_SECONDS = 60 * 60
|
||||
|
||||
endpointcommandid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
@@ -246,3 +259,89 @@ class EndpointEvent(db.Model):
|
||||
'eventmessage': self.eventmessage,
|
||||
'recordedat': _utciso(self.recordedat),
|
||||
}
|
||||
|
||||
|
||||
class EndpointApplication(db.Model):
|
||||
"""One installed-software (SBOM) entry reported for an endpoint.
|
||||
|
||||
The agent enumerates both Windows Uninstall registry hives and emits a
|
||||
per-item {displayname, displayversion, publisher}, de-duped on
|
||||
name+version. The server stores that as a per-asset SNAPSHOT: on each
|
||||
report the existing rows for the asset are replaced with the reported set,
|
||||
mirroring how the services/ports inventories are kept as the newest list
|
||||
rather than an append-only history. History of installs is not what
|
||||
operators ask for; the current inventory is.
|
||||
|
||||
appid is groundwork for a future link to the core applications catalog
|
||||
(the "Managed Applications" concept): a nullable, unenforced-by-match FK
|
||||
that a later matcher can populate to associate a discovered install with a
|
||||
managed Application row. No matching logic exists yet - the column and the
|
||||
relationship are laid down here so the association can be added without a
|
||||
second migration.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointapplications'
|
||||
|
||||
# BigInteger on MySQL, Integer on SQLite. This table is rewritten wholesale
|
||||
# on every check-in, so the key never stops climbing and a 32-bit column
|
||||
# eventually stops accepting inserts - see the migration for the arithmetic.
|
||||
#
|
||||
# The variant is not decoration: SQLite only autoincrements a column typed
|
||||
# exactly INTEGER PRIMARY KEY, so a plain BigInteger makes every insert fail
|
||||
# with a NOT NULL violation on the test database. Nothing caught that,
|
||||
# because no test inserts into this table.
|
||||
endpointapplicationid = db.Column(
|
||||
db.BigInteger().with_variant(db.Integer, 'sqlite'), primary_key=True)
|
||||
|
||||
# The asset this install belongs to. Not unique: many applications per
|
||||
# endpoint. CASCADE so removing the asset clears its SBOM snapshot.
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
asset = db.relationship('Asset', lazy='joined', viewonly=True)
|
||||
|
||||
# Wire fields, stored under the same lowercase/no-underscore names the agent
|
||||
# emits. displayname is always present (the agent skips entries without it);
|
||||
# displayversion and publisher default to '' on the agent side.
|
||||
displayname = db.Column(db.String(255), nullable=False, index=True)
|
||||
displayversion = db.Column(db.String(128), nullable=True)
|
||||
publisher = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Groundwork link to the core applications catalog ("Managed Applications").
|
||||
# Nullable and unpopulated for now: no discovery-to-catalog matcher is wired
|
||||
# yet. SET NULL so retiring a managed Application does not delete discovered
|
||||
# SBOM rows - the install is still on the machine, it is just no longer
|
||||
# managed.
|
||||
appid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('applications.appid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
application = db.relationship('Application', lazy='select', viewonly=True)
|
||||
|
||||
# When this snapshot row was written (the report that produced it).
|
||||
recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointapplications_assetname', 'assetid', 'displayname'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EndpointApplication {self.displayname!r} asset={self.assetid}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'endpointapplicationid': self.endpointapplicationid,
|
||||
'assetid': self.assetid,
|
||||
'displayname': self.displayname,
|
||||
'displayversion': self.displayversion,
|
||||
'publisher': self.publisher,
|
||||
'appid': self.appid,
|
||||
'recordedat': _utciso(self.recordedat),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user