Persist Phase 2 system telemetry
Adds rebootreasonsjson, bootinfojson and defenderjson with their migration in the same change, so the model never references a column the schema has not applied. rebootreasons is not redundant with the existing pendingreboot column. That column is a single bool OR-ed from three registry signals and cannot say which fired, and the difference matters: a staged file rename means the box is mid-servicing, while a lone servicing key can sit set for weeks on a machine nobody rebooted. Null means never reported and is deliberately distinct from an empty list, which means checked and nothing pending. Backfilling [] would assert every pre-existing endpoint was confirmed clean.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"""Add rebootreasonsjson, bootinfojson and defenderjson to endpointstates.
|
||||
|
||||
Phase 2 system-side telemetry. All three are collect-and-persist; none is
|
||||
surfaced in the UI by this change.
|
||||
|
||||
rebootreasonsjson is NOT redundant with the existing pendingreboot column. That
|
||||
column is a single boolean OR-ed from three registry signals, so it cannot say
|
||||
which one fired, and the difference is operationally significant: a
|
||||
PendingFileRenameOperations entry means files are staged for replacement on the
|
||||
next boot, so the machine is mid-servicing, whereas a lone Component Based
|
||||
Servicing or Windows Update key can sit set for weeks on a box nobody has
|
||||
rebooted. Same boolean, very different urgency.
|
||||
|
||||
bootinfojson exists because uptime lies under fast startup - a machine reports
|
||||
days of uptime after what the operator experienced as a shutdown, and a "reboot"
|
||||
that was really a hybrid resume does not clear the driver or kernel state a
|
||||
support call is usually trying to clear.
|
||||
|
||||
defenderjson is stored deliberately unsurfaced. Defender is known to produce
|
||||
false positives in this environment, so wiring it to a card now would train
|
||||
operators to ignore that card; persisting it starts the history accumulating for
|
||||
whenever someone is ready to interpret it.
|
||||
|
||||
JSON rather than scalars: reboot reasons is a variable-length list, and the boot
|
||||
and Defender payloads are small related groups that are read together and would
|
||||
otherwise be six sparse columns.
|
||||
|
||||
MEDIUMTEXT for consistency with the other snapshot columns on this table.
|
||||
|
||||
Nullable with no backfill. Null means never reported, which is deliberately
|
||||
distinct from an empty list (checked, nothing pending) - defaulting the reboot
|
||||
reasons to [] would assert every pre-existing endpoint was confirmed clean.
|
||||
|
||||
Idempotent: each column-add checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0015systemtelemetry'
|
||||
down_revision = 'endpoints0014diskhealth'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
|
||||
_COLUMNS = (
|
||||
'rebootreasonsjson',
|
||||
'bootinfojson',
|
||||
'defenderjson',
|
||||
)
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
for column_name in _COLUMNS:
|
||||
if not _has_column(TABLE_NAME, column_name):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
for column_name in reversed(_COLUMNS):
|
||||
if _has_column(TABLE_NAME, column_name):
|
||||
op.drop_column(TABLE_NAME, column_name)
|
||||
@@ -108,6 +108,27 @@ class EndpointState(db.Model):
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -54,6 +54,7 @@ KNOWN_PAYLOAD_KEYS = frozenset({
|
||||
'scheduledtasks', 'applications', 'events',
|
||||
'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
|
||||
'pendingreboot', 'usercontext', 'diskhealth',
|
||||
'rebootreasons', 'bootinfo', 'defender',
|
||||
})
|
||||
|
||||
|
||||
@@ -790,6 +791,28 @@ class EndpointsPlugin(BasePlugin):
|
||||
'precomputed predictfailure; null there '
|
||||
'means the drive did not answer.',
|
||||
},
|
||||
'rebootreasons': {
|
||||
'type': 'array',
|
||||
'description': 'Which signal(s) drove pendingreboot: '
|
||||
'componentbasedservicing, windowsupdate, '
|
||||
'pendingfilerename. Empty array means '
|
||||
'checked and nothing pending; omit when '
|
||||
'the check failed.',
|
||||
},
|
||||
'bootinfo': {
|
||||
'type': 'object',
|
||||
'description': 'hiberbootenabled (fast startup is '
|
||||
'configured) and lastboottype (what the '
|
||||
'last boot actually did). Uptime is not '
|
||||
'trustworthy when fast startup is in '
|
||||
'play.',
|
||||
},
|
||||
'defender': {
|
||||
'type': 'object',
|
||||
'description': 'Defender service/realtime state and '
|
||||
'signature and scan ages in days. '
|
||||
'Persisted but not surfaced.',
|
||||
},
|
||||
'usercontext': {
|
||||
'type': 'object',
|
||||
'description': 'Session-scoped data collected by a probe '
|
||||
@@ -1035,6 +1058,12 @@ class EndpointsPlugin(BasePlugin):
|
||||
state.pendingreboot = payload.get('pendingreboot')
|
||||
if 'diskhealth' in payload:
|
||||
state.diskhealthjson = _as_json(payload.get('diskhealth'))
|
||||
if 'rebootreasons' in payload:
|
||||
state.rebootreasonsjson = _as_json(payload.get('rebootreasons'))
|
||||
if 'bootinfo' in payload:
|
||||
state.bootinfojson = _as_json(payload.get('bootinfo'))
|
||||
if 'defender' in payload:
|
||||
state.defenderjson = _as_json(payload.get('defender'))
|
||||
|
||||
# Session-scoped probe output. The agent repeats its last result between
|
||||
# probe runs and omits the key entirely when nobody is signed in, so the
|
||||
|
||||
Reference in New Issue
Block a user