Files
shopdb-flask/plugins/endpoints/services/reconciler.py
cproudlock 27b2817fd7 Import the wilm endpoint-monitor and channel-distribution work
Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.

Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.

NOT READY FOR main. Open items:
  - 10 new routes are absent from docs/api-inventory.json, so
    test_api_inventory_parity fails. That gate SKIPS on the published mirror
    (the test ships, its data file is scrubbed), which is why wilm never saw it.
  - dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
  - plugins/endpoints/plugin.py carries a whitespace-only re-indent.

Scope: product
2026-09-02 10:05:44 -04:00

154 lines
6.2 KiB
Python

"""Warn-only monitor reconciler.
The reconciler answers one question on every agent check-in: for the monitors a
site policy (or the shipped bundle) attaches to this endpoint, is each one the
endpoint is EXPECTED to be running actually reported as running in the latest
snapshot? Every monitor that is expected-running but not observed-running is
DRIFT, and drift is surfaced to an operator for MANUAL intervention.
This is warn-only. The reconciler NEVER starts, stops, or enqueues anything - it
compares observed state against expected state and records the difference. Whether
to later automate remediation (a keep-alive that starts a process when it drifts)
is a separate, deliberate opt-in the product does not ship switched on.
Two seams are kept deliberately narrow so this stays easy to change:
monitor_expects_running(monitor)
The single place that decides "should this monitor be up?". Today every
attached monitor is treated as expected-running (attachment implies intent
to run). When an observe-only monitor kind arrives - one that only collects
general state and must never be flagged as drift - this is the one function
that branches on it. Nothing else in the reconciler encodes that policy.
compute_drift(asset_id, reported_services)
Pure given its inputs: it resolves the attached monitors and diffs them
against the reported running set. It reads no request state and writes no
rows, so the ingest path can call it inside its own transaction and tests
can call it directly.
"""
# The running-state vocabulary a watched process/service can report. Kept in
# lockstep with plugins/endpoints/api/routes.py::_RUNNING_STATES so a service the
# panel paints green is never flagged as drift here. Both read from the same
# agent snapshot; disagreeing would show a running app as out-of-desired-state.
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
def monitor_expects_running(monitor):
"""Whether an attached monitor is expected to be running on an endpoint.
The seam for the "should be up" decision. Today attachment implies intent to
run, so every active monitor returns True. A future observe-only monitor kind
(general state collection, never keep-alive) branches HERE and returns False
so it is never reported as drift - no other part of the reconciler needs to
change.
"""
return bool(getattr(monitor, 'isactive', True))
def _monitor_identity(monitor):
"""The (kind, osname) a monitor watches, or None when it has no identity.
Mirrors how the services panel reads a monitor's OS identity: a process
monitor is keyed by its processname, a service monitor by its servicename.
"""
kind = (getattr(monitor, 'kind', '') or '').lower() or None
if kind == 'process':
osname = getattr(monitor, 'processname', None)
elif kind == 'service':
osname = getattr(monitor, 'servicename', None)
else:
osname = None
osname = (osname or '').strip()
if not kind or not osname:
return None
return kind, osname
def _reported_running_keys(reported_services):
"""Set of (kind, lowercased-name) the snapshot reports as RUNNING.
Tolerant of the two shapes the agent snapshot uses: dict entries with
name/service and state/status (and an optional kind), or bare strings.
Only running entries land in the set; a stopped or unknown entry is absent,
which is what makes an expected-running monitor read as drift.
"""
running = set()
for entry in reported_services or ():
if isinstance(entry, dict):
name = entry.get('name') or entry.get('service') or ''
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
kind = (entry.get('kind') or '').lower() or None
else:
name, raw, kind = str(entry), '', None
name = name.strip().lower()
if not name or raw not in _RUNNING_STATES:
continue
running.add((kind, name))
return running
def _is_running(kind, osname, running_keys):
"""Whether a monitor's identity appears running in the snapshot.
A snapshot entry may omit kind (older agents, or a plain string list), so a
match on name alone under an unknown kind still counts as running - the
reconciler must not invent drift just because the agent did not label a row.
"""
needle = osname.strip().lower()
if (kind, needle) in running_keys:
return True
if (None, needle) in running_keys:
return True
return False
def compute_drift(asset_id, reported_services):
"""Compute the current drift list for an endpoint. Pure, no writes.
Resolves the monitors attached to the asset's PC type (shipped defaults plus
site policy), keeps only those that are expected-running, and returns one
entry per monitor whose identity is not reported as running. Returns an empty
list when nothing is attached or nothing has drifted, so the caller can store
it as "computed, no drift" distinct from a null "never computed".
Isolated from Flask request state: it reads the monitor catalog and the
passed-in snapshot only, so the ingest path calls it inside its own
transaction and tests call it directly.
"""
try:
from plugins.endpoints.api.routes import _resolved_monitors_for_asset
except Exception:
return []
monitors = _resolved_monitors_for_asset(asset_id) or []
if not monitors:
return []
running_keys = _reported_running_keys(reported_services)
drift = []
seen = set()
for monitor in monitors:
if not monitor_expects_running(monitor):
continue
identity = _monitor_identity(monitor)
if identity is None:
continue
kind, osname = identity
key = (kind, osname.lower())
if key in seen:
continue
seen.add(key)
if _is_running(kind, osname, running_keys):
continue
drift.append({
'monitorid': getattr(monitor, 'endpointmonitorid', None),
'kind': kind,
'name': osname,
'displayname': getattr(monitor, 'displayname', None) or osname,
'expected': 'running',
'observed': 'notrunning',
})
return drift