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
205 lines
8.3 KiB
Python
205 lines
8.3 KiB
Python
"""Endpoint monitor definitions API (the monitor catalog CRUD + resolver).
|
|
|
|
A monitor definition is the reusable "what" an operator builds on the Settings
|
|
page: a named process or service with a bespoke display label and optional
|
|
launch/collector paths. Attaching a monitor to a PC type is a site-policy
|
|
concern (see plugins/computers policies); this surface owns the definitions
|
|
themselves plus the resolve-by-pctype read the agent uses at runtime.
|
|
|
|
Shipped rows (source='shipped') are product-owned: readable by everyone with
|
|
the view permission but not editable or deletable through this API.
|
|
|
|
The resolve-by-pctype read returns the monitor set a deployed agent should watch
|
|
for its PC type: the shipped defaults for 'Shopfloor PC' unioned with whatever
|
|
active site policies attach to that type. Shipped monitors are hardcoded to
|
|
'Shopfloor PC' as a proof of concept until they are authored through the policy
|
|
route like any user-built monitor.
|
|
"""
|
|
|
|
from flask import request
|
|
from flask_jwt_extended import current_user
|
|
|
|
from shopdb.api import (
|
|
db, success_response, error_response, ErrorCodes, require_permission,
|
|
require_role,
|
|
)
|
|
|
|
from ..models import EndpointMonitor
|
|
from .routes import endpoints_bp
|
|
|
|
|
|
# Shipped monitors default to this PC type until they are authored as policies.
|
|
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
|
|
|
|
_VALID_KINDS = ('process', 'service')
|
|
|
|
|
|
def _validate_monitor_payload(body, kind):
|
|
"""Return an error message for an invalid create/update body, or None."""
|
|
displayname = (body.get('displayname') or '').strip()
|
|
if not displayname:
|
|
return 'displayname is required'
|
|
if kind not in _VALID_KINDS:
|
|
return "kind must be 'process' or 'service'"
|
|
if kind == 'process' and not (body.get('processname') or '').strip():
|
|
return 'processname is required for a process monitor'
|
|
if kind == 'service' and not (body.get('servicename') or '').strip():
|
|
return 'servicename is required for a service monitor'
|
|
return None
|
|
|
|
|
|
@endpoints_bp.route('/monitors', methods=['GET'])
|
|
@require_permission('endpoints.view')
|
|
def list_monitors():
|
|
"""List monitor definitions (shipped and local)."""
|
|
monitors = EndpointMonitor.query.order_by(
|
|
EndpointMonitor.source.desc(),
|
|
EndpointMonitor.displayname.asc()).all()
|
|
return success_response([monitor.to_dict() for monitor in monitors])
|
|
|
|
|
|
@endpoints_bp.route('/monitors', methods=['POST'])
|
|
@require_role('admin')
|
|
def create_monitor():
|
|
"""Create a user-defined (local) monitor definition."""
|
|
body = request.get_json(silent=True) or {}
|
|
kind = (body.get('kind') or '').strip().lower()
|
|
problem = _validate_monitor_payload(body, kind)
|
|
if problem:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
|
|
|
monitor = EndpointMonitor(
|
|
kind=kind,
|
|
displayname=(body.get('displayname') or '').strip(),
|
|
processname=(body.get('processname') or '').strip() or None,
|
|
servicename=(body.get('servicename') or '').strip() or None,
|
|
launchpath=(body.get('launchpath') or '').strip() or None,
|
|
datacollectorpath=(body.get('datacollectorpath') or '').strip() or None,
|
|
logcollectorpath=(body.get('logcollectorpath') or '').strip() or None,
|
|
source='local',
|
|
isactive=bool(body.get('isactive', True)),
|
|
)
|
|
db.session.add(monitor)
|
|
db.session.commit()
|
|
return success_response(monitor.to_dict(), message='Monitor created',
|
|
http_code=201)
|
|
|
|
|
|
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['PUT'])
|
|
@require_role('admin')
|
|
def update_monitor(monitor_id):
|
|
"""Update a local monitor definition. Shipped rows are read-only."""
|
|
monitor = db.session.get(EndpointMonitor, monitor_id)
|
|
if monitor is None:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
|
|
http_code=404)
|
|
if monitor.source != 'local':
|
|
return error_response(ErrorCodes.FORBIDDEN,
|
|
'Shipped monitors cannot be edited', http_code=403)
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
kind = (body.get('kind') or monitor.kind or '').strip().lower()
|
|
problem = _validate_monitor_payload(
|
|
{
|
|
'displayname': body.get('displayname', monitor.displayname),
|
|
'processname': body.get('processname', monitor.processname),
|
|
'servicename': body.get('servicename', monitor.servicename),
|
|
},
|
|
kind,
|
|
)
|
|
if problem:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
|
|
|
monitor.kind = kind
|
|
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
|
|
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
|
|
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
|
|
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
|
|
monitor.datacollectorpath = (body.get('datacollectorpath', monitor.datacollectorpath) or '').strip() or None
|
|
monitor.logcollectorpath = (body.get('logcollectorpath', monitor.logcollectorpath) or '').strip() or None
|
|
if 'isactive' in body:
|
|
monitor.isactive = bool(body.get('isactive'))
|
|
db.session.commit()
|
|
return success_response(monitor.to_dict(), message='Monitor updated')
|
|
|
|
|
|
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['DELETE'])
|
|
@require_role('admin')
|
|
def delete_monitor(monitor_id):
|
|
"""Delete a local monitor definition. Shipped rows are protected."""
|
|
monitor = db.session.get(EndpointMonitor, monitor_id)
|
|
if monitor is None:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
|
|
http_code=404)
|
|
if monitor.source != 'local':
|
|
return error_response(ErrorCodes.FORBIDDEN,
|
|
'Shipped monitors cannot be deleted', http_code=403)
|
|
db.session.delete(monitor)
|
|
db.session.commit()
|
|
return success_response(None, message='Monitor deleted')
|
|
|
|
|
|
def _resolve_pctype_monitors(pctype):
|
|
"""Monitor definitions a PC type should watch: shipped defaults + policy.
|
|
|
|
Shipped monitors are attached to SHIPPED_DEFAULT_PCTYPE as a code rule;
|
|
policy-attached monitors come from the computers policy resolver by PC type.
|
|
Returns a list of EndpointMonitor rows (active only), de-duped by id.
|
|
"""
|
|
selected = {}
|
|
|
|
if pctype == SHIPPED_DEFAULT_PCTYPE:
|
|
for monitor in EndpointMonitor.query.filter_by(
|
|
source='shipped', isactive=True).all():
|
|
selected[monitor.endpointmonitorid] = monitor
|
|
|
|
computertypeid = _computertypeid_for(pctype)
|
|
if computertypeid is not None:
|
|
policy_ids = _policy_monitor_ids(computertypeid)
|
|
if policy_ids:
|
|
for monitor in EndpointMonitor.query.filter(
|
|
EndpointMonitor.endpointmonitorid.in_(policy_ids),
|
|
EndpointMonitor.isactive.is_(True)).all():
|
|
selected[monitor.endpointmonitorid] = monitor
|
|
|
|
return list(selected.values())
|
|
|
|
|
|
def _computertypeid_for(pctype):
|
|
"""Resolve a PC type name to its computertypeid, or None."""
|
|
try:
|
|
from plugins.computers.models import ComputerType
|
|
except Exception:
|
|
return None
|
|
row = ComputerType.query.filter_by(computertype=pctype).first()
|
|
return row.computertypeid if row else None
|
|
|
|
|
|
def _policy_monitor_ids(computertypeid):
|
|
"""Monitor ids attached to a PC type by effective policy, or empty set."""
|
|
try:
|
|
from plugins.computers.services.policyresolver import monitor_ids_for_pctype
|
|
except Exception:
|
|
return set()
|
|
return monitor_ids_for_pctype(computertypeid)
|
|
|
|
|
|
@endpoints_bp.route('/monitors/resolve', methods=['GET'])
|
|
@require_permission('endpoints.view')
|
|
def resolve_monitors():
|
|
"""Resolve the monitor set for a PC type (agent runtime fetch).
|
|
|
|
Query param pctype names the computer type. Returns the definitions the
|
|
agent should watch: shipped defaults for 'Shopfloor PC' plus any monitors
|
|
active site policies attach to that type.
|
|
"""
|
|
pctype = (request.args.get('pctype') or '').strip()
|
|
if not pctype:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'pctype is required', http_code=400)
|
|
monitors = _resolve_pctype_monitors(pctype)
|
|
return success_response({
|
|
'pctype': pctype,
|
|
'monitors': [monitor.to_dict() for monitor in monitors],
|
|
})
|