Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows

This commit is contained in:
2026-09-02 16:15:53 -04:00
parent 2144305433
commit bb733f1332
84 changed files with 7107 additions and 186 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,244 @@
"""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 "Shopfloor App Monitors"
the site-level wrapper, carrying the Configuration Policy as its one item.
It was named for the originating plant when this arrived. A shipped row is
product-owned and every adopting site sees it, so the name cannot be one
site's designator (ADR-015). Migration 0010 renames it on installs that
already stamped this one.
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 policy tables belong to the COMPUTERS plugin and are
created by its own post-cutover chain, not by the core baseline; endpoints
declares a hard dependency on computers, which is what orders the two.
It guards on those tables being present and no-ops if they are not, and it is
idempotent on the policy names so re-running never duplicates the bundle. Be
clear about what the guard costs, though: Alembic stamps the revision either
way, so if it ever no-ops the bundle is simply never seeded and nothing comes
back to finish the job. That is acceptable only because the dependency makes
the absent case unreachable in a supported install.
"""
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 = '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

@@ -0,0 +1,60 @@
"""Rename the shipped business policy off one site's designator.
0007 seeded it as "RPCA Shopfloor App Monitors". RPCA is the originating
plant's facility designator, and the row is product-owned: source='shipped',
seeded into every install, and the CRUD API refuses to rename it. So every
adopting site would carry another plant's label at the top of its policy list
with no way to change it, which is exactly what ADR-015 exists to prevent.
0007 now seeds the neutral name, which covers a fresh install. This migration
covers the installs that already stamped 0007 with the old one - the
originating site, and anyone who upgraded before this landed.
Renames only when the old name is present AND the new one is not, so it is
idempotent and cannot collide with a site that already has a policy of its own
under the new name. Downgrade is deliberately one-way: putting another site's
designator back into a shipped row is not a state worth being able to reach.
Revision ID: endpoints0010renamepolicy
Revises: endpoints0009opreports
"""
from alembic import op
import sqlalchemy as sa
revision = 'endpoints0010renamepolicy'
down_revision = 'endpoints0009opreports'
branch_labels = None
depends_on = None
OLD_NAME = 'RPCA Shopfloor App Monitors'
NEW_NAME = 'Shopfloor App Monitors'
def _has_table(bind, name):
return sa.inspect(bind).has_table(name)
def upgrade():
bind = op.get_bind()
# The policy tables belong to the computers plugin. Same guard as 0007: if
# they are not there, there is nothing seeded to rename.
if not _has_table(bind, 'businesspolicies'):
return
exists = bind.execute(
sa.text('SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
{'name': NEW_NAME}).scalar()
if exists:
return
bind.execute(
sa.text('UPDATE businesspolicies SET name = :new WHERE name = :old'),
{'new': NEW_NAME, 'old': OLD_NAME})
def downgrade():
# One-way on purpose. See the module docstring.
pass