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:
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user