Merge wilm fleet alert scope fixes into wj

Brings in wilm commits:
- Make alertscope actually target fleet alerts
- Fix fleet alert server surface inherited from wj

Key changes merged:
- Added _alert_targets_endpoint() and _endpoint_scope_facts() for proper
  scope filtering (hostname, assettype, computertype, location, businessunit)
- Alertscope now properly filters which endpoints receive fleet alerts
- Added comprehensive test coverage in test_endpoints_fleet_alerts.py
- Preserved wj's operator report email enhancements (FQDN, machine relationship)

Resolved conflicts:
- plugins/endpoints/api/routes.py: merged scope filtering with operator report changes
- agent csproj: bumped to 1.14.2 combining both branches' changes
This commit is contained in:
Proudlock
2026-09-09 18:42:09 -04:00
8 changed files with 607 additions and 15 deletions

View File

@@ -32,7 +32,7 @@ from ..models import (
)
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
from shopdb.utils.mailer import send_alert
from shopdb.api import send_alert
from markupsafe import escape
endpoints_bp = Blueprint('endpoints', __name__)
@@ -3222,6 +3222,93 @@ def serve_operator_report_image(operatorreportid: int):
_DEFAULT_ALERT_TYPE_COLOR = '#17a2b8'
# Scope keys an alert may be narrowed by.
# rather than treated as a match-nothing, so a typo cannot silently mute an
# alert - see _alert_targets_endpoint.
_ALERT_SCOPE_KEYS = ('hostname', 'assettype', 'computertype', 'location',
'businessunit')
def _endpoint_scope_facts(hostname):
"""The scope-able attributes of the endpoint asking for its alerts.
Returns a dict of lowercased strings keyed by _ALERT_SCOPE_KEYS, or None if
the hostname resolves to no computer. Values that are unset on the asset are
absent from the dict, which makes them unmatchable rather than empty-string
matchable.
"""
from plugins.computers.models import Computer, ComputerType
from shopdb.api import Location, AssetType
row = (db.session.query(Computer.hostname, AssetType.assettype,
ComputerType.computertype, Location.locationname,
Asset.businessunitid)
.join(Asset, Asset.assetid == Computer.assetid)
.outerjoin(ComputerType,
ComputerType.computertypeid == Computer.computertypeid)
.outerjoin(AssetType, AssetType.assettypeid == Asset.assettypeid)
.outerjoin(Location, Location.locationid == Asset.locationid)
.filter(db.func.lower(Computer.hostname) == hostname.lower())
.first())
if row is None:
return None
hostname_value, assettype, computertype, locationname, businessunitid = row
facts = {'hostname': hostname_value, 'assettype': assettype,
'computertype': computertype, 'location': locationname}
# BusinessUnit is not on the plugin contract surface (shopdb.api), so resolve
# its name by table rather than importing the model and breaking the import
# contract the way the mailer import did.
if businessunitid is not None:
name = db.session.execute(
db.text('SELECT businessunit FROM businessunits '
'WHERE businessunitid = :bid'),
{'bid': businessunitid}).scalar()
facts['businessunit'] = name
return {k: str(v).strip().lower() for k, v in facts.items()
if v is not None and str(v).strip()}
def _alert_targets_endpoint(alertscope, facts):
"""Does an alert's scope include this endpoint?
An absent, empty or unusable scope means the whole fleet - that is the
default and the historical behaviour. A scope with keys narrows the alert:
every recognised key must match, and a key may hold a single value or a list
of values, any of which matches. Matching is case-insensitive because these
are operator-typed names, not identifiers.
Unrecognised keys are IGNORED rather than failing the match. A scope is
authored by hand and a typo like {"hostnam": "BAY-01"} would otherwise mute
the alert everywhere and look like a delivery failure. Ignoring the key
sends the alert wider than intended, which is the safe direction for
something whose purpose is to warn people.
"""
if not isinstance(alertscope, dict) or not alertscope:
return True
for key in _ALERT_SCOPE_KEYS:
if key not in alertscope:
continue
wanted = alertscope[key]
if wanted is None:
continue
if isinstance(wanted, (list, tuple, set)):
candidates = {str(v).strip().lower() for v in wanted if v is not None}
else:
candidates = {str(wanted).strip().lower()}
candidates.discard('')
if not candidates:
continue
if facts.get(key) not in candidates:
return False
return True
@endpoints_bp.route('/alerts', methods=['GET'])
def get_fleet_alerts():
"""Fleet alerts for one endpoint by hostname (collector-token auth).
@@ -3229,6 +3316,16 @@ def get_fleet_alerts():
Called by the endpoint agent to fetch active, unacknowledged fleet alerts
for its hostname. Returns alerts with notification type info (typename and
typecolor from database) formatted for display in the agent tray.
The list is AUTHORITATIVE for this host: it is every alert that should be
live on the endpoint right now, so the agent retires any pending alert it
holds that is absent here. That is how a server-side close or cancel reaches
a machine already showing the alert - an alert deactivated or expired server
side drops out of this list and the agent takes the toast down, rather than
the operator being stuck acknowledging something already withdrawn.
Acknowledgement is terminal, so an acknowledged alert is filtered out here
permanently and a later server-side close is a no-op for that asset.
"""
error = _collector_auth_or_error()
if error is not None:
@@ -3265,8 +3362,16 @@ def get_fleet_alerts():
results = query.all()
# Resolved once per poll. An unknown hostname cannot happen here - the
# asset lookup above already returned - but guard anyway so a missing
# computer row means fleet-wide rather than a crash.
facts = _endpoint_scope_facts(hostname) or {}
alerts = []
for notification, ntype in results:
if not _alert_targets_endpoint(notification.alertscope, facts):
continue
title = notification.notification[:100] if notification.notification else ''
if '\n' in title:
title = title.split('\n')[0][:100] or '(no title)'
@@ -3287,9 +3392,19 @@ def get_fleet_alerts():
def acknowledge_fleet_alert(notification_id: int):
"""Agent acknowledges a fleet alert (collector-token auth).
The agent posts the hostname plus assetid when dismissing a fleet alert from
the tray. Records acknowledgement so this alert will not reappear in future polls.
Idempotent - repeated acknowledgements succeed.
The agent posts the hostname plus assetid when the operator dismisses a
fleet alert from the tray. Records acknowledgement so this alert will not
reappear in future polls. Idempotent - repeated acknowledgements succeed.
Acknowledgement is TERMINAL for this asset. Once recorded, the alert is
filtered out of get_fleet_alerts for this machine permanently, and a later
server-side close or cancel of the same notification is a no-op here: there
is nothing left to withdraw. The agent therefore retries a queued
acknowledgement until it lands without risk of a conflicting later state.
Only a real operator action reaches this route. A toast that merely timed
out on screen is NOT an acknowledgement and the agent does not post it - the
alert stays pending so it can be surfaced again.
"""
error = _collector_auth_or_error()
if error is not None:
@@ -3327,9 +3442,10 @@ def acknowledge_fleet_alert(notification_id: int):
f'Asset {assetid} not found',
http_code=404)
# Validate hostname matches assetid to prevent cross-machine acknowledgements
resolved_asset = _resolve_asset_for_hostname(hostname)
if resolved_asset is None or resolved_asset.assetid != assetid:
# Validate hostname matches assetid to prevent cross-machine acknowledgements.
# _resolve_asset_for_hostname returns the asset id itself, not an Asset row.
resolved_assetid = _resolve_asset_for_hostname(hostname)
if resolved_assetid is None or resolved_assetid != assetid:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname does not match provided assetid')