"""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