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:
151
plugins/endpoints/migrations/versions/0006_endpoint_monitors.py
Normal file
151
plugins/endpoints/migrations/versions/0006_endpoint_monitors.py
Normal 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')
|
||||
@@ -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})
|
||||
51
plugins/endpoints/migrations/versions/0008_endpoint_drift.py
Normal file
51
plugins/endpoints/migrations/versions/0008_endpoint_drift.py
Normal 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')
|
||||
Reference in New Issue
Block a user