Split shipped monitor bundle into per-monitor policies

The product shipped one Configuration Policy carrying every shopfloor app
monitor, wrapped in a shipped Site Policy that applied it to every endpoint.
That bundled two decisions a site cannot separate: which monitors exist, and
which endpoints get them. A site that wanted eDNC on one PC type and MTConnect
on another had to delete the shipped policy and rebuild both halves.

Ship one Configuration Policy per monitor with no PC type attribution, and stop
shipping a Site Policy at all. Composition is a site decision; the product now
supplies parts, not an assembled opinion.

Shipped Configuration Policies become editable and deletable. Provenance stays
a source tag rather than a lock, which is safe because seeding is insert-if-
absent and no reconciling seeder exists, so a site edit is never stomped. The
stale comments claiming shipped rows were read-only and reconciled described
behavior the code never had.
This commit is contained in:
benjamin.baker
2026-09-08 15:22:10 -04:00
parent 68ddf87d3b
commit 8f115ca330
8 changed files with 591 additions and 160 deletions

View File

@@ -13,8 +13,9 @@ so the catalog matches what a deployed agent already watches:
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
Shipped rows (source='shipped') keep their kind and displayname - that pair is
the identity the monitor policies match on - but a site may edit their paths and
active flag. 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

View File

@@ -1,12 +1,22 @@
"""Ship the shopfloor app monitors as a read-only Site Policy.
"""Ship the shopfloor app monitors as a Site Policy bundle.
SUPERSEDED BY 0019. This migration is kept because installs have stamped it, but
what it seeds no longer survives an upgrade: 0019 splits this single bundle into
one Configuration Policy per monitor and REMOVES the shipped Site Policy, so
sites compose their own. Read this for history; do not extend it.
Two claims below were true when written and are now false, corrected here rather
than left to mislead: the seeded rows are NOT read-only (0019 admits 'shipped'
at the policy CRUD guards), and no seeder reconciles them - the idempotency
check at the top of upgrade() returns early if the policy exists, which is what
keeps a site's edits safe across upgrades.
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.
MTConnect process, MTConnect UDC Agent service) in a 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):
Seeds two computers-plugin rows, both source='shipped':
ConfigurationPolicy "Shopfloor App Monitors"
scope {"type":"true"} (every endpoint), one action per shipped monitor,

View File

@@ -0,0 +1,318 @@
"""Split the shipped monitor bundle into one policy per monitor.
0007 seeded a single Configuration Policy "Shopfloor App Monitors" carrying all
four shipped monitor actions, wrapped in a shipped Site (business) Policy of the
same name. That bundle was all-or-nothing: a site that runs eDNC but no
MTConnect could not detach the half it does not have, because the whole row was
product-owned and read-only.
This migration replaces it with one Configuration Policy per shipped monitor:
Monitor: eDNC (process)
Monitor: Universal Data Collector (process)
Monitor: MTConnect (process)
Monitor: MTConnect (service)
Each carries exactly one 'monitor:<endpointmonitorid>' action, mode='gate',
scope {"type":"true"} - no PC type attribution. The name carries the kind
because displayname alone is not unique: MTConnect ships as both a process and
a service, and configurationpolicies.name is UNIQUE.
source stays 'shipped'. Provenance is still true - the product did ship these -
and the UI keeps showing where they came from. What changes is that the CRUD
guards now admit 'shipped' for edit/delete, so a site can retune or drop one
without losing the record of its origin.
The shipped SITE policy is REMOVED and not replaced. Composition is a site
decision: which monitors apply to which endpoints, and at what priority, is
exactly the judgement a plant makes about its own floor. Shipping one meant
every install inherited "apply all four to every endpoint" as a starting point
that could not be edited. Sites now build their own from the per-monitor pieces.
Removing it is the behaviour change to be aware of on upgrade: monitors that
were attached through that bundle stop being attached until a site policy
composes them. It is deliberate and it is the point of the split - see the
"off by default" direction in the 2026-09-08 plan - but it is not silent, since
the per-monitor policies are listed and ready to compose.
Idempotent on the policy names, so a re-run neither duplicates the split rows
nor resurrects the bundle. Guards on the policy tables being present and no-ops
if they are not, matching 0007.
Revision ID: endpoints0019splitmonitorpolicies
Revises: endpoints0018endpointlocks
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
revision = 'endpoints0019splitmonitorpolicies'
down_revision = 'endpoints0018endpointlocks'
branch_labels = None
depends_on = None
BUNDLE_NAME = 'Shopfloor App Monitors'
# The shipped monitors, by identity (kind, displayname) - never by hardcoded id,
# so this survives id drift across installs exactly as 0007 does.
_SHIPPED_MONITORS = (
('process', 'eDNC'),
('process', 'Universal Data Collector'),
('process', 'MTConnect'),
('service', 'MTConnect'),
)
_REQUIRED = {'endpointmonitors', 'configurationpolicies',
'configurationpolicyactions', 'businesspolicies',
'businesspolicyitems'}
def _tables(bind):
from sqlalchemy import inspect
return set(inspect(bind).get_table_names())
def _policy_name(kind, displayname):
return f'Monitor: {displayname} ({kind})'
def _policy_description(kind, displayname):
noun = 'process' if kind == 'process' else 'service'
return (f'Product-shipped watch-list entry for the {displayname} {noun}. '
f'Attach it to a Site Policy to apply it to endpoints.')
def _config_policy_id(bind, name):
return bind.execute(
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'),
{'name': name}).scalar()
def _drop_config_policy(bind, name):
"""Delete a configuration policy, its actions, and any items citing it."""
policyid = _config_policy_id(bind, name)
if policyid is None:
return
# Clear children explicitly rather than lean on ON DELETE CASCADE, which
# sqlite does not enforce by default.
bind.execute(
sa.text('DELETE FROM businesspolicyitems '
'WHERE configurationpolicyid = :id'), {'id': policyid})
bind.execute(
sa.text('DELETE FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'), {'id': policyid})
bind.execute(
sa.text('DELETE FROM configurationpolicies '
'WHERE configurationpolicyid = :id'), {'id': policyid})
def _drop_business_policy(bind, name):
policyid = bind.execute(
sa.text('SELECT businesspolicyid FROM businesspolicies '
'WHERE name = :name AND source = :source'),
{'name': name, 'source': 'shipped'}).scalar()
if policyid is None:
return
bind.execute(
sa.text('DELETE FROM businesspolicyitems WHERE businesspolicyid = :id'),
{'id': policyid})
bind.execute(
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
{'id': policyid})
def _monitor_id(bind, kind, displayname):
return bind.execute(
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
'WHERE kind = :kind AND displayname = :displayname'),
{'kind': kind, 'displayname': displayname}).scalar()
def upgrade():
bind = op.get_bind()
if not _REQUIRED.issubset(_tables(bind)):
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),
)
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),
)
for kind, displayname in _SHIPPED_MONITORS:
name = _policy_name(kind, displayname)
if _config_policy_id(bind, name) is not None:
continue
monitorid = _monitor_id(bind, kind, displayname)
if monitorid is None:
# A site that never had this monitor gets no policy for it.
continue
op.bulk_insert(config_policies, [{
'name': name,
'description': _policy_description(kind, displayname),
'scopejson': '{"type": "true"}',
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
op.bulk_insert(actions, [{
'configurationpolicyid': _config_policy_id(bind, name),
'action': f'monitor:{monitorid}',
'mode': 'gate',
'desiredstate': None,
'params': None,
'intervalminutes': None,
'sortorder': 0,
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
# Retire the monolith. The business policy goes first so its item rows are
# gone before the configuration policy it cited is removed.
_drop_business_policy(bind, BUNDLE_NAME)
_drop_config_policy(bind, BUNDLE_NAME)
def downgrade():
"""Reinstate the single shipped bundle and drop the per-monitor policies.
Rebuilt from the same monitor identities rather than from anything the
upgrade stashed, so it lands in the state 0007 produces regardless of what
a site did to the split rows in between.
"""
bind = op.get_bind()
if not _REQUIRED.issubset(_tables(bind)):
return
now = datetime.utcnow()
monitor_ids = []
for kind, displayname in _SHIPPED_MONITORS:
monitorid = _monitor_id(bind, kind, displayname)
if monitorid is not None:
monitor_ids.append(monitorid)
for kind, displayname in _SHIPPED_MONITORS:
_drop_config_policy(bind, _policy_name(kind, displayname))
if not monitor_ids or _config_policy_id(bind, BUNDLE_NAME) is not None:
return
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': BUNDLE_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 = _config_policy_id(bind, BUNDLE_NAME)
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': BUNDLE_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': BUNDLE_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,
}])