Import the wilm endpoint-monitor and channel-distribution work

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
This commit is contained in:
cproudlock
2026-09-02 10:05:44 -04:00
parent 1480b30e8c
commit 27b2817fd7
76 changed files with 5930 additions and 173 deletions

View File

@@ -5,5 +5,7 @@ from .routes import endpoints_bp
from . import adoption # noqa: F401
# Attaches the site agent generator packaging routes to endpoints_bp.
from . import siteagent # noqa: F401
# Attaches the monitor catalog CRUD and resolve routes to endpoints_bp.
from . import monitors # noqa: F401
__all__ = ['endpoints_bp']

View File

@@ -0,0 +1,204 @@
"""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],
})

View File

@@ -25,7 +25,10 @@ from shopdb.api import (
Application, AppVersion, Script,
)
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
from ..models import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
OperatorReport,
)
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
@@ -87,7 +90,8 @@ def _format_uptime(seconds):
# Endpoint state
# =============================================================================
@endpoints_bp.route('', methods=['GET'])
@endpoints_bp.route('', methods=['GET'], strict_slashes=False)
@endpoints_bp.route('/', methods=['GET'], strict_slashes=False)
@require_permission('endpoints.fleet')
def list_endpoints():
"""List reported endpoint states.
@@ -396,6 +400,24 @@ def list_endpoint_events(asset_id: int):
# Asset panels (ADR-010) - shaped for the generic PluginAssetPanels renderer
# =============================================================================
def _drift_for_state(state):
"""Parse the cached warn-only reconciler drift list off an EndpointState.
driftjson is written on every check-in by the endpoints ingest: the monitors
site policy expects running that the latest snapshot did not report running.
Null means never computed; an empty list means computed with no drift.
Tolerant of a malformed blob so a bad cache never breaks the panel.
"""
raw = getattr(state, 'driftjson', None) if state else None
if not raw:
return []
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return []
return parsed if isinstance(parsed, list) else []
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
@require_permission('endpoints.view')
def panel_status(asset_id: int):
@@ -425,6 +447,19 @@ def panel_status(asset_id: int):
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
]
fields = [f for f in fields if f['value'] not in (None, '')]
# Warn-only reconciler summary: monitors site policy expects to be running
# that the last check-in did not report as running. Surfaced as a status
# field so an operator sees the drift on the panel; nothing is auto-started.
drift = _drift_for_state(state)
if drift:
names = ', '.join(entry.get('displayname') or entry.get('name')
for entry in drift)
fields.append(field(
'Needs attention',
'{} monitor(s) expected running but not reported: {}'.format(
len(drift), names)))
return success_response({'fields': fields})
@@ -446,28 +481,100 @@ def panel_telemetry_meta(asset_id: int):
return success_response({'lastreportedat': stamp})
def _pctype_for_asset(asset_id):
"""Resolve an asset's PC type name, or None.
Endpoints stays optional-on-computers: a missing plugin, a non-computer
asset, or an unset type all resolve to None so the caller falls back to
reported-only behaviour.
"""
try:
from plugins.computers.models import Computer, ComputerType
except Exception:
return None
row = (db.session.query(ComputerType.computertype)
.join(Computer,
Computer.computertypeid == ComputerType.computertypeid)
.filter(Computer.assetid == asset_id)
.first())
return row[0] if row else None
def _resolved_monitors_for_asset(asset_id):
"""Monitor definitions attached to this asset's PC type by policy/shipped.
Reuses the endpoints monitor resolver (shipped defaults plus policy-attached
monitors). Returns a list of EndpointMonitor rows, or an empty list when the
PC type is unknown or nothing resolves.
"""
pctype = _pctype_for_asset(asset_id)
if not pctype:
return []
try:
from .monitors import _resolve_pctype_monitors
return _resolve_pctype_monitors(pctype) or []
except Exception:
return []
def _row_action_dict(app, kind, name, running):
"""Build the inline Start/Stop action map for a services/processes row."""
targets = row_actions_for(app, kind, name)
if not targets:
return None
return {
'running': running,
'start': targets['start'],
'stop': targets['stop'],
'status': targets['status'],
'target': targets['target'],
'targetkind': targets['targetkind'],
}
@endpoints_bp.route('/asset/<int:asset_id>/services', methods=['GET'])
@require_permission('endpoints.view')
def panel_services(asset_id: int):
"""List panel: monitored services/processes with a run-state badge per row.
Reshapes the latest servicesjson snapshot into rows the list renderer maps
to name + status badge. Tolerant of a missing or unexpected snapshot shape:
an unknown state renders as an Unknown badge rather than failing.
to name + status badge, then MERGES the monitors that site policy (and the
shipped defaults) attach to this asset's PC type. A policy-attached monitor
the agent has not yet reported surfaces as an Unknown row with a Start
control, so an operator sees the intent immediately rather than waiting for
the next agent snapshot. Tolerant of a missing snapshot or plugin: an
unknown state renders as an Unknown badge rather than failing.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state or not state.servicesjson:
return success_response([])
maycommand = caller_holds('endpoints.command')
try:
services = json.loads(state.servicesjson)
except (TypeError, ValueError):
return success_response([])
services = []
if state and state.servicesjson:
try:
services = json.loads(state.servicesjson) or []
except (TypeError, ValueError):
services = []
rows = []
for entry in (services or []):
# Reported identities, keyed (kind, lowercased name), so a policy monitor
# the agent already reports is not duplicated as a synthetic row.
reported = set()
# Warn-only reconciler drift, keyed (kind, lowercased name), so a row that is
# expected running but not reported running can be flagged for the operator.
# kind may be None on an older snapshot, so match on name too.
drift_keys = set()
for entry in _drift_for_state(state):
name = (entry.get('name') or '').strip().lower()
if not name:
continue
drift_keys.add(((entry.get('kind') or '').lower() or None, name))
drift_keys.add((None, name))
def _is_drift(kind, name):
key = (name or '').strip().lower()
return (kind, key) in drift_keys or (None, key) in drift_keys
for entry in services:
if isinstance(entry, dict):
name = entry.get('name') or entry.get('service') or 'Unknown'
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
@@ -476,9 +583,10 @@ def panel_services(asset_id: int):
else:
name, raw, app, kind = str(entry), '', None, None
running = _service_running(entry)
normkind = (kind or '').lower() or None
row = {
'name': name,
'kind': (kind or '').lower() or None,
'kind': normkind,
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
# Amber for paused and the four Windows transitional states. A
# service stuck in Start Pending is failing to start on a retry
@@ -493,17 +601,42 @@ def panel_services(asset_id: int):
# emission never consulted the caller, so a read-only viewer was shown
# controls on a shop-floor PC that 403 on click - wrong signal, and it
# gets worse the moment guests can read this panel.
targets = row_actions_for(app, kind, name) if maycommand else None
if targets:
row['action'] = {
'running': running,
'start': targets['start'],
'stop': targets['stop'],
'status': targets['status'],
'target': targets['target'],
'targetkind': targets['targetkind'],
}
action = _row_action_dict(app, kind, name, running) if maycommand else None
if action:
row['action'] = action
if not running and _is_drift(normkind, name):
row['drift'] = True
rows.append(row)
reported.add((normkind, name.strip().lower()))
# Merge policy/shipped monitors this PC type should watch. A monitor already
# reported by the agent stays as its live row; one not yet seen is appended
# as an Unknown row so the operator sees the intended coverage and can Start
# it. The monitor's displayname is the operator label (e.g. 'msedge.exe'
# -> 'Edge'); its process/service name is the wire target for actions.
for monitor in _resolved_monitors_for_asset(asset_id):
kind = (monitor.kind or '').lower() or None
osname = (monitor.processname if kind == 'process'
else monitor.servicename)
osname = (osname or '').strip()
if not osname:
continue
if (kind, osname.lower()) in reported:
continue
row = {
'name': monitor.displayname or osname,
'kind': kind,
'statelabel': 'Unknown',
'statecolor': 'gray',
}
action = _row_action_dict(None, kind, osname, False) if maycommand else None
if action:
row['action'] = action
if _is_drift(kind, osname):
row['drift'] = True
rows.append(row)
reported.add((kind, osname.lower()))
return success_response(rows)
@@ -1335,6 +1468,64 @@ def enqueue_endpoint_command(asset_id: int):
message='Command queued', http_code=201)
@endpoints_bp.route('/monitors/resolved', methods=['GET'])
def resolved_monitors_for_agent():
"""Agent watch list: the monitors this endpoint should report on.
Collector-token auth (not JWT), same as the command channel. The agent
calls this each telemetry cycle with its hostname and receives the process
and service monitors that site policy (and the shipped defaults) attach to
this endpoint's PC type, so a policy-attached monitor is enumerated and
reported without editing appsettings.json on the box. The shape mirrors the
agent's MonitorConfig (processes[]/services[] of {name, app}) so the agent
can bind it directly: name is the wire identity (process image or service
key), app is the operator label. An unknown hostname or a PC type with no
monitors returns empty lists, which the agent merges as "watch nothing extra".
"""
error = _collector_auth_or_error()
if error is not None:
return error
hostname = (request.args.get('hostname') or '').strip()
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required')
asset_id = _resolve_asset_for_hostname(hostname)
if asset_id is None:
return error_response(ErrorCodes.NOT_FOUND,
f'Unknown hostname: {hostname!r}', http_code=404)
processes = []
services = []
for monitor in _resolved_monitors_for_asset(asset_id):
kind = (monitor.kind or '').lower()
if kind == 'process':
wirename = (monitor.processname or '').strip()
bucket = processes
elif kind == 'service':
wirename = (monitor.servicename or '').strip()
bucket = services
else:
continue
if not wirename:
continue
entry = {
'name': wirename,
'app': (monitor.displayname or wirename),
}
# Only process monitors carry a launch path, and only when set. The
# agent never trusts it blindly: it validates the path against an
# approved-root allowlist before it becomes launchable (fail closed).
if kind == 'process':
launchpath = (monitor.launchpath or '').strip()
if launchpath:
entry['launchpath'] = launchpath
bucket.append(entry)
return success_response({'processes': processes, 'services': services})
@endpoints_bp.route('/commands/next', methods=['GET'])
def claim_next_command():
"""Agent long-poll: claim the next pending command for a hostname.
@@ -1578,3 +1769,174 @@ def download_script_body(script_id: int):
return send_upload(script_dir, script.filename, as_attachment=True)
# Operator issue reports (tray screenshot + free-text notes)
# ---------------------------------------------------------------------------
# Retention groundwork: how many days an operator report (and its screenshot
# file) is kept before the purge sweep removes it. Read from the Setting store
# so Agent Configuration can expose a toggle later without a code change; the
# default is 7 days. A value of 0 or less means "keep forever" (purge disabled).
OPERATOR_REPORT_RETENTION_SETTING = 'endpoints.operatorreport.retentiondays'
OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
# malformed or hostile agent cannot fill the disk with one request.
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
def _operator_report_dir():
return os.path.join(current_app.instance_path, 'operatorreports')
def _operator_report_retention_days():
"""Configured retention window in whole days (default 7, <=0 disables)."""
raw = Setting.get(OPERATOR_REPORT_RETENTION_SETTING)
if raw is None:
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
try:
return int(raw)
except (TypeError, ValueError):
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
def _purge_expired_operator_reports():
"""Delete operator reports older than the retention window, files and all.
Best-effort and idempotent: called opportunistically on upload so the feed
stays bounded without a scheduler. A retention of <=0 disables the sweep.
"""
days = _operator_report_retention_days()
if days <= 0:
return
cutoff = _naive_utcnow() - timedelta(days=days)
stale = OperatorReport.query.filter(OperatorReport.createdat < cutoff).all()
if not stale:
return
report_dir = _operator_report_dir()
for report in stale:
if report.imagefilename:
path = os.path.join(report_dir, report.imagefilename)
try:
os.remove(path)
except OSError:
pass
db.session.delete(report)
db.session.commit()
@endpoints_bp.route('/operatorreports', methods=['POST'])
def upload_operator_report():
"""Agent uploads one operator issue report (collector-token auth).
The tray captures the operator's free-text note and an optional screenshot,
then hands them to the service, which posts here as multipart/form-data on
the same endpoints collector token used for telemetry. The reported hostname
resolves to an asset; an unresolved hostname is a validation error. The
screenshot bytes are written to the operator-reports uploads dir and the row
holds only metadata plus the stored filename. One retention sweep runs
opportunistically so the feed stays bounded.
"""
from werkzeug.utils import secure_filename
import uuid
error = _collector_auth_or_error()
if error is not None:
return error
hostname = (request.form.get('hostname') or '').strip()
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required')
asset_id = _resolve_asset_for_hostname(hostname)
if asset_id is None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'could not resolve hostname to a computer: {!r}'.format(hostname))
reason = (request.form.get('reason') or 'Other (free text)').strip()[:128]
details = (request.form.get('details') or '').strip() or None
imagefilename = None
imagemimetype = None
imagebytesize = None
upload = request.files.get('screenshot')
if upload is not None and upload.filename:
data = upload.read()
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES))
if data:
ext = os.path.splitext(secure_filename(upload.filename))[1].lower()
if ext != '.png':
ext = '.png'
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
imagemimetype = 'image/png'
imagebytesize = len(data)
report_dir = _operator_report_dir()
os.makedirs(report_dir, exist_ok=True)
with open(os.path.join(report_dir, imagefilename), 'wb') as handle:
handle.write(data)
report = OperatorReport(
assetid=asset_id,
reason=reason,
details=details,
imagefilename=imagefilename,
imagemimetype=imagemimetype,
imagebytesize=imagebytesize,
reportedhostname=hostname,
)
db.session.add(report)
db.session.commit()
# Keep the feed bounded without a scheduler. Best-effort: a purge failure
# must not fail the upload the operator already completed.
try:
_purge_expired_operator_reports()
except Exception:
db.session.rollback()
return success_response(report.to_dict(),
message='Operator report received', http_code=201)
@endpoints_bp.route('/panel/<int:asset_id>/operatorreports', methods=['GET'])
@require_permission('endpoints.view')
def panel_operator_reports(asset_id: int):
"""Operator Reports panel: this PC's recent operator issue reports.
Newest first. Each row carries a hasimage flag so the viewer knows whether
to request the screenshot from the serve route below. Runs a retention
sweep opportunistically so a viewed PC does not accumulate stale rows.
"""
try:
_purge_expired_operator_reports()
except Exception:
db.session.rollback()
reports = (OperatorReport.query
.filter_by(assetid=asset_id)
.order_by(OperatorReport.createdat.desc())
.all())
return success_response({'reports': [r.to_dict() for r in reports]})
@endpoints_bp.route('/operatorreports/<int:operatorreportid>/image',
methods=['GET'])
@require_permission('endpoints.view')
def serve_operator_report_image(operatorreportid: int):
"""Serve one operator report's screenshot (user JWT + endpoints.view)."""
from shopdb.api import send_upload
report = db.session.get(OperatorReport, operatorreportid)
if report is None or not report.imagefilename:
return error_response(ErrorCodes.NOT_FOUND,
'No screenshot for this report', http_code=404)
return send_upload(_operator_report_dir(), report.imagefilename)

