feat: add event-driven chaos triggers with command trigger (Phase 1) (#1484)

* feat: add event-driven chaos triggers with command trigger (Phase 1)

Adds a pluggable trigger system that gates chaos injection on
user-defined preconditions. A top-level `triggers` block in
config.yaml is evaluated before the chaos loop, with configurable
polling mode (all_of/any_of), timeout, interval, and on_timeout
behavior (skip/fail/run_anyway).

Phase 1 implements the command trigger type which runs a shell
command and checks its exit code.

- AbstractTrigger base class for pluggable trigger types
- CommandTrigger with input validation (expected_rc int coercion)
- TriggerManager with positive-number validation for timeout/interval
- Trigger evaluation runs before health check plugins start
- Broad exception handling in evaluate() prevents UnboundLocalError
- Debug logging of command returncode/stderr for observability
- 33 unit tests covering all functionality and edge cases

Closes #1483

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: ddjain <darjain@redhat.com>

* fix: address review feedback on trigger validation

- Validate conditions is a list, not a string
- Expose on_timeout as a property, use it in run_kraken.py
  instead of re-reading raw config
- Validate expected_rc is in 0-255 Unix range
- Add 4 new tests (37 trigger tests total)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: ddjain <darjain@redhat.com>

---------

Signed-off-by: ddjain <darjain@redhat.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Darshan Jain
2026-07-16 20:42:40 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent 99870a2fc7
commit f62d2e1c36
8 changed files with 888 additions and 0 deletions
@@ -0,0 +1,18 @@
# Copyright 2025 The Krkn Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
from krkn.scenario_plugins.triggers.command_trigger import CommandTrigger
from krkn.scenario_plugins.triggers.trigger_manager import TriggerManager
__all__ = ["AbstractTrigger", "CommandTrigger", "TriggerManager"]
@@ -0,0 +1,28 @@
# Copyright 2025 The Krkn Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from abc import ABC, abstractmethod
class AbstractTrigger(ABC):
"""Base class for all trigger types."""
@abstractmethod
def evaluate(self) -> bool:
"""Returns True if the trigger condition is met."""
pass
@abstractmethod
def describe(self) -> str:
"""Human-readable description for logging."""
pass
@@ -0,0 +1,95 @@
# Copyright 2025 The Krkn Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import subprocess
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
COMMAND_TIMEOUT_SECONDS = 30
class CommandTrigger(AbstractTrigger):
"""Trigger that runs a shell command and checks its exit code."""
def __init__(self, config: dict):
self._cmd = config.get("cmd") or config.get("inline")
if not self._cmd:
raise ValueError(
"command trigger requires either 'cmd' or 'inline' field"
)
try:
self._expected_rc = int(config.get("expected_rc", 0))
except (TypeError, ValueError):
raise ValueError(
f"expected_rc must be an integer, got {config.get('expected_rc')!r}"
)
if not 0 <= self._expected_rc <= 255:
raise ValueError(
f"expected_rc must be between 0 and 255, got {self._expected_rc}"
)
self._last_result: bool | None = None
def evaluate(self) -> bool:
try:
result = subprocess.run(
self._cmd,
shell=True,
timeout=COMMAND_TIMEOUT_SECONDS,
capture_output=True,
text=True,
)
met = result.returncode == self._expected_rc
logging.debug(
f"command trigger: rc={result.returncode} "
f"expected={self._expected_rc} cmd='{self._cmd}'"
)
if result.stderr:
logging.debug(
f"command trigger stderr: {result.stderr.strip()}"
)
except subprocess.TimeoutExpired:
logging.warning(
f"command trigger timed out after {COMMAND_TIMEOUT_SECONDS}s: "
f"{self._cmd}"
)
met = False
except FileNotFoundError:
logging.error(
f"command trigger binary not found: {self._cmd}"
)
met = False
except Exception as e:
logging.error(
f"command trigger unexpected error: {e}: {self._cmd}"
)
met = False
# Log only on state change
if met != self._last_result:
if met:
logging.info(
f"trigger condition satisfied: {self._cmd}"
)
else:
logging.info(
f"trigger condition not satisfied: {self._cmd}"
)
self._last_result = met
return met
def describe(self) -> str:
return (
f"command trigger: '{self._cmd}' "
f"(expected rc={self._expected_rc})"
)
@@ -0,0 +1,169 @@
# Copyright 2025 The Krkn Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import time
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
from krkn.scenario_plugins.triggers.command_trigger import CommandTrigger
VALID_MODES = {"all_of", "any_of"}
VALID_ON_TIMEOUT = {"skip", "fail", "run_anyway"}
DEFAULT_TIMEOUT = 300
DEFAULT_INTERVAL = 5
DEFAULT_MODE = "all_of"
DEFAULT_ON_TIMEOUT = "skip"
class TriggerManager:
"""Orchestrates polling across multiple triggers."""
def __init__(self, trigger_config: dict):
conditions = trigger_config.get("conditions")
if not conditions:
raise ValueError(
"trigger config must include a non-empty 'conditions' list"
)
if not isinstance(conditions, list):
raise ValueError(
"trigger 'conditions' must be a list, "
f"got {type(conditions).__name__}"
)
self._mode = trigger_config.get("mode", DEFAULT_MODE)
if self._mode not in VALID_MODES:
raise ValueError(
f"invalid trigger mode '{self._mode}', "
f"must be one of: {', '.join(sorted(VALID_MODES))}"
)
self._on_timeout = trigger_config.get("on_timeout", DEFAULT_ON_TIMEOUT)
if self._on_timeout not in VALID_ON_TIMEOUT:
raise ValueError(
f"invalid on_timeout '{self._on_timeout}', "
f"must be one of: {', '.join(sorted(VALID_ON_TIMEOUT))}"
)
self._timeout = trigger_config.get("timeout", DEFAULT_TIMEOUT)
self._interval = trigger_config.get("interval", DEFAULT_INTERVAL)
try:
self._timeout = float(self._timeout)
self._interval = float(self._interval)
except (TypeError, ValueError):
raise ValueError(
f"timeout and interval must be numeric, "
f"got timeout={trigger_config.get('timeout')!r}, "
f"interval={trigger_config.get('interval')!r}"
)
if self._timeout <= 0:
raise ValueError(
f"timeout must be positive, got {self._timeout}"
)
if self._interval <= 0:
raise ValueError(
f"interval must be positive, got {self._interval}"
)
self._triggers: list[AbstractTrigger] = []
for condition in trigger_config["conditions"]:
self._triggers.append(self._build_trigger(condition))
# Track per-trigger satisfaction state for get_status
self._trigger_states: list[bool | None] = [None] * len(self._triggers)
@property
def on_timeout(self) -> str:
return self._on_timeout
@staticmethod
def _build_trigger(condition_config: dict) -> AbstractTrigger:
"""Factory method that creates a trigger from a condition config."""
trigger_type = condition_config.get("type")
if not trigger_type:
raise ValueError("each condition must have a 'type' field")
if trigger_type == "command":
return CommandTrigger(condition_config)
raise ValueError(f"unknown trigger type: '{trigger_type}'")
def wait_for_triggers(self) -> bool:
"""Polls triggers until conditions are met or timeout expires.
Returns True if conditions were met, False if timed out.
"""
logging.info(
f"waiting for triggers: mode={self._mode}, "
f"timeout={self._timeout}s, interval={self._interval}s, "
f"on_timeout={self._on_timeout}"
)
deadline = time.monotonic() + self._timeout
while time.monotonic() < deadline:
results = []
for i, trigger in enumerate(self._triggers):
result = trigger.evaluate()
self._trigger_states[i] = result
results.append(result)
logging.debug(
f"trigger poll: {[r for r in results]}"
)
if self._mode == "all_of" and all(results):
logging.info("all trigger conditions satisfied")
return True
elif self._mode == "any_of" and any(results):
logging.info("at least one trigger condition satisfied")
return True
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(self._interval, remaining))
logging.warning(
f"triggers timed out after {self._timeout}s "
f"(on_timeout={self._on_timeout})"
)
return False
def describe(self) -> str:
"""Human-readable summary of all triggers."""
parts = [
f"TriggerManager(mode={self._mode}, "
f"timeout={self._timeout}s, interval={self._interval}s, "
f"on_timeout={self._on_timeout})",
]
for i, trigger in enumerate(self._triggers):
parts.append(f" [{i}] {trigger.describe()}")
return "\n".join(parts)
def get_status(self) -> dict:
"""Returns current state of each trigger for the signal server."""
return {
"mode": self._mode,
"timeout": self._timeout,
"interval": self._interval,
"on_timeout": self._on_timeout,
"triggers": [
{
"description": trigger.describe(),
"satisfied": self._trigger_states[i],
}
for i, trigger in enumerate(self._triggers)
],
}
+31
View File
@@ -66,6 +66,7 @@ from krkn.rollback.command import (
list_rollback as list_rollback_command,
execute_rollback as execute_rollback_command,
)
from krkn.scenario_plugins.triggers.trigger_manager import TriggerManager
# removes TripleDES warning
import warnings
@@ -412,6 +413,36 @@ def main(options, command: Optional[str]) -> int:
logging.error(f"⛔ Class: {class_name} Module: {module_name}")
logging.error(f"⚠️ {error}\n")
# Evaluate top-level triggers before starting health checks or chaos
trigger_config = config.get("triggers")
if trigger_config:
try:
trigger_manager = TriggerManager(trigger_config)
logging.info(
"waiting for triggers before starting chaos:\n%s",
trigger_manager.describe(),
)
triggered = trigger_manager.wait_for_triggers()
if not triggered:
on_timeout = trigger_manager.on_timeout
if on_timeout == "skip":
logging.warning(
"trigger timed out, skipping all scenarios"
)
chaos_scenarios = []
elif on_timeout == "fail":
logging.error(
"trigger timed out, exiting with failure"
)
return 1
else:
logging.warning(
"trigger timed out, running scenarios anyway"
)
except ValueError as e:
logging.error("invalid trigger configuration: %s", e)
return 1
# Start all health check plugins discovered via config_key_map.
# Returns list of (plugin, worker_thread, telemetry_queue);
# worker_thread is None for self-threading plugins (e.g. virt).
View File
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Test suite for CommandTrigger class
Usage:
python -m coverage run -a -m unittest tests/test_triggers/test_command_trigger.py -v
Assisted By: Claude Code
"""
import subprocess
import unittest
from unittest.mock import MagicMock, patch
from krkn.scenario_plugins.triggers.command_trigger import (
COMMAND_TIMEOUT_SECONDS,
CommandTrigger,
)
class TestCommandTrigger(unittest.TestCase):
def _make_trigger(self, **overrides):
"""Build a CommandTrigger with sensible defaults."""
config = {"cmd": "echo hello", "expected_rc": 0}
config.update(overrides)
return CommandTrigger(config)
# ------------------------------------------------------------------
# evaluate() tests
# ------------------------------------------------------------------
@patch("krkn.scenario_plugins.triggers.command_trigger.subprocess.run")
def test_evaluate_success(self, mock_run):
"""Command exits 0, expected_rc=0 -> returns True."""
mock_run.return_value = MagicMock(returncode=0)
trigger = self._make_trigger(cmd="echo ok", expected_rc=0)
self.assertTrue(trigger.evaluate())
mock_run.assert_called_once_with(
"echo ok",
shell=True,
timeout=COMMAND_TIMEOUT_SECONDS,
capture_output=True,
text=True,
)
@patch("krkn.scenario_plugins.triggers.command_trigger.subprocess.run")
def test_evaluate_failure(self, mock_run):
"""Command exits 1, expected_rc=0 -> returns False."""
mock_run.return_value = MagicMock(returncode=1)
trigger = self._make_trigger(cmd="false", expected_rc=0)
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.command_trigger.subprocess.run")
def test_evaluate_custom_expected_rc(self, mock_run):
"""Command exits 42, expected_rc=42 -> returns True."""
mock_run.return_value = MagicMock(returncode=42)
trigger = self._make_trigger(cmd="exit 42", expected_rc=42)
self.assertTrue(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.command_trigger.subprocess.run")
def test_evaluate_inline_command(self, mock_run):
"""Uses 'inline' field instead of 'cmd'."""
mock_run.return_value = MagicMock(returncode=0)
trigger = CommandTrigger({"inline": "date"})
self.assertTrue(trigger.evaluate())
mock_run.assert_called_once_with(
"date",
shell=True,
timeout=COMMAND_TIMEOUT_SECONDS,
capture_output=True,
text=True,
)
@patch("krkn.scenario_plugins.triggers.command_trigger.subprocess.run")
def test_cmd_takes_precedence_over_inline(self, mock_run):
"""Both 'cmd' and 'inline' provided; 'cmd' is used."""
mock_run.return_value = MagicMock(returncode=0)
trigger = CommandTrigger({"cmd": "use-this", "inline": "not-this"})
trigger.evaluate()
args, _ = mock_run.call_args
self.assertEqual(args[0], "use-this")
@patch("krkn.scenario_plugins.triggers.command_trigger.subprocess.run")
def test_evaluate_timeout(self, mock_run):
"""Command hangs, subprocess times out -> returns False."""
mock_run.side_effect = subprocess.TimeoutExpired(
cmd="sleep 999", timeout=COMMAND_TIMEOUT_SECONDS
)
trigger = self._make_trigger(cmd="sleep 999")
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.command_trigger.subprocess.run")
def test_evaluate_command_not_found(self, mock_run):
"""Binary does not exist -> returns False."""
mock_run.side_effect = FileNotFoundError("No such file")
trigger = self._make_trigger(cmd="/nonexistent/binary")
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.command_trigger.subprocess.run")
def test_evaluate_unexpected_exception(self, mock_run):
"""Unexpected exception (e.g. PermissionError) -> returns False, no crash."""
mock_run.side_effect = PermissionError("Permission denied")
trigger = self._make_trigger(cmd="/restricted/script.sh")
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.command_trigger.subprocess.run")
def test_evaluate_default_expected_rc(self, mock_run):
"""No expected_rc in config -> defaults to 0."""
mock_run.return_value = MagicMock(returncode=0)
trigger = CommandTrigger({"cmd": "true"})
self.assertTrue(trigger.evaluate())
self.assertEqual(trigger._expected_rc, 0)
# ------------------------------------------------------------------
# describe() tests
# ------------------------------------------------------------------
def test_describe(self):
"""Returns a meaningful description string."""
trigger = self._make_trigger(cmd="check_service.sh", expected_rc=0)
description = trigger.describe()
self.assertIn("check_service.sh", description)
self.assertIn("0", description)
self.assertIsInstance(description, str)
self.assertTrue(len(description) > 0)
# ------------------------------------------------------------------
# Validation tests
# ------------------------------------------------------------------
def test_missing_cmd_and_inline(self):
"""Neither 'cmd' nor 'inline' provided -> raises ValueError."""
with self.assertRaises(ValueError) as ctx:
CommandTrigger({})
self.assertIn("cmd", str(ctx.exception).lower())
self.assertIn("inline", str(ctx.exception).lower())
def test_expected_rc_string_coerced_to_int(self):
"""expected_rc='0' (string from envsubst) -> coerced to int 0."""
trigger = CommandTrigger({"cmd": "true", "expected_rc": "0"})
self.assertEqual(trigger._expected_rc, 0)
self.assertIsInstance(trigger._expected_rc, int)
def test_expected_rc_invalid_raises(self):
"""expected_rc='abc' -> raises ValueError."""
with self.assertRaises(ValueError) as ctx:
CommandTrigger({"cmd": "true", "expected_rc": "abc"})
self.assertIn("expected_rc", str(ctx.exception))
def test_expected_rc_above_255_raises(self):
"""expected_rc=999 -> raises ValueError (Unix rc range is 0-255)."""
with self.assertRaises(ValueError) as ctx:
CommandTrigger({"cmd": "true", "expected_rc": 999})
self.assertIn("255", str(ctx.exception))
def test_expected_rc_negative_raises(self):
"""expected_rc=-1 -> raises ValueError."""
with self.assertRaises(ValueError) as ctx:
CommandTrigger({"cmd": "true", "expected_rc": -1})
self.assertIn("expected_rc", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""
Test suite for TriggerManager class
Usage:
python -m coverage run -a -m unittest tests/test_triggers/test_trigger_manager.py -v
Assisted By: Claude Code
"""
import unittest
from unittest.mock import patch
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
from krkn.scenario_plugins.triggers.trigger_manager import (
DEFAULT_INTERVAL,
DEFAULT_MODE,
DEFAULT_ON_TIMEOUT,
DEFAULT_TIMEOUT,
TriggerManager,
)
class StubTrigger(AbstractTrigger):
"""Concrete trigger for testing that returns a preconfigured value."""
def __init__(self, result: bool, name: str = "stub"):
self._result = result
self._name = name
def evaluate(self) -> bool:
return self._result
def describe(self) -> str:
return f"stub trigger '{self._name}' (result={self._result})"
def set_result(self, result: bool):
self._result = result
def _make_config(**overrides):
"""Build a minimal valid trigger config dict with overrides."""
config = {
"mode": "all_of",
"timeout": 10,
"interval": 1,
"on_timeout": "skip",
"conditions": [
{"type": "command", "cmd": "true"},
],
}
config.update(overrides)
return config
class TestTriggerManager(unittest.TestCase):
# ------------------------------------------------------------------
# wait_for_triggers() — mode tests
# ------------------------------------------------------------------
@patch.object(TriggerManager, "_build_trigger")
def test_all_of_all_pass(self, mock_build):
"""mode=all_of, all triggers pass -> returns True."""
t1 = StubTrigger(True, "t1")
t2 = StubTrigger(True, "t2")
mock_build.side_effect = [t1, t2]
config = _make_config(
mode="all_of",
conditions=[
{"type": "command", "cmd": "a"},
{"type": "command", "cmd": "b"},
],
)
manager = TriggerManager(config)
self.assertTrue(manager.wait_for_triggers())
@patch("krkn.scenario_plugins.triggers.trigger_manager.time")
@patch.object(TriggerManager, "_build_trigger")
def test_all_of_one_fails(self, mock_build, mock_time):
"""mode=all_of, one trigger stays False -> loops until timeout, returns False."""
t1 = StubTrigger(True, "t1")
t2 = StubTrigger(False, "t2")
mock_build.side_effect = [t1, t2]
# Simulate: first call returns start time, subsequent calls advance past deadline
call_count = 0
def advancing_monotonic():
nonlocal call_count
call_count += 1
if call_count <= 2:
return 0.0
return 999.0 # past deadline
mock_time.monotonic.side_effect = advancing_monotonic
mock_time.sleep = lambda x: None
config = _make_config(
mode="all_of",
timeout=10,
conditions=[
{"type": "command", "cmd": "a"},
{"type": "command", "cmd": "b"},
],
)
manager = TriggerManager(config)
self.assertFalse(manager.wait_for_triggers())
@patch.object(TriggerManager, "_build_trigger")
def test_any_of_one_passes(self, mock_build):
"""mode=any_of, one trigger passes -> returns True immediately."""
t1 = StubTrigger(False, "t1")
t2 = StubTrigger(True, "t2")
mock_build.side_effect = [t1, t2]
config = _make_config(
mode="any_of",
conditions=[
{"type": "command", "cmd": "a"},
{"type": "command", "cmd": "b"},
],
)
manager = TriggerManager(config)
self.assertTrue(manager.wait_for_triggers())
# ------------------------------------------------------------------
# wait_for_triggers() — timeout tests
# ------------------------------------------------------------------
@patch("krkn.scenario_plugins.triggers.trigger_manager.time")
@patch.object(TriggerManager, "_build_trigger")
def test_timeout_returns_false(self, mock_build, mock_time):
"""Triggers never pass, timeout expires -> returns False."""
t1 = StubTrigger(False, "t1")
mock_build.side_effect = [t1]
call_count = 0
def advancing_monotonic():
nonlocal call_count
call_count += 1
if call_count <= 2:
return 0.0
return 999.0
mock_time.monotonic.side_effect = advancing_monotonic
mock_time.sleep = lambda x: None
config = _make_config(
timeout=10,
conditions=[{"type": "command", "cmd": "a"}],
)
manager = TriggerManager(config)
self.assertFalse(manager.wait_for_triggers())
# ------------------------------------------------------------------
# Default values
# ------------------------------------------------------------------
@patch.object(TriggerManager, "_build_trigger")
def test_default_timeout(self, mock_build):
"""No timeout in config -> defaults to 300."""
mock_build.return_value = StubTrigger(True)
config = {
"conditions": [{"type": "command", "cmd": "true"}],
}
manager = TriggerManager(config)
self.assertEqual(manager._timeout, DEFAULT_TIMEOUT)
self.assertEqual(manager._timeout, 300)
@patch.object(TriggerManager, "_build_trigger")
def test_default_interval(self, mock_build):
"""No interval in config -> defaults to 5."""
mock_build.return_value = StubTrigger(True)
config = {
"conditions": [{"type": "command", "cmd": "true"}],
}
manager = TriggerManager(config)
self.assertEqual(manager._interval, DEFAULT_INTERVAL)
self.assertEqual(manager._interval, 5)
@patch.object(TriggerManager, "_build_trigger")
def test_default_mode(self, mock_build):
"""No mode in config -> defaults to all_of."""
mock_build.return_value = StubTrigger(True)
config = {
"conditions": [{"type": "command", "cmd": "true"}],
}
manager = TriggerManager(config)
self.assertEqual(manager._mode, DEFAULT_MODE)
self.assertEqual(manager._mode, "all_of")
@patch.object(TriggerManager, "_build_trigger")
def test_default_on_timeout(self, mock_build):
"""No on_timeout in config -> defaults to skip."""
mock_build.return_value = StubTrigger(True)
config = {
"conditions": [{"type": "command", "cmd": "true"}],
}
manager = TriggerManager(config)
self.assertEqual(manager._on_timeout, DEFAULT_ON_TIMEOUT)
self.assertEqual(manager._on_timeout, "skip")
# ------------------------------------------------------------------
# Validation tests
# ------------------------------------------------------------------
def test_invalid_mode_raises(self):
"""mode='invalid' -> raises ValueError."""
config = _make_config(mode="invalid")
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("invalid", str(ctx.exception))
def test_missing_conditions_raises(self):
"""No 'conditions' key -> raises ValueError."""
config = {"mode": "all_of", "timeout": 10}
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("conditions", str(ctx.exception))
def test_empty_conditions_raises(self):
"""conditions=[] -> raises ValueError."""
config = _make_config(conditions=[])
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("conditions", str(ctx.exception))
def test_conditions_not_a_list_raises(self):
"""conditions='some string' -> raises ValueError."""
config = _make_config(conditions="kubectl check something")
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("list", str(ctx.exception))
def test_on_timeout_property(self):
"""on_timeout property returns validated value."""
mock_build = patch.object(TriggerManager, "_build_trigger").start()
mock_build.return_value = StubTrigger(True)
config = _make_config(on_timeout="fail")
manager = TriggerManager(config)
self.assertEqual(manager.on_timeout, "fail")
patch.stopall()
def test_unknown_trigger_type_raises(self):
"""type='kafka' -> raises ValueError."""
config = _make_config(
conditions=[{"type": "kafka", "topic": "events"}],
)
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("kafka", str(ctx.exception))
def test_negative_timeout_raises(self):
"""timeout=-1 -> raises ValueError."""
config = _make_config(timeout=-1)
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("timeout", str(ctx.exception))
def test_zero_timeout_raises(self):
"""timeout=0 -> raises ValueError."""
config = _make_config(timeout=0)
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("timeout", str(ctx.exception))
def test_negative_interval_raises(self):
"""interval=-1 -> raises ValueError."""
config = _make_config(interval=-1)
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("interval", str(ctx.exception))
def test_zero_interval_raises(self):
"""interval=0 -> raises ValueError."""
config = _make_config(interval=0)
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("interval", str(ctx.exception))
def test_string_timeout_raises(self):
"""timeout='abc' -> raises ValueError."""
config = _make_config(timeout="abc")
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("numeric", str(ctx.exception))
def test_string_interval_raises(self):
"""interval='xyz' -> raises ValueError."""
config = _make_config(interval="xyz")
with self.assertRaises(ValueError) as ctx:
TriggerManager(config)
self.assertIn("numeric", str(ctx.exception))
# ------------------------------------------------------------------
# get_status() tests
# ------------------------------------------------------------------
@patch.object(TriggerManager, "_build_trigger")
def test_get_status(self, mock_build):
"""get_status returns dict with trigger states."""
t1 = StubTrigger(True, "t1")
t2 = StubTrigger(False, "t2")
mock_build.side_effect = [t1, t2]
config = _make_config(
mode="any_of",
timeout=60,
interval=2,
on_timeout="fail",
conditions=[
{"type": "command", "cmd": "a"},
{"type": "command", "cmd": "b"},
],
)
manager = TriggerManager(config)
status = manager.get_status()
self.assertEqual(status["mode"], "any_of")
self.assertEqual(status["timeout"], 60)
self.assertEqual(status["interval"], 2)
self.assertEqual(status["on_timeout"], "fail")
self.assertEqual(len(status["triggers"]), 2)
# Before any evaluation, states should be None
for trigger_info in status["triggers"]:
self.assertIn("description", trigger_info)
self.assertIn("satisfied", trigger_info)
self.assertIsNone(trigger_info["satisfied"])
# ------------------------------------------------------------------
# describe() tests
# ------------------------------------------------------------------
@patch.object(TriggerManager, "_build_trigger")
def test_describe(self, mock_build):
"""describe returns human-readable summary."""
t1 = StubTrigger(True, "t1")
t2 = StubTrigger(False, "t2")
mock_build.side_effect = [t1, t2]
config = _make_config(
mode="all_of",
timeout=30,
interval=3,
on_timeout="skip",
conditions=[
{"type": "command", "cmd": "a"},
{"type": "command", "cmd": "b"},
],
)
manager = TriggerManager(config)
desc = manager.describe()
self.assertIsInstance(desc, str)
self.assertIn("all_of", desc)
self.assertIn("30", desc)
self.assertIn("3", desc)
self.assertIn("skip", desc)
# Should include each trigger's description
self.assertIn("t1", desc)
self.assertIn("t2", desc)
if __name__ == "__main__":
unittest.main()