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.
|
# dying. Null = never reported.
|
||||||
diskhealthjson = db.Column(db.Text(length=16777215), nullable=True)
|
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
|
# Warn-only reconciler output, refreshed on every agent check-in in the same
|
||||||
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
|
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
|
||||||
# the endpoint is expected to be running but the latest snapshot does not
|
# the endpoint is expected to be running but the latest snapshot does not
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ KNOWN_PAYLOAD_KEYS = frozenset({
|
|||||||
'scheduledtasks', 'applications', 'events',
|
'scheduledtasks', 'applications', 'events',
|
||||||
'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
|
'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
|
||||||
'pendingreboot', 'usercontext', 'diskhealth',
|
'pendingreboot', 'usercontext', 'diskhealth',
|
||||||
|
'rebootreasons', 'bootinfo', 'defender',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -790,6 +791,28 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'precomputed predictfailure; null there '
|
'precomputed predictfailure; null there '
|
||||||
'means the drive did not answer.',
|
'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': {
|
'usercontext': {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'description': 'Session-scoped data collected by a probe '
|
'description': 'Session-scoped data collected by a probe '
|
||||||
@@ -1035,6 +1058,12 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
state.pendingreboot = payload.get('pendingreboot')
|
state.pendingreboot = payload.get('pendingreboot')
|
||||||
if 'diskhealth' in payload:
|
if 'diskhealth' in payload:
|
||||||
state.diskhealthjson = _as_json(payload.get('diskhealth'))
|
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
|
# 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
|
# probe runs and omits the key entirely when nobody is signed in, so the
|
||||||
|
|||||||
Reference in New Issue
Block a user