From ff593412785070da8f266f1f12d14ae521111c72 Mon Sep 17 00:00:00 2001 From: Ben Price Date: Wed, 26 Aug 2026 18:07:14 -0400 Subject: [PATCH 1/3] Initial commit for post actions --- README.md | 93 +++++ nodescraper/models/__init__.py | 4 + nodescraper/models/pluginconfig.py | 3 + nodescraper/models/postactioncondition.py | 151 ++++++++ nodescraper/models/postactionpluginconfig.py | 96 +++++ nodescraper/pluginexecutor.py | 220 ++++++++--- test/unit/framework/test_plugin_executor.py | 251 +++++++++++- .../framework/test_post_action_condition.py | 360 ++++++++++++++++++ .../test_post_action_plugin_config.py | 206 ++++++++++ 9 files changed, 1330 insertions(+), 54 deletions(-) create mode 100644 nodescraper/models/postactioncondition.py create mode 100644 nodescraper/models/postactionpluginconfig.py create mode 100644 test/unit/framework/test_post_action_condition.py create mode 100644 test/unit/framework/test_post_action_plugin_config.py diff --git a/README.md b/README.md index 2b004861..31361c7e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ system debug. For details on what data is collected and analyzed, see the [plugi - [Configs](#configs) - [Global args](#global-args) - [Plugin config: **'--plugin-configs' command**](#plugin-config---plugin-configs-command) + - [Post-action plugins](#post-action-plugins) - [Reference config: **'gen-reference-config' command**](#reference-config-gen-reference-config-command) ## Installation @@ -645,6 +646,98 @@ Here is an example of a comprehensive plugin config that specifies analyzer args } ``` +#### Post-action plugins + +Post-action plugins run automatically **after all primary plugins have completed**, but only when +one or more configurable conditions are met. They are defined in the same plugin config JSON as +the primary plugins, under the `post_action_plugins` key. + +**Use cases:** +- Run a follow-up data-collection plugin only when a primary plugin detects errors +- Trigger remediation or additional diagnostic steps based on specific event categories or severities + +##### Config structure + +```json +{ + "plugins": { ... }, + "post_action_plugins": [ + { + "plugin": "", + "plugin_args": { ... }, + "conditions": [ + { "": "", ... }, + { "": "", ... } + ] + } + ] +} +``` + +- **`plugin`** — the name of the plugin to run (same registry name used in the `plugins` dict). +- **`plugin_args`** — arguments forwarded to the plugin's `run()` method (same shape as a normal + `plugins` entry, e.g. `collection`, `analysis`, `collection_args`, `analysis_args`). +- **`conditions`** — a list of condition objects. The post-action fires if **any** condition in the + list is satisfied (**OR** semantics). Within a single condition all specified fields must match + (**AND** semantics); unspecified fields are ignored. + +##### Condition fields + +All fields are optional. A condition with no fields specified matches any result. + +| Field | Type | Description | +|---|---|---| +| `plugin` | string | If set, only the result whose `source` matches this name is inspected. If omitted, all primary results are candidates. | +| `status` | string | The primary plugin's `ExecutionStatus` must be **≥** this value. Accepted values (in ascending order): `OK`, `WARNING`, `ERROR`, `EXECUTION_FAILURE`. | +| `event_category` | string | At least one event (from analysis or collection) must have this category. Normalised to uppercase with spaces/hyphens converted to underscores before comparison. | +| `event_priority` | string | At least one event's priority must be **≥** this value. Accepted values: `INFO`, `WARNING`, `ERROR`, `CRITICAL`. | +| `event_description_contains` | string | At least one event's description must contain this substring (case-sensitive). | + +##### Example: run OsPlugin if DmesgPlugin finds error-level events + +```json +{ + "name": "DmesgWithOsPostAction", + "desc": "Run DmesgPlugin; if any error-level event is found, run OsPlugin to capture OS state.", + "global_args": {}, + "plugins": { + "DmesgPlugin": { + "collection": true, + "analysis": true + } + }, + "result_collators": {}, + "post_action_plugins": [ + { + "plugin": "OsPlugin", + "plugin_args": { + "collection": true, + "analysis": true + }, + "conditions": [ + { + "plugin": "DmesgPlugin", + "event_priority": "ERROR" + } + ] + } + ] +} +``` + +Save to a file and pass it with `--plugin-configs`: + +```sh +node-scraper --plugin-configs=plugin_config_dmesg_os_post_action.json +``` + +Post-action plugin results are included in the same result list as primary plugins — they appear +in the console summary table, the `nodescraper.csv` output, and any result hooks. + +> **Note:** Post-action plugins run before connections are closed, so they have access to the same +> live connection managers as primary plugins. Post-action plugins cannot enqueue additional +> plugins into the primary queue. + #### Reference config: **'gen-reference-config' command** This command can be used to generate a reference config that is populated with current system configurations. Plugins that use analyzer args (where applicable) will be populated with system diff --git a/nodescraper/models/__init__.py b/nodescraper/models/__init__.py index 6b7ebb00..cde27594 100644 --- a/nodescraper/models/__init__.py +++ b/nodescraper/models/__init__.py @@ -30,6 +30,8 @@ from .event import Event from .pluginconfig import PluginConfig from .pluginresult import PluginResult +from .postactioncondition import PostActionCondition +from .postactionpluginconfig import PostActionPluginConfig from .priority_override import ( NO_CHANGE, PriorityOverrideRule, @@ -51,6 +53,8 @@ "PluginResult", "DataPluginResult", "PluginConfig", + "PostActionCondition", + "PostActionPluginConfig", "NO_CHANGE", "PriorityOverrideRule", "apply_priority_override_rules", diff --git a/nodescraper/models/pluginconfig.py b/nodescraper/models/pluginconfig.py index a060530f..0e35d0a1 100644 --- a/nodescraper/models/pluginconfig.py +++ b/nodescraper/models/pluginconfig.py @@ -29,6 +29,8 @@ from pydantic import BaseModel, Field +from nodescraper.models.postactionpluginconfig import PostActionPluginConfig + class PluginConfig(BaseModel): """Model for preset configuration of plugins and result collators""" @@ -36,6 +38,7 @@ class PluginConfig(BaseModel): global_args: dict = Field(default_factory=dict) plugins: dict[str, dict] = Field(default_factory=dict) result_collators: dict[str, dict] = Field(default_factory=dict) + post_action_plugins: list[PostActionPluginConfig] = Field(default_factory=list) name: Optional[str] = None desc: Optional[str] = None diff --git a/nodescraper/models/postactioncondition.py b/nodescraper/models/postactioncondition.py new file mode 100644 index 00000000..357909c2 --- /dev/null +++ b/nodescraper/models/postactioncondition.py @@ -0,0 +1,151 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Optional + +from pydantic import BaseModel + +from nodescraper.enums import EventPriority, ExecutionStatus + +if TYPE_CHECKING: + from nodescraper.models.event import Event + from nodescraper.models.pluginresult import PluginResult + + +class PostActionCondition(BaseModel): + """A single condition that, if matched, causes a post-action plugin to run. + + All specified (non-None) fields are AND'd together within one condition. + Unspecified fields are ignored and never prevent a match. A list of + ``PostActionCondition`` objects is OR'd by the containing + :class:`PostActionPluginConfig`. + """ + + plugin: Optional[str] = None + """If set, only inspect the PluginResult whose ``source`` matches this name. + If None, all results are candidates.""" + + status: Optional[str] = None + """If set, the result's ExecutionStatus must be >= this value. + Accepts any :class:`~nodescraper.enums.ExecutionStatus` name + (e.g. ``"WARNING"``, ``"ERROR"``, ``"EXECUTION_FAILURE"``).""" + + event_category: Optional[str] = None + """If set, at least one event from analysis_result or collection_result must + have a category equal to this value (matched after the same normalisation + applied to event categories: strip, upper, spaces/hyphens → underscores).""" + + event_priority: Optional[str] = None + """If set, at least one event's priority must be >= this value. + Accepts any :class:`~nodescraper.enums.EventPriority` name + (e.g. ``"WARNING"``, ``"ERROR"``, ``"CRITICAL"``).""" + + event_description_contains: Optional[str] = None + """If set, at least one event's description must contain this substring + (case-sensitive).""" + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _normalise_category(raw: str) -> str: + """Apply the same normalisation used by :class:`~nodescraper.models.event.Event`.""" + normalised = str(raw).strip().upper() + return re.sub(r"[\s-]", "_", normalised) + + def _get_all_events(self, result: PluginResult) -> list[Event]: + """Collect events from both collection and analysis task results.""" + events: list[Event] = [] + rd = result.result_data + if rd is None: + return events + if hasattr(rd, "collection_result") and rd.collection_result is not None: + events.extend(rd.collection_result.events) + if hasattr(rd, "analysis_result") and rd.analysis_result is not None: + events.extend(rd.analysis_result.events) + return events + + def _matches_result(self, result: PluginResult) -> bool: + """Return True if *result* satisfies all specified fields (AND logic). + + Each field that is not None must be satisfied; unset fields are skipped. + """ + # --- status check --- + if self.status is not None: + try: + threshold = ExecutionStatus[self.status.upper()] + except KeyError: + return False + if result.status < threshold: + return False + + # Remaining checks all operate on events; collect them once. + events = self._get_all_events(result) + + # --- event_category check --- + if self.event_category is not None: + normalised = self._normalise_category(self.event_category) + if not any(e.category == normalised for e in events): + return False + + # --- event_priority check --- + if self.event_priority is not None: + try: + threshold = EventPriority[self.event_priority.upper()] + except KeyError: + return False + if not any(e.priority >= threshold for e in events): + return False + + # --- event_description_contains check --- + if self.event_description_contains is not None: + if not any(self.event_description_contains in e.description for e in events): + return False + + return True + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def is_met(self, plugin_results: list[PluginResult]) -> bool: + """Return True if this condition is satisfied by any of the provided results. + + If ``plugin`` is set only that plugin's result is checked; otherwise all + results are candidates. + + Args: + plugin_results: List of :class:`~nodescraper.models.pluginresult.PluginResult` + objects from the primary plugin run. + + Returns: + bool: True if at least one candidate result satisfies all specified fields. + """ + candidates = [r for r in plugin_results if self.plugin is None or r.source == self.plugin] + return any(self._matches_result(r) for r in candidates) diff --git a/nodescraper/models/postactionpluginconfig.py b/nodescraper/models/postactionpluginconfig.py new file mode 100644 index 00000000..c67e61f3 --- /dev/null +++ b/nodescraper/models/postactionpluginconfig.py @@ -0,0 +1,96 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel, Field + +from nodescraper.models.postactioncondition import PostActionCondition + +if TYPE_CHECKING: + from nodescraper.models.pluginresult import PluginResult + + +class PostActionPluginConfig(BaseModel): + """Configuration for a single post-action plugin. + + A post-action plugin runs after all primary plugins have completed, but only + if at least one condition in ``conditions`` is satisfied by the primary results + (OR semantics across conditions). + + The ``plugin`` and ``plugin_args`` fields mirror the structure of a regular + entry in :attr:`~nodescraper.models.PluginConfig.plugins` — the plugin is + looked up by name in the registry and run identically to a primary plugin. + + Example JSON config entry:: + + { + "plugin": "SomeRemediationPlugin", + "plugin_args": {"collection": true, "analysis": false}, + "conditions": [ + {"plugin": "DmesgPlugin", "status": "ERROR"}, + {"event_priority": "CRITICAL", "event_description_contains": "GPU reset"} + ] + } + """ + + plugin: str + """Name of the plugin to run — must be registered in the plugin registry.""" + + plugin_args: dict = Field(default_factory=dict) + """Arguments forwarded verbatim to ``plugin.run()``. Same shape as entries + in :attr:`~nodescraper.models.PluginConfig.plugins`, e.g.:: + + { + "collection": True, + "analysis": False, + "collection_args": {"some_arg": "value"} + } + """ + + conditions: list[PostActionCondition] = Field(default_factory=list) + """List of conditions (OR'd). If any one condition is met by the primary + plugin results this post-action plugin will be executed.""" + + def should_run(self, plugin_results: list[PluginResult]) -> bool: + """Return True if at least one condition is satisfied by *plugin_results*. + + An empty ``conditions`` list is treated as *never run* (returns False), + which prevents post-action plugins from accidentally firing unconditionally + when a config omits conditions. + + Args: + plugin_results: The list of + :class:`~nodescraper.models.pluginresult.PluginResult` objects + produced by the primary plugin run. + + Returns: + bool: True if this post-action plugin should be executed. + """ + if not self.conditions: + return False + return any(condition.is_met(plugin_results) for condition in self.conditions) diff --git a/nodescraper/pluginexecutor.py b/nodescraper/pluginexecutor.py index 772f3662..75d549ba 100644 --- a/nodescraper/pluginexecutor.py +++ b/nodescraper/pluginexecutor.py @@ -158,9 +158,72 @@ def merge_configs(plugin_configs: list[PluginConfig]) -> PluginConfig: else: merged_config.plugins[plugin_name] = dict(plugin_args) merged_config.result_collators.update(config.result_collators) + merged_config.post_action_plugins.extend(config.post_action_plugins) return merged_config + def _get_connection_manager_for_plugin( + self, + plugin_class: type, + plugin_name: str, + ) -> Optional[ConnectionManager]: + """Resolve and (if needed) initialise the connection manager for *plugin_class*. + + Returns the :class:`~nodescraper.interfaces.ConnectionManager` instance to + use, or ``None`` if one cannot be obtained (errors are logged and the caller + should skip the plugin). + + The resolved manager is stored in ``self.connection_library`` so that it can + be shared across plugins that require the same type. + """ + if not plugin_class.CONNECTION_TYPE: + return None + + if issubclass(plugin_class, OOBSSHDataPlugin): + mgr_impl = OobSshConnectionManager + connection_args = self.connection_configs.get("RedfishConnectionManager") + if connection_args is None: + self.logger.error( + "%s requires RedfishConnectionManager in the connection config", + plugin_name, + ) + return None + else: + connection_manager_class: Type[ConnectionManager] = plugin_class.CONNECTION_TYPE + if connection_manager_class.__name__ in self.plugin_registry.connection_managers: + mgr_impl = self.plugin_registry.connection_managers[ + connection_manager_class.__name__ + ] + elif ( + inspect.isclass(connection_manager_class) + and issubclass(connection_manager_class, ConnectionManager) + and not inspect.isabstract(connection_manager_class) + ): + # External packages set CONNECTION_TYPE on the plugin; use it when + # not listed under nodescraper.connection_managers entry points. + mgr_impl = connection_manager_class + else: + self.logger.error( + "Unable to find registered connection manager class for %s that is required by", + connection_manager_class.__name__, + ) + return None + connection_args = None + + if mgr_impl not in self.connection_library: + self.logger.info("Initializing connection manager for %s", mgr_impl.__name__) + init_kwargs = { + "system_info": self.system_info, + "logger": self.logger, + "task_result_hooks": self.connection_result_hooks, + "session_id": self.session_id, + } + if connection_args is not None: + init_kwargs["connection_args"] = connection_args + self.connection_library[mgr_impl] = mgr_impl(**init_kwargs) + + return self.connection_library[mgr_impl] + def run_queue(self) -> list[PluginResult]: """Run the plugin queue and return results @@ -187,58 +250,10 @@ def run_queue(self) -> list[PluginResult]: } if plugin_class.CONNECTION_TYPE: - if issubclass(plugin_class, OOBSSHDataPlugin): - mgr_impl = OobSshConnectionManager - connection_args = self.connection_configs.get("RedfishConnectionManager") - if connection_args is None: - self.logger.error( - "%s requires RedfishConnectionManager in the connection config", - plugin_name, - ) - continue - else: - connection_manager_class: Type[ConnectionManager] = ( - plugin_class.CONNECTION_TYPE - ) - if ( - connection_manager_class.__name__ - in self.plugin_registry.connection_managers - ): - mgr_impl = self.plugin_registry.connection_managers[ - connection_manager_class.__name__ - ] - elif ( - inspect.isclass(connection_manager_class) - and issubclass(connection_manager_class, ConnectionManager) - and not inspect.isabstract(connection_manager_class) - ): - # External packages set CONNECTION_TYPE on the plugin; - # use it when not listed under nodescraper.connection_managers entry points. - mgr_impl = connection_manager_class - else: - self.logger.error( - "Unable to find registered connection manager class for %s that is required by", - connection_manager_class.__name__, - ) - continue - connection_args = None - - if mgr_impl not in self.connection_library: - self.logger.info( - "Initializing connection manager for %s", - mgr_impl.__name__, - ) - init_kwargs = { - "system_info": self.system_info, - "logger": self.logger, - "task_result_hooks": self.connection_result_hooks, - "session_id": self.session_id, - } - if connection_args is not None: - init_kwargs["connection_args"] = connection_args - self.connection_library[mgr_impl] = mgr_impl(**init_kwargs) - - init_payload["connection_manager"] = self.connection_library[mgr_impl] + conn_mgr = self._get_connection_manager_for_plugin(plugin_class, plugin_name) + if conn_mgr is None: + continue + init_payload["connection_manager"] = conn_mgr try: plugin_inst = plugin_class(**init_payload) @@ -281,7 +296,10 @@ def run_queue(self) -> list[PluginResult]: except Exception as e: self.logger.exception("Unexpected exception running plugin queue: %s", str(e)) finally: - self.logger.info("Closing connections") + # Run post-action plugins before tearing down connections or collating + # results so that post-actions still have access to live connections + # and their results are included in the collator output. + self._run_post_actions(plugin_results) if self.plugin_config.result_collators: self.logger.info("Running result collators") @@ -303,11 +321,107 @@ def run_queue(self) -> list[PluginResult]: ], **collator_args, ) + + self.logger.info("Closing connections") for connection_manager in self.connection_library.values(): connection_manager.disconnect() return plugin_results + def _run_post_actions(self, plugin_results: list[PluginResult]) -> None: + """Evaluate post-action conditions and run qualifying plugins. + + Post-action plugins are run after all primary plugins have finished but + *before* connections are closed, so they have full access to the same + connection managers. Their :class:`~nodescraper.models.PluginResult` + objects are appended to *plugin_results* in-place, making them visible + to result collators and any ``plugin_run_result_hooks``. + + Each :class:`~nodescraper.models.PostActionPluginConfig` entry in + ``self.plugin_config.post_action_plugins`` is evaluated independently; + those whose conditions are satisfied (OR semantics within each entry's + ``conditions`` list) are run in order. + + Args: + plugin_results: Accumulated primary plugin results. Mutated in-place + with results from post-action plugins that fire. + """ + if not self.plugin_config.post_action_plugins: + return + + self.logger.info("Evaluating post-action plugin conditions") + + for post_action in self.plugin_config.post_action_plugins: + plugin_name = post_action.plugin + + if not post_action.should_run(plugin_results): + self.logger.info("Post-action plugin %s: conditions not met, skipping", plugin_name) + continue + + self.logger.info("=" * 50) + self.logger.info("Running post-action plugin: %s", plugin_name) + + if plugin_name not in self.plugin_registry.plugins: + self.logger.error( + "Unable to find registered plugin for post-action name %s", plugin_name + ) + continue + + plugin_class = self.plugin_registry.plugins[plugin_name] + + init_payload = { + "system_info": self.system_info, + "logger": self.logger, + # Post-action plugins cannot enqueue further plugins into the + # primary queue; pass None so _update_queue is a no-op. + "queue_callback": None, + "log_path": self.log_path, + "session_id": self.session_id, + } + + if plugin_class.CONNECTION_TYPE: + conn_mgr = self._get_connection_manager_for_plugin(plugin_class, plugin_name) + if conn_mgr is None: + continue + init_payload["connection_manager"] = conn_mgr + + try: + plugin_inst = plugin_class(**init_payload) + + run_payload = copy.deepcopy(post_action.plugin_args) + run_args = TypeUtils.get_func_arg_types(plugin_class.run, plugin_class) + + for arg in run_args.keys(): + if arg == "preserve_connection" and issubclass(plugin_class, DataPlugin): + run_payload[arg] = True + + try: + global_run_args = self.apply_global_args_to_plugin( + plugin_inst, plugin_class, self.plugin_config.global_args + ) + for args_key in ["analysis_args", "collection_args"]: + if args_key in global_run_args and args_key in run_payload: + run_payload[args_key].update(global_run_args[args_key]) + del global_run_args[args_key] + run_payload.update(global_run_args) + except ValueError as ve: + self.logger.error( + "Invalid global_args for post-action plugin %s: %s. Skipping.", + plugin_name, + str(ve), + ) + continue + + plugin_result = plugin_inst.run(**run_payload) + plugin_results.append(plugin_result) + for hook in self.plugin_run_result_hooks: + hook(plugin_result) + + except Exception as e: + self.logger.exception( + "Unexpected exception when running post-action plugin %s: %s", plugin_name, e + ) + def apply_global_args_to_plugin( self, plugin_inst: PluginInterface, diff --git a/test/unit/framework/test_plugin_executor.py b/test/unit/framework/test_plugin_executor.py index 494551ce..f0b29182 100644 --- a/test/unit/framework/test_plugin_executor.py +++ b/test/unit/framework/test_plugin_executor.py @@ -23,6 +23,8 @@ # SOFTWARE. # ############################################################################### +import logging + import pytest from framework.common.shared_utils import DummyDataModel, MockConnectionManager from pydantic import BaseModel @@ -32,6 +34,8 @@ from nodescraper.enums.systeminteraction import SystemInteractionLevel from nodescraper.interfaces import PluginInterface from nodescraper.models import PluginConfig, PluginResult +from nodescraper.models.postactioncondition import PostActionCondition +from nodescraper.models.postactionpluginconfig import PostActionPluginConfig from nodescraper.pluginexecutor import PluginExecutor from nodescraper.pluginregistry import PluginRegistry @@ -67,10 +71,23 @@ def run(self, test_arg=None): ) +class PostActionPlugin(PluginInterface[MockConnectionManager, None]): + """Minimal plugin used as a post-action target in tests.""" + + CONNECTION_TYPE = MockConnectionManager + + def run(self, **kwargs): + return PluginResult(source="PostActionPlugin", status=ExecutionStatus.OK) + + @pytest.fixture def plugin_registry(): registry = PluginRegistry() - registry.plugins = {"TestPluginA": TestPluginA, "TestPluginB": TestPluginB} + registry.plugins = { + "TestPluginA": TestPluginA, + "TestPluginB": TestPluginB, + "PostActionPlugin": PostActionPlugin, + } registry.connection_managers = {"MockConnectionManager": MockConnectionManager} return registry @@ -201,3 +218,235 @@ def hook(res: PluginResult) -> None: ) executor.run_queue() assert seen == ["testB"] + + +# --------------------------------------------------------------------------- +# merge_configs: post_action_plugins concatenation +# --------------------------------------------------------------------------- + + +def test_merge_configs_concatenates_post_action_plugins(): + """post_action_plugins lists from multiple configs are concatenated.""" + pa1 = PostActionPluginConfig( + plugin="PostActionPlugin", + conditions=[PostActionCondition(status="ERROR")], + ) + pa2 = PostActionPluginConfig( + plugin="PostActionPlugin", + conditions=[PostActionCondition(status="WARNING")], + ) + configs = [ + PluginConfig(post_action_plugins=[pa1]), + PluginConfig(post_action_plugins=[pa2]), + ] + merged = PluginExecutor.merge_configs(configs) + assert len(merged.post_action_plugins) == 2 + assert merged.post_action_plugins[0] is pa1 + assert merged.post_action_plugins[1] is pa2 + + +def test_merge_configs_empty_post_action_plugins(): + """Merging configs with no post_action_plugins yields an empty list.""" + configs = [PluginConfig(plugins={"TestPluginB": {}})] + merged = PluginExecutor.merge_configs(configs) + assert merged.post_action_plugins == [] + + +# --------------------------------------------------------------------------- +# run_queue: post-action execution +# --------------------------------------------------------------------------- + + +def test_post_action_runs_when_condition_met(plugin_registry): + """Post-action plugin fires when primary result meets the condition. + + TestPluginA returns ERROR and also queues TestPluginB via _update_queue, + so the primary run produces two results (testA + testB). The post-action + fires on the ERROR status, giving a total of 3 results. + """ + executor = PluginExecutor( + plugin_configs=[ + PluginConfig( + plugins={"TestPluginA": {}}, # TestPluginA always returns ERROR + post_action_plugins=[ + PostActionPluginConfig( + plugin="PostActionPlugin", + conditions=[PostActionCondition(status="ERROR")], + ) + ], + ) + ], + plugin_registry=plugin_registry, + ) + results = executor.run_queue() + + sources = [r.source for r in results] + assert "testA" in sources + assert "testB" in sources # queued by TestPluginA via _update_queue + assert "PostActionPlugin" in sources + assert len(results) == 3 + + +def test_post_action_does_not_run_when_condition_not_met(plugin_registry): + """Post-action plugin is skipped when no primary result meets the condition.""" + executor = PluginExecutor( + plugin_configs=[ + PluginConfig( + plugins={"TestPluginB": {}}, # TestPluginB always returns OK + post_action_plugins=[ + PostActionPluginConfig( + plugin="PostActionPlugin", + conditions=[PostActionCondition(status="ERROR")], + ) + ], + ) + ], + plugin_registry=plugin_registry, + ) + results = executor.run_queue() + + assert len(results) == 1 + assert results[0].source == "testB" + + +def test_post_action_result_appended_to_run_queue_return(plugin_registry): + """The post-action PluginResult is present in the list returned by run_queue().""" + executor = PluginExecutor( + plugin_configs=[ + PluginConfig( + plugins={"TestPluginA": {}}, + post_action_plugins=[ + PostActionPluginConfig( + plugin="PostActionPlugin", + conditions=[PostActionCondition(status="ERROR")], + ) + ], + ) + ], + plugin_registry=plugin_registry, + ) + results = executor.run_queue() + + post_action_results = [r for r in results if r.source == "PostActionPlugin"] + assert len(post_action_results) == 1 + assert post_action_results[0].status == ExecutionStatus.OK + + +def test_post_action_result_hooks_called(plugin_registry): + """plugin_run_result_hooks are invoked for post-action results too.""" + seen: list[str] = [] + + def hook(res: PluginResult) -> None: + seen.append(res.source) + + executor = PluginExecutor( + plugin_configs=[ + PluginConfig( + plugins={"TestPluginA": {}}, + post_action_plugins=[ + PostActionPluginConfig( + plugin="PostActionPlugin", + conditions=[PostActionCondition(status="ERROR")], + ) + ], + ) + ], + plugin_registry=plugin_registry, + plugin_run_result_hooks=[hook], + ) + executor.run_queue() + + assert "testA" in seen + assert "PostActionPlugin" in seen + + +def test_multiple_post_actions_selective_firing(plugin_registry): + """With two post-actions, only the one whose condition is met runs. + + testA returns ERROR (value=40). The first condition requires ERROR (40 >= 40) → fires. + The second condition requires EXECUTION_FAILURE (50); ERROR (40) < 50 → does not fire. + """ + executor = PluginExecutor( + plugin_configs=[ + PluginConfig( + plugins={"TestPluginA": {}}, # returns ERROR + post_action_plugins=[ + PostActionPluginConfig( + plugin="PostActionPlugin", + conditions=[PostActionCondition(status="ERROR")], # fires: ERROR >= ERROR + ), + PostActionPluginConfig( + plugin="PostActionPlugin", + # does not fire: testA is ERROR (40) < EXECUTION_FAILURE (50) + conditions=[ + PostActionCondition(plugin="testA", status="EXECUTION_FAILURE") + ], + ), + ], + ) + ], + plugin_registry=plugin_registry, + ) + results = executor.run_queue() + + post_action_results = [r for r in results if r.source == "PostActionPlugin"] + assert len(post_action_results) == 1 # only first post-action fired + + +def test_post_action_invalid_plugin_name_logs_error_and_continues(plugin_registry, caplog): + """An unregistered post-action plugin name is logged as an error; run completes.""" + with caplog.at_level(logging.ERROR): + executor = PluginExecutor( + plugin_configs=[ + PluginConfig( + plugins={"TestPluginA": {}}, + post_action_plugins=[ + PostActionPluginConfig( + plugin="NonExistentPlugin", + conditions=[PostActionCondition(status="ERROR")], + ) + ], + ) + ], + plugin_registry=plugin_registry, + ) + results = executor.run_queue() + + # Primary result still returned; no exception raised + assert any(r.source == "testA" for r in results) + assert any("NonExistentPlugin" in record.message for record in caplog.records) + + +def test_closing_connections_logged_after_post_actions(plugin_registry, caplog): + """'Closing connections' log appears after the post-action run log, not before.""" + with caplog.at_level(logging.INFO): + executor = PluginExecutor( + plugin_configs=[ + PluginConfig( + plugins={"TestPluginA": {}}, + post_action_plugins=[ + PostActionPluginConfig( + plugin="PostActionPlugin", + conditions=[PostActionCondition(status="ERROR")], + ) + ], + ) + ], + plugin_registry=plugin_registry, + ) + executor.run_queue() + + messages = [r.message for r in caplog.records] + + post_action_idx = next( + (i for i, m in enumerate(messages) if "post-action plugin" in m.lower()), None + ) + closing_idx = next( + (i for i, m in enumerate(messages) if "closing connections" in m.lower()), None + ) + + assert post_action_idx is not None, "Expected a post-action log message" + assert closing_idx is not None, "Expected a 'Closing connections' log message" + assert ( + post_action_idx < closing_idx + ), "'Closing connections' must be logged after the post-action plugin runs" diff --git a/test/unit/framework/test_post_action_condition.py b/test/unit/framework/test_post_action_condition.py new file mode 100644 index 00000000..fca7fc1c --- /dev/null +++ b/test/unit/framework/test_post_action_condition.py @@ -0,0 +1,360 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +"""Unit tests for PostActionCondition matching logic.""" +import pytest + +from nodescraper.enums import EventPriority, ExecutionStatus +from nodescraper.models import DataPluginResult, Event, PluginResult, TaskResult +from nodescraper.models.postactioncondition import PostActionCondition + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_event( + category: str, + description: str, + priority: EventPriority = EventPriority.ERROR, +) -> Event: + return Event( + category=category, + description=description, + priority=priority, + reporter="test", + ) + + +def _make_result( + source: str = "SomePlugin", + status: ExecutionStatus = ExecutionStatus.OK, + analysis_events: list[Event] | None = None, + collection_events: list[Event] | None = None, +) -> PluginResult: + """Build a PluginResult with optional events in analysis and/or collection results.""" + return PluginResult( + status=status, + source=source, + result_data=DataPluginResult( + analysis_result=TaskResult(status=status, events=analysis_events or []), + collection_result=TaskResult(status=status, events=collection_events or []), + ), + ) + + +# --------------------------------------------------------------------------- +# No-field conditions (vacuously true) +# --------------------------------------------------------------------------- + + +def test_no_fields_matches_any_result(): + """A condition with all fields None matches any result.""" + condition = PostActionCondition() + result = _make_result(status=ExecutionStatus.OK) + assert condition.is_met([result]) is True + + +def test_no_fields_matches_error_result(): + condition = PostActionCondition() + result = _make_result(status=ExecutionStatus.ERROR) + assert condition.is_met([result]) is True + + +def test_no_results_returns_false(): + """With an empty results list there are no candidates — always False.""" + condition = PostActionCondition() + assert condition.is_met([]) is False + + +# --------------------------------------------------------------------------- +# status field +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "condition_status, result_status, expected", + [ + ("WARNING", ExecutionStatus.WARNING, True), # exact match at threshold + ("WARNING", ExecutionStatus.ERROR, True), # above threshold + ("WARNING", ExecutionStatus.EXECUTION_FAILURE, True), + ("ERROR", ExecutionStatus.ERROR, True), + ("ERROR", ExecutionStatus.OK, False), # below threshold + ("ERROR", ExecutionStatus.WARNING, False), + ("EXECUTION_FAILURE", ExecutionStatus.ERROR, False), + ("OK", ExecutionStatus.OK, True), + ("OK", ExecutionStatus.WARNING, True), + ], +) +def test_status_threshold(condition_status, result_status, expected): + condition = PostActionCondition(status=condition_status) + result = _make_result(status=result_status) + assert condition.is_met([result]) is expected + + +def test_status_invalid_name_returns_false(): + """An unrecognised status name never matches (doesn't raise).""" + condition = PostActionCondition(status="NONEXISTENT_STATUS") + result = _make_result(status=ExecutionStatus.ERROR) + assert condition.is_met([result]) is False + + +# --------------------------------------------------------------------------- +# event_category field +# --------------------------------------------------------------------------- + + +def test_event_category_matches_analysis_event(): + condition = PostActionCondition(event_category="WIDGET_ERROR") + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("WIDGET_ERROR", "widget fault detected")], + ) + assert condition.is_met([result]) is True + + +def test_event_category_matches_collection_event(): + """event_category also matches events from collection_result.""" + condition = PostActionCondition(event_category="COLLECTION_WARN") + result = _make_result( + status=ExecutionStatus.WARNING, + collection_events=[_make_event("COLLECTION_WARN", "something", EventPriority.WARNING)], + ) + assert condition.is_met([result]) is True + + +def test_event_category_no_match(): + condition = PostActionCondition(event_category="WIDGET_ERROR") + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("GIZMO_ERROR", "gizmo fault")], + ) + assert condition.is_met([result]) is False + + +def test_event_category_normalisation(): + """Category matching normalises spaces and hyphens to underscores and uppercases.""" + condition = PostActionCondition(event_category="widget-error") + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("WIDGET_ERROR", "widget fault")], + ) + assert condition.is_met([result]) is True + + +def test_event_category_no_events_returns_false(): + condition = PostActionCondition(event_category="WIDGET_ERROR") + result = _make_result(status=ExecutionStatus.ERROR) # no events + assert condition.is_met([result]) is False + + +# --------------------------------------------------------------------------- +# event_priority field +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "condition_priority, event_priority, expected", + [ + ("WARNING", EventPriority.WARNING, True), + ("WARNING", EventPriority.ERROR, True), + ("WARNING", EventPriority.CRITICAL, True), + ("ERROR", EventPriority.ERROR, True), + ("ERROR", EventPriority.CRITICAL, True), + ("ERROR", EventPriority.WARNING, False), + ("CRITICAL", EventPriority.ERROR, False), + ("CRITICAL", EventPriority.CRITICAL, True), + ], +) +def test_event_priority_threshold(condition_priority, event_priority, expected): + condition = PostActionCondition(event_priority=condition_priority) + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("SOME_CAT", "some description", event_priority)], + ) + assert condition.is_met([result]) is expected + + +def test_event_priority_invalid_name_returns_false(): + condition = PostActionCondition(event_priority="SUPER_CRITICAL") + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("CAT", "desc", EventPriority.CRITICAL)], + ) + assert condition.is_met([result]) is False + + +def test_event_priority_no_events_returns_false(): + condition = PostActionCondition(event_priority="WARNING") + result = _make_result(status=ExecutionStatus.ERROR) + assert condition.is_met([result]) is False + + +# --------------------------------------------------------------------------- +# event_description_contains field +# --------------------------------------------------------------------------- + + +def test_event_description_contains_match(): + condition = PostActionCondition(event_description_contains="widget fault") + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("WIDGET_ERROR", "widget fault detected on unit 0")], + ) + assert condition.is_met([result]) is True + + +def test_event_description_contains_no_match(): + condition = PostActionCondition(event_description_contains="widget fault") + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("GIZMO_ERROR", "gizmo fault")], + ) + assert condition.is_met([result]) is False + + +def test_event_description_contains_case_sensitive(): + """Substring match is case-sensitive.""" + condition = PostActionCondition(event_description_contains="Widget Fault") + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("WIDGET_ERROR", "widget fault detected")], + ) + assert condition.is_met([result]) is False + + +def test_event_description_contains_no_events_returns_false(): + condition = PostActionCondition(event_description_contains="anything") + result = _make_result(status=ExecutionStatus.ERROR) + assert condition.is_met([result]) is False + + +# --------------------------------------------------------------------------- +# AND semantics within a single condition +# --------------------------------------------------------------------------- + + +def test_and_semantics_status_matches_category_does_not(): + """Both fields specified; status matches but category doesn't → False.""" + condition = PostActionCondition(status="ERROR", event_category="WIDGET_ERROR") + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("GIZMO_ERROR", "gizmo fault")], + ) + assert condition.is_met([result]) is False + + +def test_and_semantics_category_matches_status_does_not(): + """Both fields specified; category matches but status doesn't → False.""" + condition = PostActionCondition(status="ERROR", event_category="WIDGET_ERROR") + result = _make_result( + status=ExecutionStatus.WARNING, + analysis_events=[_make_event("WIDGET_ERROR", "widget fault")], + ) + assert condition.is_met([result]) is False + + +def test_and_semantics_all_fields_match(): + """All four fields specified and all matched → True.""" + condition = PostActionCondition( + status="WARNING", + event_category="WIDGET_ERROR", + event_priority="ERROR", + event_description_contains="widget fault", + ) + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("WIDGET_ERROR", "widget fault detected", EventPriority.ERROR)], + ) + assert condition.is_met([result]) is True + + +def test_and_semantics_three_fields_one_missing(): + """Three fields specified; the one unmatched field causes False.""" + condition = PostActionCondition( + status="ERROR", + event_category="WIDGET_ERROR", + event_description_contains="widget fault", + ) + result = _make_result( + status=ExecutionStatus.ERROR, + analysis_events=[_make_event("WIDGET_ERROR", "gizmo fault")], # description doesn't match + ) + assert condition.is_met([result]) is False + + +# --------------------------------------------------------------------------- +# plugin filter +# --------------------------------------------------------------------------- + + +def test_plugin_filter_restricts_to_named_source(): + """With plugin set, only that plugin's result is a candidate.""" + condition = PostActionCondition(plugin="PluginA", status="ERROR") + result_a = _make_result(source="PluginA", status=ExecutionStatus.ERROR) + result_b = _make_result(source="PluginB", status=ExecutionStatus.ERROR) + assert condition.is_met([result_a, result_b]) is True + + +def test_plugin_filter_excludes_other_source(): + """Named plugin's result doesn't meet the condition; other results are excluded.""" + condition = PostActionCondition(plugin="PluginA", status="ERROR") + result_a = _make_result(source="PluginA", status=ExecutionStatus.OK) + result_b = _make_result(source="PluginB", status=ExecutionStatus.ERROR) + assert condition.is_met([result_a, result_b]) is False + + +def test_plugin_filter_none_checks_all_results(): + """With plugin=None, any result may satisfy the condition.""" + condition = PostActionCondition(plugin=None, status="ERROR") + result_a = _make_result(source="PluginA", status=ExecutionStatus.OK) + result_b = _make_result(source="PluginB", status=ExecutionStatus.ERROR) + assert condition.is_met([result_a, result_b]) is True + + +def test_plugin_filter_named_plugin_not_in_results(): + """Named plugin not present in results at all → no candidates → False.""" + condition = PostActionCondition(plugin="MissingPlugin", status="ERROR") + result = _make_result(source="OtherPlugin", status=ExecutionStatus.ERROR) + assert condition.is_met([result]) is False + + +# --------------------------------------------------------------------------- +# result_data edge cases +# --------------------------------------------------------------------------- + + +def test_result_with_no_result_data_status_only(): + """A bare PluginResult (no result_data) can still match on status.""" + condition = PostActionCondition(status="ERROR") + result = PluginResult(status=ExecutionStatus.ERROR, source="BarePlugin") + assert condition.is_met([result]) is True + + +def test_result_with_no_result_data_event_field_returns_false(): + """A bare PluginResult has no events; event-based conditions are False.""" + condition = PostActionCondition(event_category="WIDGET_ERROR") + result = PluginResult(status=ExecutionStatus.ERROR, source="BarePlugin") + assert condition.is_met([result]) is False diff --git a/test/unit/framework/test_post_action_plugin_config.py b/test/unit/framework/test_post_action_plugin_config.py new file mode 100644 index 00000000..b555a648 --- /dev/null +++ b/test/unit/framework/test_post_action_plugin_config.py @@ -0,0 +1,206 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +"""Unit tests for PostActionPluginConfig.should_run OR semantics.""" + +from nodescraper.enums import ExecutionStatus +from nodescraper.models import PluginResult +from nodescraper.models.postactioncondition import PostActionCondition +from nodescraper.models.postactionpluginconfig import PostActionPluginConfig + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_result(source: str, status: ExecutionStatus) -> PluginResult: + return PluginResult(status=status, source=source) + + +def _cond(status: str, plugin: str | None = None) -> PostActionCondition: + """Shorthand: a condition that fires when *source* has at least *status*.""" + return PostActionCondition(plugin=plugin, status=status) + + +# --------------------------------------------------------------------------- +# Empty conditions +# --------------------------------------------------------------------------- + + +def test_no_conditions_never_fires(): + """An empty conditions list always returns False — no accidental unconditional runs.""" + cfg = PostActionPluginConfig(plugin="SomePlugin", conditions=[]) + results = [_make_result("Primary", ExecutionStatus.EXECUTION_FAILURE)] + assert cfg.should_run(results) is False + + +def test_no_conditions_empty_results_also_false(): + cfg = PostActionPluginConfig(plugin="SomePlugin", conditions=[]) + assert cfg.should_run([]) is False + + +# --------------------------------------------------------------------------- +# OR semantics across conditions +# --------------------------------------------------------------------------- + + +def test_or_first_condition_met_second_not(): + """should_run is True when the first condition fires even if the second doesn't.""" + cfg = PostActionPluginConfig( + plugin="SomePlugin", + conditions=[ + _cond("ERROR", plugin="PluginA"), # PluginA has ERROR → True + _cond("ERROR", plugin="PluginB"), # PluginB has OK → False + ], + ) + results = [ + _make_result("PluginA", ExecutionStatus.ERROR), + _make_result("PluginB", ExecutionStatus.OK), + ] + assert cfg.should_run(results) is True + + +def test_or_first_condition_not_met_second_met(): + """should_run is True when only the second condition fires.""" + cfg = PostActionPluginConfig( + plugin="SomePlugin", + conditions=[ + _cond("ERROR", plugin="PluginA"), # PluginA has OK → False + _cond("ERROR", plugin="PluginB"), # PluginB has ERROR → True + ], + ) + results = [ + _make_result("PluginA", ExecutionStatus.OK), + _make_result("PluginB", ExecutionStatus.ERROR), + ] + assert cfg.should_run(results) is True + + +def test_or_no_conditions_met(): + """should_run is False when no condition is satisfied.""" + cfg = PostActionPluginConfig( + plugin="SomePlugin", + conditions=[ + _cond("ERROR", plugin="PluginA"), + _cond("ERROR", plugin="PluginB"), + ], + ) + results = [ + _make_result("PluginA", ExecutionStatus.OK), + _make_result("PluginB", ExecutionStatus.WARNING), + ] + assert cfg.should_run(results) is False + + +def test_or_all_conditions_met(): + """should_run is True when every condition is satisfied (OR short-circuits at first).""" + cfg = PostActionPluginConfig( + plugin="SomePlugin", + conditions=[ + _cond("ERROR", plugin="PluginA"), + _cond("ERROR", plugin="PluginB"), + ], + ) + results = [ + _make_result("PluginA", ExecutionStatus.ERROR), + _make_result("PluginB", ExecutionStatus.ERROR), + ] + assert cfg.should_run(results) is True + + +def test_single_condition_met(): + cfg = PostActionPluginConfig( + plugin="SomePlugin", + conditions=[_cond("WARNING")], + ) + results = [_make_result("Primary", ExecutionStatus.ERROR)] + assert cfg.should_run(results) is True + + +def test_single_condition_not_met(): + cfg = PostActionPluginConfig( + plugin="SomePlugin", + conditions=[_cond("ERROR")], + ) + results = [_make_result("Primary", ExecutionStatus.OK)] + assert cfg.should_run(results) is False + + +# --------------------------------------------------------------------------- +# Empty results list +# --------------------------------------------------------------------------- + + +def test_conditions_present_empty_results_returns_false(): + """Conditions exist but there are no primary results to match against.""" + cfg = PostActionPluginConfig( + plugin="SomePlugin", + conditions=[_cond("ERROR")], + ) + assert cfg.should_run([]) is False + + +# --------------------------------------------------------------------------- +# plugin_args field validation +# --------------------------------------------------------------------------- + + +def test_plugin_args_defaults_to_empty_dict(): + cfg = PostActionPluginConfig(plugin="SomePlugin", conditions=[_cond("ERROR")]) + assert cfg.plugin_args == {} + + +def test_plugin_args_passed_through(): + cfg = PostActionPluginConfig( + plugin="SomePlugin", + plugin_args={"collection": True, "analysis": False}, + conditions=[_cond("ERROR")], + ) + assert cfg.plugin_args == {"collection": True, "analysis": False} + + +# --------------------------------------------------------------------------- +# JSON round-trip (Pydantic model_validate from dict) +# --------------------------------------------------------------------------- + + +def test_model_validate_from_dict(): + """PostActionPluginConfig can be constructed from a plain dict (as from JSON config).""" + raw = { + "plugin": "RemediationPlugin", + "plugin_args": {"collection": True}, + "conditions": [ + {"plugin": "PrimaryPlugin", "status": "ERROR"}, + {"event_priority": "CRITICAL", "event_description_contains": "widget fault"}, + ], + } + cfg = PostActionPluginConfig.model_validate(raw) + assert cfg.plugin == "RemediationPlugin" + assert cfg.plugin_args == {"collection": True} + assert len(cfg.conditions) == 2 + assert cfg.conditions[0].plugin == "PrimaryPlugin" + assert cfg.conditions[0].status == "ERROR" + assert cfg.conditions[1].event_priority == "CRITICAL" + assert cfg.conditions[1].event_description_contains == "widget fault" From b9b8e92a22c88d6bea37eb3306106db9a9fb7da6 Mon Sep 17 00:00:00 2001 From: Ben Price Date: Thu, 27 Aug 2026 17:20:38 -0400 Subject: [PATCH 2/3] bugfix for remote execution --- nodescraper/pluginexecutor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodescraper/pluginexecutor.py b/nodescraper/pluginexecutor.py index 75d549ba..8283f351 100644 --- a/nodescraper/pluginexecutor.py +++ b/nodescraper/pluginexecutor.py @@ -111,7 +111,7 @@ def __init__( connection_manager = self.plugin_registry.connection_managers[connection] self.connection_library[connection_manager] = connection_manager( - system_info=self.system_info.model_copy(), + system_info=self.system_info, logger=self.logger, connection_args=connection_args, task_result_hooks=self.connection_result_hooks, From 5a93be8d0d3deb1179d0e812c382c3a838cad168 Mon Sep 17 00:00:00 2001 From: Ben Price Date: Mon, 31 Aug 2026 17:13:13 -0400 Subject: [PATCH 3/3] Address pre-commit issues --- nodescraper/models/postactioncondition.py | 8 ++++---- test/unit/framework/test_post_action_condition.py | 6 ++++-- test/unit/framework/test_post_action_plugin_config.py | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/nodescraper/models/postactioncondition.py b/nodescraper/models/postactioncondition.py index 357909c2..f6e04b42 100644 --- a/nodescraper/models/postactioncondition.py +++ b/nodescraper/models/postactioncondition.py @@ -99,10 +99,10 @@ def _matches_result(self, result: PluginResult) -> bool: # --- status check --- if self.status is not None: try: - threshold = ExecutionStatus[self.status.upper()] + status_threshold = ExecutionStatus[self.status.upper()] except KeyError: return False - if result.status < threshold: + if result.status < status_threshold: return False # Remaining checks all operate on events; collect them once. @@ -117,10 +117,10 @@ def _matches_result(self, result: PluginResult) -> bool: # --- event_priority check --- if self.event_priority is not None: try: - threshold = EventPriority[self.event_priority.upper()] + priority_threshold = EventPriority[self.event_priority.upper()] except KeyError: return False - if not any(e.priority >= threshold for e in events): + if not any(e.priority >= priority_threshold for e in events): return False # --- event_description_contains check --- diff --git a/test/unit/framework/test_post_action_condition.py b/test/unit/framework/test_post_action_condition.py index fca7fc1c..4a454bbd 100644 --- a/test/unit/framework/test_post_action_condition.py +++ b/test/unit/framework/test_post_action_condition.py @@ -24,6 +24,8 @@ # ############################################################################### """Unit tests for PostActionCondition matching logic.""" +from typing import Union + import pytest from nodescraper.enums import EventPriority, ExecutionStatus @@ -51,8 +53,8 @@ def _make_event( def _make_result( source: str = "SomePlugin", status: ExecutionStatus = ExecutionStatus.OK, - analysis_events: list[Event] | None = None, - collection_events: list[Event] | None = None, + analysis_events: Union[list[Event], None] = None, + collection_events: Union[list[Event], None] = None, ) -> PluginResult: """Build a PluginResult with optional events in analysis and/or collection results.""" return PluginResult( diff --git a/test/unit/framework/test_post_action_plugin_config.py b/test/unit/framework/test_post_action_plugin_config.py index b555a648..c34f1897 100644 --- a/test/unit/framework/test_post_action_plugin_config.py +++ b/test/unit/framework/test_post_action_plugin_config.py @@ -24,6 +24,7 @@ # ############################################################################### """Unit tests for PostActionPluginConfig.should_run OR semantics.""" +from typing import Union from nodescraper.enums import ExecutionStatus from nodescraper.models import PluginResult @@ -39,7 +40,7 @@ def _make_result(source: str, status: ExecutionStatus) -> PluginResult: return PluginResult(status=status, source=source) -def _cond(status: str, plugin: str | None = None) -> PostActionCondition: +def _cond(status: str, plugin: Union[str, None] = None) -> PostActionCondition: """Shorthand: a condition that fires when *source* has at least *status*.""" return PostActionCondition(plugin=plugin, status=status)