Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows
This commit is contained in:
235
plugins/endpoints/api/monitors.py
Normal file
235
plugins/endpoints/api/monitors.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""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, Setting,
|
||||
)
|
||||
|
||||
from ..models import EndpointMonitor
|
||||
from .routes import endpoints_bp
|
||||
|
||||
|
||||
# Which PC type the shipped monitors attach to, as a SETTING rather than a
|
||||
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
|
||||
# type is called "Shop Floor Workstation" got no shipped monitors at all when
|
||||
# this was hardcoded, and a plant that happens to use the name for something
|
||||
# else got all of them. The default matches the seeded type so nothing changes
|
||||
# for a site that uses it; a blank setting attaches the shipped bundle to
|
||||
# nothing, which is the right neutral behaviour for a site that does not want
|
||||
# it rather than a silent mismatch.
|
||||
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
|
||||
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
|
||||
|
||||
|
||||
def shipped_pctype():
|
||||
"""The PC type the shipped monitor bundle attaches to at this site."""
|
||||
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
|
||||
return (configured or '').strip()
|
||||
|
||||
_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)
|
||||
|
||||
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)
|
||||
|
||||
# A shipped row is product-owned IDENTITY, not a product-owned opinion about
|
||||
# one site's disk layout. It used to refuse every edit, which meant a plant
|
||||
# whose eDNC lives on D:, or that runs no UDC at all, could neither correct
|
||||
# the launch path nor switch the monitor off - and then got permanent false
|
||||
# drift warnings for it. ADR-015 allows seed data precisely because a site
|
||||
# can edit it afterwards.
|
||||
#
|
||||
# So: kind and displayname are the identity 0007's policy bundle matches on
|
||||
# and stay fixed on a shipped row; the paths and the active flag, which are
|
||||
# what actually differ between plants, are editable.
|
||||
if monitor.source != 'local':
|
||||
for field in ('kind', 'displayname'):
|
||||
if field in body and (body.get(field) or '').strip() != (
|
||||
getattr(monitor, field) or ''):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'A shipped monitor keeps its kind and display name; its '
|
||||
'paths and active flag can be changed', http_code=403)
|
||||
else:
|
||||
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 attach to the PC type named by the shipped-pctype setting;
|
||||
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 = {}
|
||||
|
||||
shipped_for = shipped_pctype()
|
||||
if shipped_for and pctype == shipped_for:
|
||||
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],
|
||||
})
|
||||
Reference in New Issue
Block a user