Skip to content
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
7 changes: 7 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 All @@ -51,18 +54,22 @@ def merge(cls, *configs: PluginConfig | dict[str, Any]) -> PluginConfig:
"""Merge recipe plugin configs.

Plugin entries from later configs overwrite earlier ones with the same name.
``post_action_plugins`` are concatenated from all configs in order.
``name``, ``desc``, ``global_args``, and ``result_collators`` come from the first
config.
"""
normalized = [cls.coerce(config) for config in configs]
merged_plugins: dict[str, dict[str, Any]] = {}
merged_post_actions = []
for config in normalized:
merged_plugins.update(config.plugins)
merged_post_actions.extend(config.post_action_plugins)
first = normalized[0] if normalized else cls()
return cls(
name=first.name,
desc=first.desc,
global_args=first.global_args,
plugins=merged_plugins,
result_collators=first.result_collators,
post_action_plugins=merged_post_actions,
)
173 changes: 173 additions & 0 deletions nodescraper/models/postactioncondition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
###############################################################################
#
# MIT License
#
# Copyright (c) 2026 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, Any, Optional

from pydantic import BaseModel, field_validator

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[ExecutionStatus] = None
"""If set, the result's ExecutionStatus must be >= this value.
Accepts an :class:`~nodescraper.enums.ExecutionStatus` member or its name as
a string (e.g. ``"WARNING"``, ``"ERROR"``, ``"EXECUTION_FAILURE"``)."""

event_category: Optional[str] = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should also be EventPriority not str

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming you meant event_priority with this comment, which is fixed in 3cc2d56

"""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[EventPriority] = None
"""If set, at least one event's priority must be >= this value.
Accepts an :class:`~nodescraper.enums.EventPriority` member or its name as
a string (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)."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the types are fixed, i recommend you add these too:

    @field_validator("status", mode="before")
    @classmethod
    def _coerce_status(cls, v):
        # mirror TaskResult.validate_status
        ...
    @field_validator("event_priority", mode="before")
    @classmethod
    def _coerce_priority(cls, v):
        # mirror Event.validate_priority (or import shared helper)
        ...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3cc2d56

# ------------------------------------------------------------------
# Field validators — allow string names from JSON configs
# ------------------------------------------------------------------

@field_validator("status", mode="before")
@classmethod
def validate_status(cls, v: Any) -> Optional[ExecutionStatus]:
"""Accept an ExecutionStatus member or its name string."""
if v is None or isinstance(v, ExecutionStatus):
return v
if isinstance(v, str):
try:
return ExecutionStatus[v.upper()]
except KeyError as e:
raise ValueError(f"Unknown ExecutionStatus name: {v!r}") from e
return v

@field_validator("event_priority", mode="before")
@classmethod
def validate_event_priority(cls, v: Any) -> Optional[EventPriority]:
"""Accept an EventPriority member or its name string."""
if v is None or isinstance(v, EventPriority):
return v
if isinstance(v, str):
try:
return EventPriority[v.upper()]
except KeyError as e:
raise ValueError(f"Unknown EventPriority name: {v!r}") from e
return v

# ------------------------------------------------------------------
# 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:
if result.status < self.status:
return False
Comment on lines +124 to +132

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

once the correct type is set for status this function can probably just be:

Suggested change
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
def _matches_result(self, result: PluginResult) -> bool:
"""Return True if *result* satisfies all specified fields (AND logic).
if self.status is not None and result.status < self.status:
return False

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3cc2d56


# 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:
if not any(e.priority >= self.event_priority 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)
Loading
Loading