View File

@@ -41,6 +41,14 @@ from .routes import endpoints_bp
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
STAGED_TRAY_RELPATH = os.path.join('siteagent', 'shopdb-endpoint-tray.exe')
# The standalone uninstaller lives in the repo tree (not staged per-instance),
# so it ships in every bundle. Resolve it relative to this file:
# plugins/endpoints/api/siteagent.py -> repo root is three levels up.
UNINSTALLER_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))),
'scripts', 'uninstall-shopdbagent.ps1')
# Public TLS certificate the agent must trust when this deployment terminates
# TLS itself with a self-signed or otherwise untrusted certificate. We can only
# auto-bundle when THIS app owns the cert file (the dev topology, where gunicorn
@@ -144,6 +152,12 @@ FILES
appsettings.json Non-secret runtime config (cadence, monitors,
launch paths). No credentials.
install.ps1 Provisions the credentials and installs the service.
uninstall-shopdbagent.ps1
Standalone uninstaller. Run elevated to reverse the
install: stops and deletes the service, kills agent
and tray processes, removes the tray autostart Run
key, the Start Menu shortcut, and the credential
hive, and deletes the install directory.
server.crt Server TLS certificate (present only for self-signed
or internal-CA deployments, either auto-detected or
uploaded by the operator). install.ps1 imports it
@@ -153,10 +167,16 @@ FILES
USAGE
1. Copy this whole folder to the target endpoint.
2. Right-click install.ps1 and Run with PowerShell as administrator. It
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service.
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service. It also
registers the tray to autostart for every user at logon and adds a
"ShopDB Agent" Start Menu shortcut for manual relaunch.
SECURITY
UNINSTALL
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
agent, tray autostart, Start Menu shortcut, and credentials.
SECURITY
install.ps1 carries a live collector key in its default -CollectorKey value.
Treat this package as a secret. The key is not recoverable from the ShopDB
server after it was generated; regenerate the key in the UI to revoke it.
@@ -248,7 +268,7 @@ Write-Host "ServiceName : $ServiceName"
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {{
Write-Host "[0/4] Removing existing service $ServiceName ..."
Write-Host "[0/6] Removing existing service $ServiceName ..."
if ($existing.Status -ne "Stopped") {{
try {{ Stop-Service -Name $ServiceName -Force -ErrorAction Stop }}
catch {{ Write-Warning "could not stop service: $($_.Exception.Message)" }}
@@ -264,7 +284,7 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
}}
# 1. Provision registry credentials (both 64-bit and 32-bit views).
Write-Host "[1/4] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
@@ -288,7 +308,7 @@ if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
}}
# 2. Copy agent files to the install directory.
Write-Host "[2/4] Copying agent files to $InstallDir ..."
Write-Host "[2/6] Copying agent files to $InstallDir ..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Get-ChildItem -Path $sourceDir -File | ForEach-Object {{
try {{ Unblock-File -Path $_.FullName -ErrorAction Stop }} catch {{ }}
@@ -306,17 +326,118 @@ if (-not $NoTray) {{
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {{
Copy-Item -Path $traySource -Destination $InstallDir -Force
$installedTray = Join-Path $InstallDir $TrayExeName
# Autostart the tray for every user at logon via the HKLM Run key (both
# 64-bit and 32-bit views, matching the credential hive). This is the
# reliable launch guarantee: a plain Start Menu shortcut does not
# autostart, and the SYSTEM service cannot cleanly spawn into the
# interactive session. The tray itself is single-instance, so a stray
# second launch is harmless.
Set-HklmValueBothViews -SubKey "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run" -Name "ShopDbEndpointTray" -Value ('"' + $installedTray + '"')
# Export the branded icon from the tray exe so the shortcut shows the
# ShopDB brand instead of the generic exe icon. The tray draws its icon
# at runtime (no embedded Win32 icon), so we ask it to write an .ico into
# the install dir and point the shortcut at it.
$iconPath = Join-Path $InstallDir "shopdb-agent.ico"
try {{ & $installedTray --export-icon $iconPath | Out-Null }}
catch {{ Write-Warning "could not export shortcut icon: $($_.Exception.Message)" }}
# Start Menu shortcut so operators can relaunch the tray on demand. This
# is discoverability only, not autostart. Written to the machine-wide
# (All Users) Programs folder.
try {{
$commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms)
$shortcutPath = Join-Path $commonPrograms "ShopDB Agent.lnk"
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $installedTray
$shortcut.WorkingDirectory = $InstallDir
$shortcut.Description = "ShopDB Endpoint Agent tray"
if (Test-Path $iconPath) {{ $shortcut.IconLocation = "$iconPath,0" }}
$shortcut.Save()
Write-Host " created Start Menu shortcut: $shortcutPath"
}}
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
# Launch the tray once now so it appears without waiting for the next
# logon. Single-instance guard makes a duplicate launch a no-op.
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
}}
}}
# 3. Install and start the service. The agent supports its own service install.
Write-Host "[3/4] Installing and starting the service ..."
Write-Host "[3/6] Installing and starting the service ..."
$installedAgent = Join-Path $InstallDir "shopdbagent.exe"
$binPath = '"' + $installedAgent + '"'
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
sc.exe start $ServiceName | Out-Null
Write-Host "[4/4] Done." -ForegroundColor Green
# 4. Confirm the service (and tray, when installed) actually reached a running
# state before we destroy the installer. sc.exe start returns before the service
# is up, and a failed start would otherwise leave a half-installed endpoint with
# the secret-bearing install.ps1 already deleted. Poll with a short backoff and
# retry the start a few times; only proceed to cleanup once healthy.
Write-Host "[4/6] Verifying agent runtime ..."
$serviceOk = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {{
Start-Sleep -Seconds 3
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq "Running") {{ $serviceOk = $true; break }}
Write-Host " service not running yet (attempt $attempt/5); retrying start ..."
try {{ Start-Service -Name $ServiceName -ErrorAction Stop }} catch {{ }}
}}
if (-not $serviceOk) {{
throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved."
}}
Write-Host " service is running." -ForegroundColor Green
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
$trayOk = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {{
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
Start-Sleep -Seconds 2
}}
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
}}
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
# ProgramData so the uninstaller and appsettings/cert are available later, then
# scrub install.ps1 from wherever it was run. install.ps1 carries the live
# collector key in its default parameter, so it must not linger on the endpoint.
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
foreach ($name in @("uninstall-shopdbagent.ps1", "appsettings.json", "README.txt", $CertFileName)) {{
if ([string]::IsNullOrWhiteSpace($name)) {{ continue }}
$src = Join-Path $sourceDir $name
if (Test-Path $src) {{
try {{ Copy-Item -Path $src -Destination $dataDir -Force }}
catch {{ Write-Warning "could not archive $name : $($_.Exception.Message)" }}
}}
}}
$uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1"
$installSelf = Join-Path $sourceDir "install.ps1"
if (Test-Path $installSelf) {{
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }}
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
}}
Write-Host "[6/6] Done." -ForegroundColor Green
if (Test-Path $uninstaller) {{
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
Write-Host " To remove the agent, run that script from an elevated PowerShell."
}}
else {{
Write-Warning "uninstaller was not bundled; fetch uninstall-shopdbagent.ps1 from the ShopDB repo scripts/ folder if needed."
}}
""".format(
baseurl=baseurl_literal,
collectorkey=collectorkey_literal,
@@ -421,6 +542,9 @@ def package_siteagent():
cadenceseconds, certfilename))
archive.writestr('README.txt', _README)
if os.path.isfile(UNINSTALLER_PATH):
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
agent = _staged_path(STAGED_AGENT_RELPATH)
if os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')

View File

@@ -185,6 +185,36 @@ def requires_target(action):
return action_targetkind(action) is not None
# Monitor attachment as a policy action. A monitor definition is bound to a PC
# type by carrying a ConfigurationPolicyAction whose name is 'monitor:<id>'.
# Distinct names let one policy attach several monitors and fold naturally
# (first-match-wins per name), and they are validated in the policy layer but
# never enqueued as commands because ALLOWED_ACTIONS_FOR intersects with
# COMMAND_ALLOWLIST, which carries no monitor: entries.
MONITOR_ACTION_PREFIX = 'monitor:'
def monitor_action_name(endpointmonitorid):
"""Build the policy action name that attaches a monitor definition."""
return f'{MONITOR_ACTION_PREFIX}{endpointmonitorid}'
def is_monitor_action(action):
"""True when action names a monitor attachment (monitor:<id>)."""
return isinstance(action, str) and action.startswith(MONITOR_ACTION_PREFIX)
def monitor_id_from_action(action):
"""The monitor id an action attaches, or None when it is not a monitor action."""
if not is_monitor_action(action):
return None
raw = action[len(MONITOR_ACTION_PREFIX):]
try:
return int(raw)
except (TypeError, ValueError):
return None
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
"""The action names an endpoint may run.
@@ -211,6 +241,44 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
# behavior). This is intentionally permissive; deny-by-default would be
# a separate, explicit setting.
return base
# Attaching a monitor expresses intent to watch a process/service AND to
# control it: the panel renders that row's Start/Stop/Status buttons, so the
# gate must permit the matching generic primitives or every click 409s
# "not permitted by policy". Expand each monitor:<id> to its kind's control
# actions before intersecting.
allowed = allowed | _monitor_control_actions(allowed)
# Never widen past the code-level allowlist: policy can only gate within it.
return base & allowed
def _monitor_control_actions(action_names):
"""Control primitives granted by the monitor: attachments in a policy.
A process monitor grants process_start/stop/status; a service monitor grants
service_start/stop/status. The monitor's kind is read from the catalog, so a
definition's kind is the single source of truth for what its row can do.
"""
ids = set()
for name in action_names:
if isinstance(name, str) and name.startswith(MONITOR_ACTION_PREFIX):
monitor_id = monitor_id_from_action(name)
if monitor_id is not None:
ids.add(monitor_id)
if not ids:
return set()
try:
from .models import EndpointMonitor
except Exception:
return set()
granted = set()
for monitor in EndpointMonitor.query.filter(
EndpointMonitor.endpointmonitorid.in_(ids)).all():
kind = (monitor.kind or '').lower()
if kind == 'process':
granted |= {'process_start', 'process_stop', 'process_status'}
elif kind == 'service':
granted |= {'service_start', 'service_stop', 'service_status'}
return granted

