Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "<PluginName>",
"plugin_args": { ... },
"conditions": [
{ "<field>": "<value>", ... },
{ "<field>": "<value>", ... }
]
}
]
}
```

- **`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
Expand Down
4 changes: 4 additions & 0 deletions nodescraper/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -51,6 +53,8 @@
"PluginResult",
"DataPluginResult",
"PluginConfig",
"PostActionCondition",
"PostActionPluginConfig",
"NO_CHANGE",
"PriorityOverrideRule",
"apply_priority_override_rules",
Expand Down
3 changes: 3 additions & 0 deletions nodescraper/models/pluginconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@

from pydantic import BaseModel, Field

from nodescraper.models.postactionpluginconfig import PostActionPluginConfig


class PluginConfig(BaseModel):
"""Model for preset configuration of plugins and result collators"""

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

Expand Down
151 changes: 151 additions & 0 deletions nodescraper/models/postactioncondition.py
Original file line number Diff line number Diff line change
@@ -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:
status_threshold = ExecutionStatus[self.status.upper()]
except KeyError:
return False
if result.status < 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:
priority_threshold = EventPriority[self.event_priority.upper()]
except KeyError:
return False
if not any(e.priority >= 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)
96 changes: 96 additions & 0 deletions nodescraper/models/postactionpluginconfig.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading