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

@@ -1,141 +0,0 @@
"""The shipped shopfloor app monitors ride a read-only Site Policy.
Migration endpoints0007shippedpolicy offloads the eDNC/UDC/MTConnect monitors
from the agent's hardcoded appsettings.json into a product-owned policy bundle:
a shipped ConfigurationPolicy "Shopfloor App Monitors" (global scope, one
desired-state monitor action per shipped monitor) wrapped in a shipped
BusinessPolicy "Shopfloor App Monitors".
Run the endpoints chain against a fresh engine, alongside the computers policy
tables the bundle writes into, and assert the bundle exists, is source='shipped'
(read-only), and attaches every shipped monitor as a desired/running:true action.
"""
import importlib.util
import json
from pathlib import Path
from sqlalchemy import create_engine, inspect, text
from alembic.migration import MigrationContext
from alembic.operations import Operations
_ROOT = Path(__file__).resolve().parent.parent.parent
_ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions'
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
def _load(path, prefix):
spec = importlib.util.spec_from_file_location(prefix + path.stem, str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _run_bundle_migrations(connection):
"""Build the policy tables (computers 0004), the monitor catalog (endpoints
0006) and the shipped bundle (endpoints 0007) on one engine, in order.
The core baseline creates every plugin's tables before per-plugin chains run,
so on a real deploy the computers policy tables already exist when endpoints
0007 runs. Here we stand up exactly the three migrations the bundle depends
on - the earlier endpoints revisions carry FKs to core tables that a bare
engine does not have, and are irrelevant to what 0007 seeds.
"""
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
_load(_COMPUTERS_VERSIONS / '0004_policies.py',
'computers_mig_').upgrade()
_load(_ENDPOINTS_VERSIONS / '0006_endpoint_monitors.py',
'endpoints_mig_0006_').upgrade()
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
'endpoints_mig_0007_').upgrade()
finally:
operations._remove_proxy()
connection.commit()
def test_shipped_bundle_seeds_read_only_site_policy():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
config = connection.execute(text(
'SELECT configurationpolicyid, source, scopejson '
'FROM configurationpolicies WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).first()
assert config is not None, 'shipped Configuration Policy was not seeded'
config_id, config_source, scopejson = config
assert config_source == 'shipped'
assert json.loads(scopejson) == {'type': 'true'}
business = connection.execute(text(
'SELECT businesspolicyid, source FROM businesspolicies '
'WHERE name = :name'), {'name': BUSINESS_POLICY_NAME}).first()
assert business is not None, 'shipped Business Policy was not seeded'
business_id, business_source = business
assert business_source == 'shipped'
item = connection.execute(text(
'SELECT configurationpolicyid FROM businesspolicyitems '
'WHERE businesspolicyid = :id'), {'id': business_id}).first()
assert item is not None and item[0] == config_id
def test_shipped_bundle_attaches_every_shipped_monitor():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
monitor_ids = {row[0] for row in connection.execute(text(
'SELECT endpointmonitorid FROM endpointmonitors '
"WHERE source = 'shipped'"))}
assert monitor_ids, 'no shipped monitors to attach'
config_id = connection.execute(text(
'SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'), {'name': CONFIG_POLICY_NAME}).scalar()
actions = connection.execute(text(
'SELECT action, mode, desiredstate FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'), {'id': config_id}).all()
attached_ids = set()
for action, mode, desiredstate in actions:
assert action.startswith('monitor:')
# Gate, not desired: the shipped bundle reports state and exposes
# Start/Stop in the UI, but never auto-starts a process. A desired
# running state is a deliberate per-monitor opt-in, not shipped.
assert mode == 'gate'
assert desiredstate is None
attached_ids.add(int(action[len('monitor:'):]))
assert attached_ids == monitor_ids
def test_shipped_bundle_is_idempotent():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
# Re-run only the shipped-bundle migration; it must not duplicate rows.
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
'endpoints_mig_rerun_').upgrade()
finally:
operations._remove_proxy()
connection.commit()
config_count = connection.execute(text(
'SELECT COUNT(*) FROM configurationpolicies WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).scalar()
business_count = connection.execute(text(
'SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
{'name': BUSINESS_POLICY_NAME}).scalar()
assert config_count == 1
assert business_count == 1

View File

@@ -0,0 +1,224 @@
"""The shipped shopfloor monitors ride one Configuration Policy per monitor.
0007 seeded a single "Shopfloor App Monitors" bundle wrapped in a shipped Site
Policy. 0019 replaced that with one policy per monitor and removed the shipped
Site Policy, so sites compose their own.
Run the endpoints chain against a fresh engine, alongside the computers policy
tables the migrations write into, and assert the post-0019 shape: a policy per
shipped monitor, each carrying exactly one gate-mode monitor action with no PC
type attribution, and no shipped Site Policy left behind.
The idempotency test is the load-bearing one. Nothing reconciles shipped rows,
so insert-if-absent is the only thing protecting a site's edits from an upgrade
that re-runs a seeding migration - see POLICY_SOURCE_SHIPPED in
plugins/computers/models/policy.py.
"""
import importlib.util
import json
from pathlib import Path
from sqlalchemy import create_engine, text
from alembic.migration import MigrationContext
from alembic.operations import Operations
_ROOT = Path(__file__).resolve().parent.parent.parent
_ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions'
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
BUNDLE_NAME = 'Shopfloor App Monitors'
SPLIT_MIGRATION = '0019_split_shipped_monitor_policies.py'
_SHIPPED_MONITORS = (
('process', 'eDNC'),
('process', 'Universal Data Collector'),
('process', 'MTConnect'),
('service', 'MTConnect'),
)
def _policy_name(kind, displayname):
return f'Monitor: {displayname} ({kind})'
def _load(path, prefix):
spec = importlib.util.spec_from_file_location(prefix + path.stem, str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _run(connection, *loaded):
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
for module in loaded:
module.upgrade()
finally:
operations._remove_proxy()
connection.commit()
def _run_bundle_migrations(connection):
"""Build the policy tables, monitor catalog, bundle and split, in order.
The core baseline creates every plugin's tables before per-plugin chains
run, so on a real deploy the computers policy tables already exist. Here we
stand up exactly the migrations the bundle depends on - the earlier
endpoints revisions carry FKs to core tables a bare engine does not have.
"""
_run(
connection,
_load(_COMPUTERS_VERSIONS / '0004_policies.py', 'computers_mig_'),
_load(_ENDPOINTS_VERSIONS / '0006_endpoint_monitors.py',
'endpoints_mig_0006_'),
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
'endpoints_mig_0007_'),
_load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION, 'endpoints_mig_0019_'),
)
def _monitor_id(connection, kind, displayname):
return connection.execute(text(
'SELECT endpointmonitorid FROM endpointmonitors '
'WHERE kind = :kind AND displayname = :displayname'),
{'kind': kind, 'displayname': displayname}).scalar()
def test_split_seeds_one_policy_per_shipped_monitor():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
for kind, displayname in _SHIPPED_MONITORS:
name = _policy_name(kind, displayname)
row = connection.execute(text(
'SELECT configurationpolicyid, source, scopejson '
'FROM configurationpolicies WHERE name = :name'),
{'name': name}).first()
assert row is not None, f'{name} was not seeded'
policyid, source, scopejson = row
assert source == 'shipped'
# No PC type attribution: the policy applies wherever a site
# composes it, and carries no opinion about which types match.
assert json.loads(scopejson) == {'type': 'true'}
actions = connection.execute(text(
'SELECT action, mode, desiredstate '
'FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'), {'id': policyid}).all()
assert len(actions) == 1, f'{name} should carry exactly one action'
action, mode, desiredstate = actions[0]
# Gate, not desired: a monitor reports state and exposes Start/Stop
# but never auto-starts a process. A desired running state is a
# deliberate per-monitor opt-in, not something the product ships on.
assert mode == 'gate'
assert desiredstate is None
expected = _monitor_id(connection, kind, displayname)
assert action == f'monitor:{expected}'
def test_split_removes_the_shipped_site_policy():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
business = connection.execute(text(
'SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
{'name': BUNDLE_NAME}).scalar()
assert business == 0, 'the shipped Site Policy must not survive 0019'
config = connection.execute(text(
'SELECT COUNT(*) FROM configurationpolicies WHERE name = :name'),
{'name': BUNDLE_NAME}).scalar()
assert config == 0, 'the bundled Configuration Policy must be retired'
items = connection.execute(text(
'SELECT COUNT(*) FROM businesspolicyitems')).scalar()
assert items == 0
def test_no_shipped_business_policy_remains():
"""Sites build their own composition; the product ships none."""
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
shipped = connection.execute(text(
"SELECT COUNT(*) FROM businesspolicies WHERE source = 'shipped'"
)).scalar()
assert shipped == 0
def test_split_is_idempotent_and_preserves_site_edits():
"""A re-run must not duplicate rows nor stomp an edit.
Nothing reconciles shipped rows, so this is the whole protection: the
migration returns early when the policy name is already present. If that
ever becomes a reconciling seeder, this test is what fails.
"""
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
edited = _policy_name('process', 'eDNC')
connection.execute(text(
'UPDATE configurationpolicies SET description = :description '
'WHERE name = :name'),
{'description': 'site edited', 'name': edited})
connection.commit()
_run(connection, _load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION,
'endpoints_mig_rerun_'))
for kind, displayname in _SHIPPED_MONITORS:
count = connection.execute(text(
'SELECT COUNT(*) FROM configurationpolicies '
'WHERE name = :name'),
{'name': _policy_name(kind, displayname)}).scalar()
assert count == 1
description = connection.execute(text(
'SELECT description FROM configurationpolicies WHERE name = :name'),
{'name': edited}).scalar()
assert description == 'site edited', 'a re-run stomped a site edit'
def test_downgrade_restores_the_bundle():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
split = _load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION,
'endpoints_mig_down_')
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
split.downgrade()
finally:
operations._remove_proxy()
connection.commit()
config_id = connection.execute(text(
'SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
assert config_id is not None, 'downgrade did not restore the bundle'
business_id = connection.execute(text(
'SELECT businesspolicyid FROM businesspolicies WHERE name = :name'),
{'name': BUNDLE_NAME}).scalar()
assert business_id is not None
item = connection.execute(text(
'SELECT configurationpolicyid FROM businesspolicyitems '
'WHERE businesspolicyid = :id'), {'id': business_id}).first()
assert item is not None and item[0] == config_id
for kind, displayname in _SHIPPED_MONITORS:
count = connection.execute(text(
'SELECT COUNT(*) FROM configurationpolicies '
'WHERE name = :name'),
{'name': _policy_name(kind, displayname)}).scalar()
assert count == 0, 'downgrade left the split policies behind'