View File

@@ -18,6 +18,12 @@ export default [
component: () => import('./views/AgentKeySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
{
path: 'settings/endpoints/monitors',
name: 'endpoints-monitors-settings',
component: () => import('./views/MonitorsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
// run_script delivery path end to end while Cameron builds the real UI.
// Gated on endpoints.deploy inside the view; remove once the production

View File

@@ -307,7 +307,7 @@
</section>
<!-- ====================================================================
Agent configuration (placeholder)
Agent configuration
================================================================= -->
<section class="settings-section">
<h3>Agent configuration</h3>
@@ -319,11 +319,16 @@
stamped into the build above so a deployed agent needs no local edits.
</p>
<p class="field-hint">
<strong>Coming soon.</strong> These controls arrive with the C# agent
runtime. Until then the agent uses its built-in defaults.
Monitored processes and services are defined as reusable monitors and
attached to PC types through site policies, so a deployed agent fetches
the right set for its type.
</p>
<button class="btn btn-secondary" type="button" disabled>
Edit configuration (coming soon)
<button
class="btn btn-secondary"
type="button"
@click="editConfiguration"
>
Edit configuration
</button>
</div>
</section>
@@ -332,8 +337,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '@/api'
const router = useRouter()
const loading = ref(true)
const busy = ref(false)
const key = ref(null)
@@ -540,6 +547,10 @@ async function buildSiteAgent() {
agentbusy.value = false
}
}
function editConfiguration() {
router.push({ name: 'endpoints-monitors-settings' })
}
</script>
<style scoped>

View File

@@ -0,0 +1,232 @@
<template>
<div>
<div class="page-header">
<h2>Monitor Definitions</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Monitor</button>
</div>
<p class="setting-description">
A monitor definition is a reusable process or service the agent can watch,
with a bespoke display label separate from the raw OS name. Shipped monitors
are product-owned and read-only. Attach monitors to PC types through site
policies so a deployed agent fetches the right set for its type.
</p>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Display name</th>
<th>Kind</th>
<th>OS identity</th>
<th>Launch path</th>
<th>Active</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="m in monitors" :key="m.endpointmonitorid">
<td>{{ m.displayname }}</td>
<td>{{ m.kind }}</td>
<td class="cell-truncate" :title="osidentity(m)">{{ osidentity(m) || '-' }}</td>
<td class="cell-truncate" :title="m.launchpath">{{ m.launchpath || '-' }}</td>
<td>{{ m.isactive ? 'Yes' : 'No' }}</td>
<td>
<span class="badge" :class="m.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ m.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="m.source !== 'local'" @click="openModal(m)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="m.source !== 'local'" @click="remove(m)">Delete</button>
</td>
</tr>
<tr v-if="monitors.length === 0">
<td colspan="7" style="text-align:center; color: var(--text-light);">No monitor definitions</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Monitor' : 'Add Monitor' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Display name *</label>
<input v-model="form.displayname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Kind *</label>
<select v-model="form.kind" class="form-control">
<option value="process">process</option>
<option value="service">service</option>
</select>
</div>
<div v-if="form.kind === 'process'" class="form-group">
<label>Process name * <span class="hint">(executable the agent matches, e.g. agent.exe)</span></label>
<input v-model="form.processname" type="text" class="form-control" />
</div>
<div v-else class="form-group">
<label>Service name * <span class="hint">(Windows service name)</span></label>
<input v-model="form.servicename" type="text" class="form-control" />
</div>
<div class="form-group">
<label>Launch path <span class="hint">(optional; starts the app)</span></label>
<input v-model="form.launchpath" type="text" class="form-control" />
</div>
<div class="form-group">
<label>Data collector path <span class="hint">(optional)</span></label>
<input v-model="form.datacollectorpath" type="text" class="form-control" />
</div>
<div class="form-group">
<label>Log collector path <span class="hint">(optional)</span></label>
<input v-model="form.logcollectorpath" type="text" class="form-control" />
</div>
<div class="form-group">
<label>
<input v-model="form.isactive" type="checkbox" />
Active
</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { endpointsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const monitors = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return {
kind: 'process',
displayname: '',
processname: '',
servicename: '',
launchpath: '',
datacollectorpath: '',
logcollectorpath: '',
isactive: true
}
}
function osidentity(m) {
return m.kind === 'service' ? m.servicename : m.processname
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await endpointsApi.monitors.list()
monitors.value = response.data.data || []
} catch (err) {
console.error('Error loading monitor definitions:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
kind: item.kind || 'process',
displayname: item.displayname || '',
processname: item.processname || '',
servicename: item.servicename || '',
launchpath: item.launchpath || '',
datacollectorpath: item.datacollectorpath || '',
logcollectorpath: item.logcollectorpath || '',
isactive: item.isactive !== false
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
kind: form.value.kind,
displayname: form.value.displayname,
processname: form.value.processname,
servicename: form.value.servicename,
launchpath: form.value.launchpath,
datacollectorpath: form.value.datacollectorpath,
logcollectorpath: form.value.logcollectorpath,
isactive: form.value.isactive
}
if (editing.value) {
await endpointsApi.monitors.update(editing.value.endpointmonitorid, payload)
} else {
await endpointsApi.monitors.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save monitor')
} finally {
saving.value = false
}
}
async function remove(m) {
if (!confirm(`Delete monitor "${m.displayname}"?`)) return
try {
await endpointsApi.monitors.remove(m.endpointmonitorid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.cell-truncate {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint { color: var(--text-light); font-weight: normal; }
</style>

View File

@@ -0,0 +1,151 @@
"""Reusable endpoint monitor definitions (the monitor catalog).
Adds endpointmonitors: one row per process or service the agent can watch, with
a bespoke display label separate from the raw OS name and optional launch and
collector paths. A definition is the "what"; attaching it to a PC type is a
separate site-policy concern.
Seeds the four monitors we ship today, lifted from the agent's appsettings.json
so the catalog matches what a deployed agent already watches:
process dncMain.exe -> eDNC
process udc.exe -> Universal Data Collector
process agent.exe -> MTConnect
service MTConnect UDC Agent -> MTConnect
Shipped rows are product-owned (source='shipped'): the CRUD API refuses to edit
or delete them. Launch paths mirror the agent's launchpaths block; collector and
log paths are left null until site paths are supplied.
Idempotent: creation checks the live schema first so a db.create_all() site
(which already built the table) re-runs clean on strict MySQL 8. Seeding is a
separate check on row count, so a site that built the table without the shipped
rows still gets them.
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# revision identifiers, used by Alembic.
revision = 'endpoints0006monitors'
down_revision = 'endpoints0005cmdpin'
branch_labels = None
depends_on = None
def _has_table(table_name):
from sqlalchemy import inspect
return inspect(op.get_bind()).has_table(table_name)
def upgrade():
bind = op.get_bind()
if not _has_table('endpointmonitors'):
op.create_table(
'endpointmonitors',
sa.Column('endpointmonitorid', sa.Integer(), nullable=False),
sa.Column('kind', sa.String(length=16), nullable=False),
sa.Column('displayname', sa.String(length=128), nullable=False),
sa.Column('processname', sa.String(length=255), nullable=True),
sa.Column('servicename', sa.String(length=255), nullable=True),
sa.Column('launchpath', sa.String(length=512), nullable=True),
sa.Column('datacollectorpath', sa.String(length=512), nullable=True),
sa.Column('logcollectorpath', sa.String(length=512), nullable=True),
sa.Column('source', sa.String(length=16), nullable=False,
server_default='local'),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('endpointmonitorid'),
sa.UniqueConstraint('kind', 'displayname',
name='uq_endpointmonitors_kinddisplay'),
)
op.create_index('idx_endpointmonitors_kind', 'endpointmonitors', ['kind'])
op.create_index('idx_endpointmonitors_source', 'endpointmonitors', ['source'])
# Seed the shipped catalog independently of table creation so a
# db.create_all() site (which already built the table) still gets the rows.
existing = bind.execute(
sa.text('SELECT COUNT(*) FROM endpointmonitors')).scalar()
if existing:
return
monitors = sa.table(
'endpointmonitors',
sa.column('kind', sa.String),
sa.column('displayname', sa.String),
sa.column('processname', sa.String),
sa.column('servicename', sa.String),
sa.column('launchpath', sa.String),
sa.column('datacollectorpath', sa.String),
sa.column('logcollectorpath', sa.String),
sa.column('source', sa.String),
sa.column('isactive', sa.Boolean),
sa.column('createdat', sa.DateTime),
)
now = datetime.utcnow()
op.bulk_insert(monitors, [
{
'kind': 'process',
'displayname': 'eDNC',
'processname': 'dncMain.exe',
'servicename': None,
'launchpath': r'C:\Program Files (x86)\Dnc\bin\DncMain.exe',
'datacollectorpath': None,
'logcollectorpath': None,
'source': 'shipped',
'isactive': True,
'createdat': now,
},
{
'kind': 'process',
'displayname': 'Universal Data Collector',
'processname': 'udc.exe',
'servicename': None,
'launchpath': r'C:\Program Files\UDC\UDC.exe',
'datacollectorpath': None,
'logcollectorpath': None,
'source': 'shipped',
'isactive': True,
'createdat': now,
},
{
'kind': 'process',
'displayname': 'MTConnect',
'processname': 'agent.exe',
'servicename': None,
'launchpath': r'C:\ProgramData\udc\mtconnect_udc\agent\run.bat',
'datacollectorpath': None,
'logcollectorpath': None,
'source': 'shipped',
'isactive': True,
'createdat': now,
},
{
'kind': 'service',
'displayname': 'MTConnect',
'processname': None,
'servicename': 'MTConnect UDC Agent',
'launchpath': None,
'datacollectorpath': None,
'logcollectorpath': None,
'source': 'shipped',
'isactive': True,
'createdat': now,
},
])
def downgrade():
if not _has_table('endpointmonitors'):
return
op.drop_index('idx_endpointmonitors_source', table_name='endpointmonitors')
op.drop_index('idx_endpointmonitors_kind', table_name='endpointmonitors')
op.drop_table('endpointmonitors')

View File

@@ -0,0 +1,234 @@
"""Ship the shopfloor app monitors as a read-only Site Policy.
Wraps the four shipped endpointmonitors (eDNC, Universal Data Collector,
MTConnect process, MTConnect UDC Agent service) in a product-owned policy bundle
so the agent stops carrying them as hardcoded appsettings.json defaults and reads
its watch-list from the server instead.
Seeds two computers-plugin rows, both source='shipped' (read-only in the UI, the
CRUD API refuses to edit or delete them):
ConfigurationPolicy "Shopfloor App Monitors"
scope {"type":"true"} (every endpoint), one action per shipped monitor,
named 'monitor:<endpointmonitorid>', mode='gate', desiredstate=NULL.
Gate, not desired, on purpose. A monitor REPORTS a process/service state and
exposes Start/Stop in the Monitor UI; the state read happens on manual
refresh and on agent check-in, so attaching a monitor never generates fleet
traffic on its own and a policy edit does not push a collection. A 'desired'
running state is a separate, deliberate opt-in that keeps an app alive by
trying to START the process when a launch path is mapped - never something
the product ships switched on for every endpoint.
BusinessPolicy "RPCA Shopfloor App Monitors"
the site-level wrapper, carrying the Configuration Policy as its one item.
Monitors are looked up by identity (kind, displayname), never by a hardcoded id,
so the bundle survives id drift across installs.
Lives in the endpoints chain, after 0006, because the monitor rows it references
are seeded there. The computers policy tables are created by the core baseline
before per-plugin chains run; this migration still guards on their presence and
no-ops cleanly if they are absent, and is idempotent on the policy/business-policy
names so re-running never duplicates the bundle.
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# revision identifiers, used by Alembic.
revision = 'endpoints0007shippedpolicy'
down_revision = 'endpoints0006monitors'
branch_labels = None
depends_on = None
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
BUSINESS_POLICY_NAME = 'RPCA Shopfloor App Monitors'
# The shipped monitors this bundle attaches, by identity (kind, displayname).
_SHIPPED_MONITORS = (
('process', 'eDNC'),
('process', 'Universal Data Collector'),
('process', 'MTConnect'),
('service', 'MTConnect'),
)
def _tables(bind):
from sqlalchemy import inspect
return set(inspect(bind).get_table_names())
def upgrade():
bind = op.get_bind()
tables = _tables(bind)
# The bundle lives in the computers policy tables and references the
# endpoints monitor catalog. If either side is missing (a partial site),
# do nothing rather than fail - a later run reconciles once both exist.
required = {'endpointmonitors', 'configurationpolicies',
'configurationpolicyactions', 'businesspolicies',
'businesspolicyitems'}
if not required.issubset(tables):
return
# Idempotent: never seed the bundle twice.
existing_config = bind.execute(
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).first()
if existing_config:
return
# Resolve the shipped monitor ids by identity. Skip any that a site has
# not got; attach whatever shipped monitors are present.
monitor_ids = []
for kind, displayname in _SHIPPED_MONITORS:
row = bind.execute(
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
'WHERE kind = :kind AND displayname = :displayname'),
{'kind': kind, 'displayname': displayname}).first()
if row:
monitor_ids.append(row[0])
if not monitor_ids:
return
now = datetime.utcnow()
config_policies = sa.table(
'configurationpolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('scopejson', sa.Text),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(config_policies, [{
'name': CONFIG_POLICY_NAME,
'description': 'Product-shipped watch-list for the shopfloor apps '
'(eDNC, Universal Data Collector, MTConnect). Read-only.',
'scopejson': '{"type": "true"}',
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
config_policy_id = bind.execute(
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).scalar()
actions = sa.table(
'configurationpolicyactions',
sa.column('configurationpolicyid', sa.Integer),
sa.column('action', sa.String),
sa.column('mode', sa.String),
sa.column('desiredstate', sa.Text),
sa.column('params', sa.Text),
sa.column('intervalminutes', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(actions, [{
'configurationpolicyid': config_policy_id,
'action': f'monitor:{monitorid}',
'mode': 'gate',
'desiredstate': None,
'params': None,
'intervalminutes': None,
'sortorder': index,
'createddate': now,
'modifieddate': now,
'isactive': True,
} for index, monitorid in enumerate(monitor_ids)])
business_policies = sa.table(
'businesspolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('priority', sa.Integer),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(business_policies, [{
'name': BUSINESS_POLICY_NAME,
'description': 'Product-shipped Site Policy that applies the shopfloor '
'app monitors to every endpoint. Read-only.',
'priority': 100,
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
business_policy_id = bind.execute(
sa.text('SELECT businesspolicyid FROM businesspolicies '
'WHERE name = :name'),
{'name': BUSINESS_POLICY_NAME}).scalar()
items = sa.table(
'businesspolicyitems',
sa.column('businesspolicyid', sa.Integer),
sa.column('configurationpolicyid', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(items, [{
'businesspolicyid': business_policy_id,
'configurationpolicyid': config_policy_id,
'sortorder': 0,
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
def downgrade():
bind = op.get_bind()
tables = _tables(bind)
if 'businesspolicies' not in tables or 'configurationpolicies' not in tables:
return
# Clear children explicitly rather than lean on ON DELETE CASCADE, which a
# test DB (sqlite) does not enforce by default.
business_policy_id = bind.execute(
sa.text('SELECT businesspolicyid FROM businesspolicies '
'WHERE name = :name'),
{'name': BUSINESS_POLICY_NAME}).scalar()
config_policy_id = bind.execute(
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).scalar()
if business_policy_id is not None:
bind.execute(
sa.text('DELETE FROM businesspolicyitems '
'WHERE businesspolicyid = :id'),
{'id': business_policy_id})
bind.execute(
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
{'id': business_policy_id})
if config_policy_id is not None:
bind.execute(
sa.text('DELETE FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'),
{'id': config_policy_id})
bind.execute(
sa.text('DELETE FROM configurationpolicies '
'WHERE configurationpolicyid = :id'),
{'id': config_policy_id})

View File

@@ -0,0 +1,51 @@
"""Cache the warn-only reconciler drift on the endpoint state.
Adds endpointstates.driftjson: a per-asset JSON snapshot of the monitors a site
policy (or the shipped bundle) expects to be running on an endpoint but which the
latest agent report does not show as running. It is computed server-side on every
agent check-in and overwritten in place, the same ledger pattern the other state
snapshots (servicesjson, portsjson) already use - one current row per asset, no
history. Warn-only: nothing here is auto-remediated, it exists so an operator can
see the drift and intervene manually.
Nullable so an endpoint that has never checked in since the column landed reads
as "never computed" rather than "no drift".
Idempotent: the column-add checks the live schema first, so a db.create_all()
site (which built the column from the model already) and a re-run both stay clean
on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0008drift'
down_revision = 'endpoints0007shippedpolicy'
branch_labels = None
depends_on = None
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if _has_column('endpointstates', 'driftjson'):
return
op.add_column(
'endpointstates',
sa.Column('driftjson', sa.Text(length=16777215), nullable=True))
def downgrade():
if not _has_column('endpointstates', 'driftjson'):
return
op.drop_column('endpointstates', 'driftjson')

View File

@@ -0,0 +1,63 @@
"""Operator issue reports (tray screenshot + free-text notes).
Adds operatorreports: one row per operator-submitted issue report for a PC
asset. The screenshot bytes live on disk under the instance uploads dir; this
table holds only metadata plus the stored file name so the image can be served
back and purged on retention. Reports are transient - a retention window
(default 7 days) purges old rows and their files - so this is a rolling
operator-signal feed, not a permanent record.
Idempotent: creation checks the live schema first so a db.create_all() site
(which already built the table) re-runs clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0009opreports'
down_revision = 'endpoints0008drift'
branch_labels = None
depends_on = None
def _has_table(table_name):
from sqlalchemy import inspect
return inspect(op.get_bind()).has_table(table_name)
def upgrade():
if _has_table('operatorreports'):
return
op.create_table(
'operatorreports',
sa.Column('operatorreportid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('reason', sa.String(length=128), nullable=False,
server_default='Other (free text)'),
sa.Column('details', sa.Text(), nullable=True),
sa.Column('imagefilename', sa.String(length=255), nullable=True),
sa.Column('imagemimetype', sa.String(length=64), nullable=True),
sa.Column('imagebytesize', sa.Integer(), nullable=True),
sa.Column('reportedhostname', sa.String(length=255), nullable=True),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('operatorreportid'),
sa.ForeignKeyConstraint(
['assetid'], ['assets.assetid'],
name='fk_operatorreports_asset', ondelete='CASCADE'),
)
op.create_index('idx_operatorreports_asset', 'operatorreports', ['assetid'])
op.create_index('idx_operatorreports_createdat', 'operatorreports',
['createdat'])
def downgrade():
if not _has_table('operatorreports'):
return
op.drop_index('idx_operatorreports_createdat', table_name='operatorreports')
op.drop_index('idx_operatorreports_asset', table_name='operatorreports')
op.drop_table('operatorreports')

View File

@@ -3,7 +3,10 @@
from .endpoint import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
)
from .monitor import EndpointMonitor
from .operatorreport import OperatorReport
__all__ = [
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
'EndpointMonitor', 'OperatorReport',
]

View File

@@ -75,6 +75,13 @@ class EndpointState(db.Model):
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
# Warn-only reconciler output, refreshed on every agent check-in in the same
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
# the endpoint is expected to be running but the latest snapshot does not
# report as running, so an operator sees the drift and intervenes manually.
# This is warn-only: nothing here is auto-remediated. Null = never computed.
driftjson = db.Column(db.Text(length=16777215), nullable=True)
# Last-known MTConnect agent run state, cached so the WebUI toggle can show
# a real state between command polls. Set by the agent's mtconnect_status
# result. Null = never reported (unknown).

View File

@@ -0,0 +1,87 @@
"""Endpoint monitor definitions (the reusable monitor catalog).
A monitor definition is the "what": a named process or service the agent should
watch, with a bespoke display label separate from the raw OS name, plus optional
launch and collector paths. Definitions are the noun that a configuration policy
action points at; attaching a monitor to a PC type is a policy concern, not a
property of the definition itself.
Two sources:
shipped Internal apps we ship (eDNC, MTConnect, UDC). Seeded by migration.
Read-only in the UI: name and identity are fixed by the product.
local User-defined monitors built on the Settings page.
A definition is watched on an endpoint when a site policy in effect for that
endpoint's PC type includes a monitor action pointing at it.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
return value.isoformat() + 'Z' if value else None
class EndpointMonitor(db.Model):
"""One reusable process or service monitor definition."""
__tablename__ = 'endpointmonitors'
endpointmonitorid = db.Column(db.Integer, primary_key=True)
# 'process' or 'service'. Decides which identity column is authoritative and
# which start/stop/status actions the row exposes.
kind = db.Column(db.String(16), nullable=False)
# The bespoke label operators see (e.g. 'MTConnect'). Distinct from the raw
# OS name because agent.exe is not obviously MTConnect.
displayname = db.Column(db.String(128), nullable=False)
# OS identity. For a process monitor, processname is the executable the agent
# matches (e.g. 'agent.exe'); for a service monitor, servicename is the
# Windows service name (e.g. 'MTConnect UDC Agent'). Exactly one is the
# authoritative identity for a given kind; the other stays null.
processname = db.Column(db.String(255), nullable=True)
servicename = db.Column(db.String(255), nullable=True)
# Optional paths supplied per monitor. launchpath starts the app; the
# collector paths point the agent at where the app writes data and logs.
launchpath = db.Column(db.String(512), nullable=True)
datacollectorpath = db.Column(db.String(512), nullable=True)
logcollectorpath = db.Column(db.String(512), nullable=True)
# 'shipped' (product-owned, seeded) or 'local' (user-built). Shipped rows are
# not editable or deletable through the CRUD API.
source = db.Column(db.String(16), nullable=False, default='local')
# Soft on/off so a monitor can be retired without losing policy references.
isactive = db.Column(db.Boolean, nullable=False, default=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('kind', 'displayname', name='uq_endpointmonitors_kinddisplay'),
db.Index('idx_endpointmonitors_source', 'source'),
)
def __repr__(self):
return f"<EndpointMonitor {self.displayname!r} kind={self.kind} source={self.source}>"
def to_dict(self):
return {
'endpointmonitorid': self.endpointmonitorid,
'kind': self.kind,
'displayname': self.displayname,
'processname': self.processname,
'servicename': self.servicename,
'launchpath': self.launchpath,
'datacollectorpath': self.datacollectorpath,
'logcollectorpath': self.logcollectorpath,
'source': self.source,
'isactive': self.isactive,
'createdat': _utciso(self.createdat),
}

View File

@@ -0,0 +1,82 @@
"""Operator issue reports (screenshot + free-text notes from the tray).
An operator on a shop-floor PC opens the tray "Report an issue" dialog, writes a
free-text description, and optionally attaches a screenshot of their screen. The
agent uploads that to ShopDB, where it becomes one OperatorReport row linked to
the reporting PC's asset. The screenshot bytes live on disk under the instance
uploads dir (like employee photos); this row holds only metadata plus the
relative file path so the image can be served back and purged on retention.
Reports are transient by design: a retention window (default 7 days) purges old
rows and their image files, so this is a rolling operator-signal feed, not a
permanent record.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
return value.isoformat() + 'Z' if value else None
class OperatorReport(db.Model):
"""One operator-submitted issue report for a PC asset."""
__tablename__ = 'operatorreports'
operatorreportid = db.Column(db.Integer, primary_key=True)
# The PC this report is about. ON DELETE CASCADE: when the asset goes, its
# operator reports go with it.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
# The dropdown reason. Only 'Other (free text)' is functional today, but the
# column is kept so the richer reason routing can return without a migration.
reason = db.Column(db.String(128), nullable=False, default='Other (free text)')
# The operator's free-text description. Nullable: a screenshot alone is a
# valid report.
details = db.Column(db.Text, nullable=True)
# Screenshot metadata. imagefilename is the stored file name under the
# operator-reports uploads dir; null when the operator declined a screenshot.
imagefilename = db.Column(db.String(255), nullable=True)
imagemimetype = db.Column(db.String(64), nullable=True)
imagebytesize = db.Column(db.Integer, nullable=True)
# The hostname the agent reported at upload time, kept for traceability even
# if the asset link is later re-pointed.
reportedhostname = db.Column(db.String(255), nullable=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.Index('idx_operatorreports_asset', 'assetid'),
db.Index('idx_operatorreports_createdat', 'createdat'),
)
def __repr__(self):
return f"<OperatorReport {self.operatorreportid} asset={self.assetid}>"
def has_image(self):
return bool(self.imagefilename)
def to_dict(self):
return {
'operatorreportid': self.operatorreportid,
'assetid': self.assetid,
'reason': self.reason,
'details': self.details,
'hasimage': self.has_image(),
'imagemimetype': self.imagemimetype,
'imagebytesize': self.imagebytesize,
'reportedhostname': self.reportedhostname,
'createdat': _utciso(self.createdat),
}

View File

@@ -167,19 +167,19 @@ class EndpointsPlugin(BasePlugin):
# running a staged script or pushing an installer is a strictly
# heavier grant than a routine service/process toggle.
('endpoints.deploy',
'Run staged scripts and push application installers to endpoints', 'endpoints'),
# Packaging the agent is a heavier grant than deploying to one, and
# it is not the same job either: the generated ZIP carries a live
# collector key stamped into its install script, so whoever holds
# this can mint a distributable artefact containing the fleet-wide
# command-channel credential. It was require_role('admin'), which
# this codebase otherwise reserves for the few genuinely
# role-shaped checks - and a role cannot be granted to one person
# for one job the way a permission can.
('endpoints.agentpackage',
'Generate and download an agent or adoption-tool package '
'(contains a live collector key)', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
'Run staged scripts and push application installers to endpoints', 'endpoints'),
# Packaging the agent is a heavier grant than deploying to one, and
# it is not the same job either: the generated ZIP carries a live
# collector key stamped into its install script, so whoever holds
# this can mint a distributable artefact containing the fleet-wide
# command-channel credential. It was require_role('admin'), which
# this codebase otherwise reserves for the few genuinely
# role-shaped checks - and a role cannot be granted to one person
# for one job the way a permission can.
('endpoints.agentpackage',
'Generate and download an agent or adoption-tool package '
'(contains a live collector key)', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
# scoped to ONLY this authorizes the endpoints collector and nothing
# else, so rotating/revoking it stops endpoint-agent traffic without
# touching computers/geenforce collectors. Minted from the settings
@@ -486,6 +486,23 @@ class EndpointsPlugin(BasePlugin):
# the installed-software inventory
'group': 'software',
},
{
# Operator-submitted issue reports: a free-text note and an
# optional screenshot the operator captured from the tray. Sits
# in the Monitors tab beside the other watch-and-observe panels.
# A custom render carries the thumbnail grid and the expanding
# lightbox with left/right cycling the generic modes cannot.
'id': 'endpoints-operator-reports',
'title': 'Operator Reports',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/panel/{assetid}/operatorreports',
'render': 'operatorreports',
'imagebase': '/api/endpoints/operatorreports',
'collapsible': True,
'empty': 'No operator reports for this endpoint.',
'position': 18,
'group': 'monitors',
},
]
def get_dashboard_widgets(self) -> List[Dict]:
@@ -802,6 +819,26 @@ class EndpointsPlugin(BasePlugin):
state.lastreportedat = datetime.utcnow()
# Warn-only reconciler: on this check-in, compare the monitors site
# policy expects to be running against what the snapshot reports, and
# cache the drift on the state in THIS transaction (same ledger commit as
# servicesjson). No start/stop is issued - drift is surfaced for an
# operator to act on. Reads the freshly reported services when present,
# else the last-known snapshot, so a report that omits services still
# reconciles against what we last saw. Fail-open: a reconciler error must
# never fail an otherwise-good check-in, so drift is left unchanged.
try:
if 'services' in payload:
reported_services = payload.get('services') or []
else:
reported_services = json.loads(state.servicesjson or '[]')
from .services.reconciler import compute_drift
state.driftjson = _as_json(compute_drift(assetid, reported_services))
except Exception:
logger.exception(
'reconciler failed for asset %s; leaving drift unchanged',
assetid)
# Log any keys the agent sent that the ingest path does not consume yet.
# New agent telemetry (disks, bios, memory, ...) lands here first: it is
# not persisted until the server grows columns for it, but logging the

View File

View File

@@ -0,0 +1,153 @@
"""Warn-only monitor reconciler.
The reconciler answers one question on every agent check-in: for the monitors a
site policy (or the shipped bundle) attaches to this endpoint, is each one the
endpoint is EXPECTED to be running actually reported as running in the latest
snapshot? Every monitor that is expected-running but not observed-running is
DRIFT, and drift is surfaced to an operator for MANUAL intervention.
This is warn-only. The reconciler NEVER starts, stops, or enqueues anything - it
compares observed state against expected state and records the difference. Whether
to later automate remediation (a keep-alive that starts a process when it drifts)
is a separate, deliberate opt-in the product does not ship switched on.
Two seams are kept deliberately narrow so this stays easy to change:
monitor_expects_running(monitor)
The single place that decides "should this monitor be up?". Today every
attached monitor is treated as expected-running (attachment implies intent
to run). When an observe-only monitor kind arrives - one that only collects
general state and must never be flagged as drift - this is the one function
that branches on it. Nothing else in the reconciler encodes that policy.
compute_drift(asset_id, reported_services)
Pure given its inputs: it resolves the attached monitors and diffs them
against the reported running set. It reads no request state and writes no
rows, so the ingest path can call it inside its own transaction and tests
can call it directly.
"""
# The running-state vocabulary a watched process/service can report. Kept in
# lockstep with plugins/endpoints/api/routes.py::_RUNNING_STATES so a service the
# panel paints green is never flagged as drift here. Both read from the same
# agent snapshot; disagreeing would show a running app as out-of-desired-state.
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
def monitor_expects_running(monitor):
"""Whether an attached monitor is expected to be running on an endpoint.
The seam for the "should be up" decision. Today attachment implies intent to
run, so every active monitor returns True. A future observe-only monitor kind
(general state collection, never keep-alive) branches HERE and returns False
so it is never reported as drift - no other part of the reconciler needs to
change.
"""
return bool(getattr(monitor, 'isactive', True))
def _monitor_identity(monitor):
"""The (kind, osname) a monitor watches, or None when it has no identity.
Mirrors how the services panel reads a monitor's OS identity: a process
monitor is keyed by its processname, a service monitor by its servicename.
"""
kind = (getattr(monitor, 'kind', '') or '').lower() or None
if kind == 'process':
osname = getattr(monitor, 'processname', None)
elif kind == 'service':
osname = getattr(monitor, 'servicename', None)
else:
osname = None
osname = (osname or '').strip()
if not kind or not osname:
return None
return kind, osname
def _reported_running_keys(reported_services):
"""Set of (kind, lowercased-name) the snapshot reports as RUNNING.
Tolerant of the two shapes the agent snapshot uses: dict entries with
name/service and state/status (and an optional kind), or bare strings.
Only running entries land in the set; a stopped or unknown entry is absent,
which is what makes an expected-running monitor read as drift.
"""
running = set()
for entry in reported_services or ():
if isinstance(entry, dict):
name = entry.get('name') or entry.get('service') or ''
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
kind = (entry.get('kind') or '').lower() or None
else:
name, raw, kind = str(entry), '', None
name = name.strip().lower()
if not name or raw not in _RUNNING_STATES:
continue
running.add((kind, name))
return running
def _is_running(kind, osname, running_keys):
"""Whether a monitor's identity appears running in the snapshot.
A snapshot entry may omit kind (older agents, or a plain string list), so a
match on name alone under an unknown kind still counts as running - the
reconciler must not invent drift just because the agent did not label a row.
"""
needle = osname.strip().lower()
if (kind, needle) in running_keys:
return True
if (None, needle) in running_keys:
return True
return False
def compute_drift(asset_id, reported_services):
"""Compute the current drift list for an endpoint. Pure, no writes.
Resolves the monitors attached to the asset's PC type (shipped defaults plus
site policy), keeps only those that are expected-running, and returns one
entry per monitor whose identity is not reported as running. Returns an empty
list when nothing is attached or nothing has drifted, so the caller can store
it as "computed, no drift" distinct from a null "never computed".
Isolated from Flask request state: it reads the monitor catalog and the
passed-in snapshot only, so the ingest path calls it inside its own
transaction and tests call it directly.
"""
try:
from plugins.endpoints.api.routes import _resolved_monitors_for_asset
except Exception:
return []
monitors = _resolved_monitors_for_asset(asset_id) or []
if not monitors:
return []
running_keys = _reported_running_keys(reported_services)
drift = []
seen = set()
for monitor in monitors:
if not monitor_expects_running(monitor):
continue
identity = _monitor_identity(monitor)
if identity is None:
continue
kind, osname = identity
key = (kind, osname.lower())
if key in seen:
continue
seen.add(key)
if _is_running(kind, osname, running_keys):
continue
drift.append({
'monitorid': getattr(monitor, 'endpointmonitorid', None),
'kind': kind,
'name': osname,
'displayname': getattr(monitor, 'displayname', None) or osname,
'expected': 'running',
'observed': 'notrunning',
})
return drift