main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
417 lines
16 KiB
Python
417 lines
16 KiB
Python
"""Evaluate one endpoint's health verdict and record it when it changes.
|
|
|
|
Scoped to ONE assetid on purpose. The old rollup aggregated 48h of events
|
|
fleet-wide on every Fleet page load; this runs when the data actually changes
|
|
(a check-in, or an explicit refresh from PC detail) and narrows to a single
|
|
asset, which the existing idx_endpointevents_assetevent (assetid, eventtime)
|
|
composite already serves. The Fleet page then reads a cached row per endpoint
|
|
and recomputes nothing.
|
|
|
|
THE ORDERING RULE, which is the whole point of this module:
|
|
|
|
1. Unresolved BLOCKER conditions (drive down, blocking service stopped)
|
|
2. DEGRADED conditions (stalled service, non-blocking stop)
|
|
3. Events, ranked by catalog severity
|
|
4. ok / unknown
|
|
|
|
A condition outranks an event count no matter how large the count. That is the
|
|
inversion of the old behaviour, where the event branch returned first and a
|
|
crash loop permanently hid a dead service.
|
|
|
|
THE NO-POLICY RULE, which is what keeps it safe:
|
|
|
|
A policy binding may only RAISE impact. It may never gate PRESENCE.
|
|
|
|
An endpoint with no policies, no PC type, or no monitors still gets a verdict.
|
|
Drives and events are not policy-gated at all, so they work with zero
|
|
configuration; services fall back to a built-in default impact. Absence of an
|
|
EndpointMonitorImpact row is the normal path, not an error path.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
|
|
from shopdb.api import db
|
|
|
|
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
|
from ..models.endpoint import EndpointEvent
|
|
from ..models.health import (
|
|
EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact,
|
|
IMPACT_BLOCKER, IMPACT_DEGRADED, IMPACT_INFORMATIONAL, IMPACT_RANK,
|
|
SOURCE_DRIVE, SOURCE_EVENT, SOURCE_NONE, SOURCE_PROCESS, SOURCE_SERVICE,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Keep the last 50 transitions per asset, but do not pay for a DELETE on every
|
|
# write. Let history grow to the high-water mark and then trim back, which
|
|
# amortises the delete to roughly once per ten transitions and leaves the write
|
|
# path a plain insert almost every time.
|
|
TRANSITION_RETENTION = 50
|
|
TRANSITION_HIGHWATER = 60
|
|
|
|
# Window for the flap counter and the event rollup. Matches the 48h the
|
|
# stability card used, so the number on screen does not change meaning.
|
|
HEALTH_WINDOW_HOURS = 48
|
|
|
|
# A drive that is unavailable stops work on the floor. This is the one signal
|
|
# that is a blocker with NO configuration at all - it never consults policy,
|
|
# so it survives an endpoint that has none. What counts as unavailable is
|
|
# _mapped_drive_rows' decision, not a second list maintained here.
|
|
|
|
# Default impact when no EndpointMonitorImpact row applies. This is the
|
|
# no-policy path, and it reproduces the old rollup's verdict rather than
|
|
# inventing a new one: a stopped watched service was a warning before and is
|
|
# 'degraded' now.
|
|
_DEFAULT_IMPACT_BY_SOURCE = {
|
|
SOURCE_DRIVE: IMPACT_BLOCKER,
|
|
SOURCE_SERVICE: IMPACT_DEGRADED,
|
|
SOURCE_PROCESS: IMPACT_DEGRADED,
|
|
SOURCE_EVENT: IMPACT_INFORMATIONAL,
|
|
}
|
|
|
|
_LEVEL_BY_IMPACT = {
|
|
IMPACT_BLOCKER: 'critical',
|
|
IMPACT_DEGRADED: 'warning',
|
|
IMPACT_INFORMATIONAL: 'warning',
|
|
}
|
|
|
|
|
|
def _naive_utcnow():
|
|
return datetime.utcnow()
|
|
|
|
|
|
def impact_for_monitor(endpointmonitorid, computertypeid):
|
|
"""Impact class for a monitor on a PC type, or None if unmapped.
|
|
|
|
Returns None rather than raising or defaulting when there is no mapping,
|
|
no PC type, or no monitor id. The caller supplies the default; this
|
|
function only reports what policy explicitly said, so "policy said
|
|
degraded" and "policy said nothing" stay distinguishable.
|
|
"""
|
|
if endpointmonitorid is None or computertypeid is None:
|
|
return None
|
|
row = (EndpointMonitorImpact.query
|
|
.filter_by(endpointmonitorid=endpointmonitorid,
|
|
computertypeid=computertypeid)
|
|
.first())
|
|
return row.impactclass if row is not None else None
|
|
|
|
|
|
def _drive_conditions(state):
|
|
"""Open drive conditions from the last user-context snapshot.
|
|
|
|
Not policy-gated: mapped drives arrive with the user-context payload and
|
|
are evaluated for every endpoint regardless of configuration. On a shop
|
|
floor a dead share is a stoppage, so this is a blocker by default and does
|
|
not wait for anyone to declare it one.
|
|
"""
|
|
from ..api.routes import _mapped_drive_rows
|
|
|
|
raw = getattr(state, 'usercontextjson', None)
|
|
if not raw:
|
|
return []
|
|
|
|
try:
|
|
context = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
# A malformed payload is not a health verdict. Say nothing rather than
|
|
# claiming the endpoint is either broken or fine.
|
|
logger.warning('malformed usercontextjson for asset %s',
|
|
getattr(state, 'assetid', None))
|
|
return []
|
|
|
|
if not isinstance(context, dict):
|
|
return []
|
|
|
|
out = []
|
|
for row in _mapped_drive_rows(context):
|
|
# Reuse the card's own verdict rather than re-deriving one from the
|
|
# raw status string. _mapped_drive_rows already resolves the two
|
|
# conflicting sources, treats unknown as unhealthy, and is the thing
|
|
# the PC detail card renders - so the Health column and the drive card
|
|
# cannot disagree about whether a drive is down.
|
|
if row['statecolor'] == 'green':
|
|
continue
|
|
out.append({
|
|
'impactclass': IMPACT_BLOCKER,
|
|
'sourcekind': SOURCE_DRIVE,
|
|
'sourceref': row['drive'],
|
|
'label': '{} {}'.format(row['drive'], row['statuslabel']),
|
|
})
|
|
return out
|
|
|
|
|
|
def _service_conditions(state, computertypeid):
|
|
"""Open service conditions from the last services snapshot.
|
|
|
|
Distinguishes STALLED from STOPPED, which the old rollup could not: it
|
|
read a running/not-running boolean, so a service sitting in stop-pending
|
|
was reported identically to one cleanly down. _service_transitional() has
|
|
existed for a while; nothing was calling it.
|
|
|
|
Policy-gated only in the sense that an endpoint with no monitors reports no
|
|
services and therefore has no service conditions - which is the existing
|
|
contract, not a regression. Where a service IS reported, an unmapped
|
|
monitor falls back to the built-in default impact.
|
|
"""
|
|
from ..api.routes import (_service_name, _service_running,
|
|
_service_transitional)
|
|
|
|
raw = getattr(state, 'servicesjson', None)
|
|
if not raw:
|
|
# Nothing watched, or nothing reported. Distinct from malformed: this
|
|
# is "no condition", and it must read as ok rather than unknown or the
|
|
# Fleet page would light up every unpolicied endpoint.
|
|
return []
|
|
|
|
try:
|
|
entries = json.loads(raw) or []
|
|
except (TypeError, ValueError):
|
|
# Malformed IS unknown - we genuinely cannot tell. Signalled to the
|
|
# caller with a sentinel so it does not get mistaken for "healthy".
|
|
return None
|
|
|
|
out = []
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if _service_running(entry):
|
|
continue
|
|
|
|
name = _service_name(entry)
|
|
monitorid = entry.get('monitorid') or entry.get('endpointmonitorid')
|
|
raw_state = entry.get('state') or entry.get('status') or ''
|
|
stalled = _service_transitional(raw_state)
|
|
|
|
mapped = impact_for_monitor(monitorid, computertypeid)
|
|
if mapped is not None:
|
|
impactclass = mapped
|
|
elif stalled:
|
|
# A stalled service MAY be a blocker but we cannot know without
|
|
# someone saying so. Degraded until policy says otherwise - it is
|
|
# visible, but it does not cry wolf.
|
|
impactclass = IMPACT_DEGRADED
|
|
else:
|
|
impactclass = _DEFAULT_IMPACT_BY_SOURCE[SOURCE_SERVICE]
|
|
|
|
kind = (entry.get('kind') or SOURCE_SERVICE).strip().lower()
|
|
sourcekind = SOURCE_PROCESS if kind == 'process' else SOURCE_SERVICE
|
|
|
|
out.append({
|
|
'impactclass': impactclass,
|
|
'sourcekind': sourcekind,
|
|
'sourceref': str(monitorid) if monitorid is not None else name,
|
|
'label': '{} {}'.format(name, 'stalled' if stalled else 'stopped'),
|
|
})
|
|
return out
|
|
|
|
|
|
def _event_condition(assetid, cutoff):
|
|
"""Worst recent event for one asset, as a pseudo-condition, or None.
|
|
|
|
Narrowed to a single assetid so the (assetid, eventtime) composite serves
|
|
it. Counts are still shown, but a count can no longer outrank a real
|
|
condition - it is ranked beneath every one of them.
|
|
"""
|
|
if not CRITICAL_EVENT_IDS:
|
|
return None
|
|
|
|
rows = (db.session.query(EndpointEvent.eventid,
|
|
db.func.count(EndpointEvent.endpointeventid))
|
|
.filter(EndpointEvent.assetid == assetid,
|
|
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS),
|
|
EndpointEvent.eventtime.isnot(None),
|
|
EndpointEvent.eventtime >= cutoff)
|
|
.group_by(EndpointEvent.eventid)
|
|
.all())
|
|
if not rows:
|
|
return None
|
|
|
|
total = sum(count for _eventid, count in rows)
|
|
dominant = max(rows, key=lambda row: row[1])[0]
|
|
title = event_title(dominant)
|
|
label = title if total == 1 else '{} x{}'.format(title, total)
|
|
|
|
severity = event_severity(dominant)
|
|
impactclass = (IMPACT_DEGRADED if severity == 'critical'
|
|
else IMPACT_INFORMATIONAL)
|
|
|
|
return {
|
|
'impactclass': impactclass,
|
|
'sourcekind': SOURCE_EVENT,
|
|
'sourceref': str(dominant),
|
|
'label': label,
|
|
}
|
|
|
|
|
|
def _verdict(state, computertypeid, assetid, now):
|
|
"""Resolve the single worst signal into a verdict dict.
|
|
|
|
Order is impact class first, source second. A flat "conditions always beat
|
|
events" rule would eventually put a stopped print spooler above filesystem
|
|
corruption; ranking by impact avoids that while still guaranteeing a
|
|
blocker outranks any event.
|
|
"""
|
|
cutoff = now - timedelta(hours=HEALTH_WINDOW_HOURS)
|
|
|
|
services = _service_conditions(state, computertypeid)
|
|
if services is None:
|
|
return {
|
|
'level': 'unknown',
|
|
'label': None,
|
|
'impactclass': None,
|
|
'sourcekind': SOURCE_NONE,
|
|
'sourceref': None,
|
|
}
|
|
|
|
candidates = list(_drive_conditions(state)) + list(services)
|
|
|
|
event = _event_condition(assetid, cutoff)
|
|
if event is not None:
|
|
candidates.append(event)
|
|
|
|
if not candidates:
|
|
if getattr(state, 'lastreportedat', None) is None:
|
|
return {
|
|
'level': 'unknown',
|
|
'label': 'no agent',
|
|
'impactclass': None,
|
|
'sourcekind': SOURCE_NONE,
|
|
'sourceref': None,
|
|
}
|
|
return {
|
|
'level': 'ok',
|
|
'label': 'ok',
|
|
'impactclass': None,
|
|
'sourcekind': SOURCE_NONE,
|
|
'sourceref': None,
|
|
}
|
|
|
|
# Conditions before events at equal impact: a live condition is actionable
|
|
# now, an event is a report of something that already finished happening.
|
|
def sort_key(candidate):
|
|
return (
|
|
IMPACT_RANK.get(candidate['impactclass'], 99),
|
|
1 if candidate['sourcekind'] == SOURCE_EVENT else 0,
|
|
)
|
|
|
|
worst = sorted(candidates, key=sort_key)[0]
|
|
return {
|
|
'level': _LEVEL_BY_IMPACT.get(worst['impactclass'], 'warning'),
|
|
'label': worst['label'],
|
|
'impactclass': worst['impactclass'],
|
|
'sourcekind': worst['sourcekind'],
|
|
'sourceref': worst['sourceref'],
|
|
}
|
|
|
|
|
|
def _same_condition(existing, verdict):
|
|
"""Is this the same ongoing condition, or a new one?
|
|
|
|
Compared on identity (what and where), NOT on the label: a crash count
|
|
ticking from x12 to x13 is the same condition and must not open a new
|
|
transition, or the history would fill with noise and the flap counter
|
|
would be meaningless.
|
|
"""
|
|
if existing is None:
|
|
return False
|
|
return (existing.level == verdict['level']
|
|
and existing.impactclass == verdict['impactclass']
|
|
and existing.sourcekind == verdict['sourcekind']
|
|
and existing.sourceref == verdict['sourceref'])
|
|
|
|
|
|
def _trim_transitions(assetid):
|
|
"""Enforce retention, keeping every unresolved row regardless of age."""
|
|
total = (db.session.query(db.func.count(
|
|
EndpointHealthTransition.endpointhealthtransitionid))
|
|
.filter(EndpointHealthTransition.assetid == assetid)
|
|
.scalar()) or 0
|
|
if total <= TRANSITION_HIGHWATER:
|
|
return
|
|
|
|
keep = [row.endpointhealthtransitionid for row in
|
|
(db.session.query(
|
|
EndpointHealthTransition.endpointhealthtransitionid)
|
|
.filter(EndpointHealthTransition.assetid == assetid)
|
|
.order_by(EndpointHealthTransition.openedat.desc())
|
|
.limit(TRANSITION_RETENTION)
|
|
.all())]
|
|
|
|
query = (EndpointHealthTransition.query
|
|
.filter(EndpointHealthTransition.assetid == assetid,
|
|
EndpointHealthTransition.resolvedat.isnot(None)))
|
|
if keep:
|
|
query = query.filter(
|
|
~EndpointHealthTransition.endpointhealthtransitionid.in_(keep))
|
|
query.delete(synchronize_session=False)
|
|
|
|
|
|
def evaluate_endpoint_health(assetid, state, computertypeid=None, now=None):
|
|
"""Recompute one endpoint's verdict and record a transition if it changed.
|
|
|
|
Does NOT commit. The caller owns the transaction so the verdict lands in
|
|
the same commit as the telemetry it was derived from - the cached row and
|
|
the data behind it can never disagree.
|
|
|
|
Fail-open is the caller's job too: a health error must never fail an
|
|
otherwise-good check-in.
|
|
"""
|
|
now = now or _naive_utcnow()
|
|
verdict = _verdict(state, computertypeid, assetid, now)
|
|
|
|
existing = (EndpointHealthState.query
|
|
.filter_by(assetid=assetid)
|
|
.first())
|
|
|
|
unchanged = _same_condition(existing, verdict)
|
|
|
|
if existing is None:
|
|
existing = EndpointHealthState(assetid=assetid)
|
|
db.session.add(existing)
|
|
|
|
if not unchanged:
|
|
# Close the outgoing condition before opening the new one, so the two
|
|
# never overlap in history.
|
|
(EndpointHealthTransition.query
|
|
.filter(EndpointHealthTransition.assetid == assetid,
|
|
EndpointHealthTransition.resolvedat.is_(None))
|
|
.update({'resolvedat': now}, synchronize_session=False))
|
|
|
|
db.session.add(EndpointHealthTransition(
|
|
assetid=assetid,
|
|
level=verdict['level'],
|
|
label=verdict['label'],
|
|
impactclass=verdict['impactclass'],
|
|
sourcekind=verdict['sourcekind'],
|
|
sourceref=verdict['sourceref'],
|
|
openedat=now,
|
|
))
|
|
|
|
existing.firstseenat = now
|
|
window = now - timedelta(hours=HEALTH_WINDOW_HOURS)
|
|
existing.flapcount = (db.session.query(db.func.count(
|
|
EndpointHealthTransition.endpointhealthtransitionid))
|
|
.filter(EndpointHealthTransition.assetid == assetid,
|
|
EndpointHealthTransition.openedat >= window)
|
|
.scalar()) or 0
|
|
|
|
_trim_transitions(assetid)
|
|
|
|
existing.level = verdict['level']
|
|
existing.label = verdict['label']
|
|
existing.impactclass = verdict['impactclass']
|
|
existing.sourcekind = verdict['sourcekind']
|
|
existing.sourceref = verdict['sourceref']
|
|
existing.lastevaluatedat = now
|
|
|
|
# Only an explicit observation confirms an endpoint. A verdict recomputed
|
|
# from a stale snapshot must NOT refresh this, or a pinned blocker on a
|
|
# silent endpoint would look freshly verified forever.
|
|
if getattr(state, 'lastreportedat', None) is not None:
|
|
existing.lastconfirmedat = state.lastreportedat
|
|
|
|
return existing
|