Add endpoint monitor catalog and warn-only drift reconciler

Operators need to see when a machine's running services or processes fall outside the desired state defined by policy. This adds a reusable monitor catalog (shipped read-only rows plus user-built local rows), computes drift server-side on agent check-in, and surfaces a warning on the endpoint panels. Remediation stays manual for now; automation can follow once demand is proven. Includes migrations 0006-0008 and the monitors API client.
This commit is contained in:
benjamin.baker
2026-09-01 15:44:03 -04:00
parent 1a1bf0640f
commit 42db980ca9
23 changed files with 2054 additions and 51 deletions

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')