diff --git a/plugins/computers/api/policy_routes.py b/plugins/computers/api/policy_routes.py index 72f350d5..dde1a342 100644 --- a/plugins/computers/api/policy_routes.py +++ b/plugins/computers/api/policy_routes.py @@ -19,7 +19,7 @@ from shopdb.api import (db, AuditLog, success_response, error_response, from .routes import computers_bp from ..models import (ConfigurationPolicy, ConfigurationPolicyAction, BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL, - POLICY_SOURCE_AUTOAPP, + POLICY_SOURCE_AUTOAPP, POLICY_SOURCE_SHIPPED, ACTION_MODE_DESIRED, ACTION_MODE_GATE) from ..policyflag import policy_flag_enabled from ..services.apppolicy import disown_if_generated @@ -262,8 +262,12 @@ def update_configuration_policy(policy_id: int): 'Configuration policy not found', http_code=404) # An autoapp row is generated from an Application's "Required on all PCs" # checkbox and IS editable: editing it is precisely how an admin takes - # ownership. Genuinely external sources (shipped/managed) stay read-only. - if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP): + # ownership. A shipped row is editable for the same reason - the product + # ships a starting point, not an opinion about one site's floor - and keeps + # source='shipped' so provenance survives the edit. Genuinely external + # sources (a future managed owner) stay read-only. + if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP, + POLICY_SOURCE_SHIPPED): return error_response(ErrorCodes.CONFLICT, 'Policy is managed externally and is read-only', http_code=409) @@ -326,9 +330,11 @@ def delete_configuration_policy(policy_id: int): if not policy: return error_response(ErrorCodes.NOT_FOUND, 'Configuration policy not found', http_code=404) - # As with update: a generated row is the site's to remove, an externally - # managed one is not. - if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP): + # As with update: a generated or shipped row is the site's to remove, an + # externally managed one is not. A site that does not run MTConnect should + # be able to delete its monitor policy outright rather than carry it. + if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP, + POLICY_SOURCE_SHIPPED): return error_response(ErrorCodes.CONFLICT, 'Policy is managed externally and is read-only', http_code=409) diff --git a/plugins/computers/models/policy.py b/plugins/computers/models/policy.py index 82e16c00..994bc4d8 100644 --- a/plugins/computers/models/policy.py +++ b/plugins/computers/models/policy.py @@ -27,10 +27,21 @@ from .jsontext import JSONText # owner. Kept a plain string, not an enum column, so adding a source is data. POLICY_SOURCE_LOCAL = 'local' -# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only -# treatment as any non-local source: the CRUD API refuses to edit or delete -# them, the seeder reconciles them. Distinct from 'local' so a site's own -# policies are never confused with what the product shipped. +# Rows the product seeds (the shopfloor monitor policies, one per monitor). +# Distinct from 'local' so a site's own policies are never confused with what +# the product shipped, and so the UI can badge the origin. +# +# A shipped row is EDITABLE and DELETABLE, unlike a genuinely external source. +# The product ships a starting point, not an opinion about one site's floor: a +# plant that runs no MTConnect must be able to drop that policy. source stays +# 'shipped' through an edit, so provenance is a fact about where the row came +# from rather than a claim about who controls it now. +# +# Nothing reconciles these rows. The seeding migrations are insert-if-absent +# (they SELECT by name and return early if it exists), so an upgrade cannot +# stomp a site's edits. That property is load-bearing now that the rows are +# editable - do not replace it with a reconciling seeder without adding a +# dirty flag first. POLICY_SOURCE_SHIPPED = 'shipped' # Rows generated FOR US by another surface rather than authored by hand: today diff --git a/plugins/endpoints/migrations/versions/0006_endpoint_monitors.py b/plugins/endpoints/migrations/versions/0006_endpoint_monitors.py index db0bc87c..aa016f4b 100644 --- a/plugins/endpoints/migrations/versions/0006_endpoint_monitors.py +++ b/plugins/endpoints/migrations/versions/0006_endpoint_monitors.py @@ -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 diff --git a/plugins/endpoints/migrations/versions/0007_shipped_monitor_policy.py b/plugins/endpoints/migrations/versions/0007_shipped_monitor_policy.py index e7672199..49e04b0f 100644 --- a/plugins/endpoints/migrations/versions/0007_shipped_monitor_policy.py +++ b/plugins/endpoints/migrations/versions/0007_shipped_monitor_policy.py @@ -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, diff --git a/plugins/endpoints/migrations/versions/0019_split_shipped_monitor_policies.py b/plugins/endpoints/migrations/versions/0019_split_shipped_monitor_policies.py new file mode 100644 index 00000000..69d1028b --- /dev/null +++ b/plugins/endpoints/migrations/versions/0019_split_shipped_monitor_policies.py @@ -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:' 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, + }]) diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index ed669573..146c748a 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -113,7 +113,9 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof' # and endpointmonitorimpacts. # 0018 adds endpointlocks: the admin quarantine refusing actions from non-admins # on one endpoint, a caller-authority axis separate from the policy hold. -EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0018endpointlocks' +# 0019 splits the 0007 bundle into one shipped Configuration Policy per monitor +# and drops the shipped Business Policy, so sites compose their own. +EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0019splitmonitorpolicies' # Plugins built after the cutover: their 0001 baseline really creates tables the # core chain never owned. diff --git a/tests/test_plugins/test_endpoints_shipped_monitor_policy.py b/tests/test_plugins/test_endpoints_shipped_monitor_policy.py deleted file mode 100644 index 901d586d..00000000 --- a/tests/test_plugins/test_endpoints_shipped_monitor_policy.py +++ /dev/null @@ -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 diff --git a/tests/test_plugins/test_endpoints_split_monitor_policies.py b/tests/test_plugins/test_endpoints_split_monitor_policies.py new file mode 100644 index 00000000..41dd0bf1 --- /dev/null +++ b/tests/test_plugins/test_endpoints_split_monitor_policies.py @@ -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'