feat: add http trigger type for event-driven chaos (#1504)

* feat: add http trigger type for event-driven chaos

Adds an http trigger type so krkn can wait for an HTTP endpoint to return
an expected status before chaos injection. Completes the trigger types
on top of the Phase 1 framework (#1484).

- krkn/scenario_plugins/triggers/http_trigger.py: implements HttpTrigger
- krkn/scenario_plugins/triggers/trigger_manager.py: registers "http" type
- krkn/scenario_plugins/triggers/__init__.py: exports HttpTrigger
- tests/test_triggers/test_http_trigger.py: 30 tests for HttpTrigger
- scenarios/kube/trigger_http_example.yaml: example configuration

Closes krkn-chaos/krkn#1498

Signed-off-by: swagatobauri <swagato731123@gmail.com>

* fix(triggers): address code review feedback for http trigger

- Add type validation for body_contains in http_trigger.py
- Use mock_info.assert_not_called() for strict logging assertions in tests

Signed-off-by: swagatobauri <swagato731123@gmail.com>

* feat(krknctl): add http trigger arguments to krknctl input fields

Wire HTTP triggers into krknctl-input.json so they are exposed as CLI flags
by krknctl.

Related to krkn-chaos/krkn#1498

Signed-off-by: swagatobauri <swagato731123@gmail.com>

* test(triggers): add factory wiring and invalid body_contains tests

Signed-off-by: swagatobauri <swagato731123@gmail.com>

* fix(triggers): move trigger_http_example.yaml to config directory per review

Signed-off-by: swagatobauri <swagato731123@gmail.com>

* fix(triggers): move HttpTrigger import to top of file per review

Signed-off-by: swagatobauri <swagato731123@gmail.com>

---------

Signed-off-by: swagatobauri <swagato731123@gmail.com>
Co-authored-by: Darshan Jain <darjain@redhat.com>
This commit is contained in:
SWAGATO BAURI
2026-07-27 13:09:37 +05:30
committed by GitHub
co-authored by Darshan Jain
parent be88742816
commit 06954dd648
7 changed files with 560 additions and 10 deletions
+15
View File
@@ -0,0 +1,15 @@
kraken:
chaos_scenarios:
- pod_disruption_scenarios:
- scenarios/kube/pod.yml
triggers:
mode: all_of
timeout: 300
interval: 5
on_timeout: skip
conditions:
- type: http
url: "http://nginx.default.svc:8080/health"
method: GET
expected_status: 200
+51
View File
@@ -706,5 +706,56 @@
"default": "skip",
"required": "false",
"group": "triggers"
},
{
"name": "trigger-http-url",
"short_description": "Trigger HTTP URL",
"description": "URL to poll for the HTTP trigger",
"variable": "TRIGGER_HTTP_URL",
"type": "string",
"default": "",
"required": "false",
"group": "triggers"
},
{
"name": "trigger-http-method",
"short_description": "Trigger HTTP Method",
"description": "HTTP method to use (GET, POST, etc.)",
"variable": "TRIGGER_HTTP_METHOD",
"type": "string",
"default": "GET",
"required": "false",
"group": "triggers"
},
{
"name": "trigger-http-expected-status",
"short_description": "Trigger HTTP Expected Status",
"description": "Expected HTTP status code",
"variable": "TRIGGER_HTTP_EXPECTED_STATUS",
"type": "number",
"default": "200",
"required": "false",
"group": "triggers"
},
{
"name": "trigger-http-bearer-token",
"short_description": "Trigger HTTP Bearer Token",
"description": "Bearer token for HTTP authentication",
"variable": "TRIGGER_HTTP_BEARER_TOKEN",
"type": "string",
"default": "",
"required": "false",
"secret": "true",
"group": "triggers"
},
{
"name": "trigger-http-body-contains",
"short_description": "Trigger HTTP Body Contains",
"description": "Substring expected in the HTTP response body",
"variable": "TRIGGER_HTTP_BODY_CONTAINS",
"type": "string",
"default": "",
"required": "false",
"group": "triggers"
}
]
+2 -1
View File
@@ -13,6 +13,7 @@
# 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.http_trigger import HttpTrigger
from krkn.scenario_plugins.triggers.trigger_manager import TriggerManager
__all__ = ["AbstractTrigger", "CommandTrigger", "TriggerManager"]
__all__ = ["AbstractTrigger", "CommandTrigger", "HttpTrigger", "TriggerManager"]
@@ -0,0 +1,117 @@
# 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 requests
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
HTTP_REQUEST_TIMEOUT_SECONDS = 30
VALID_METHODS = {"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"}
class HttpTrigger(AbstractTrigger):
"""Trigger that polls an HTTP endpoint and checks its response status."""
def __init__(self, config: dict):
self._url = config.get("url")
if not self._url:
raise ValueError("http trigger requires a 'url' field")
method = config.get("method", "GET")
self._method = str(method).upper()
if self._method not in VALID_METHODS:
raise ValueError(
f"http trigger method must be one of "
f"{', '.join(sorted(VALID_METHODS))}, got {self._method!r}"
)
try:
self._expected_status = int(config.get("expected_status", 200))
except (TypeError, ValueError):
raise ValueError(
f"expected_status must be an integer, "
f"got {config.get('expected_status')!r}"
)
if not 100 <= self._expected_status <= 599:
raise ValueError(
f"expected_status must be a valid HTTP status code (100-599), "
f"got {self._expected_status}"
)
self._headers: dict = dict(config.get("headers") or {})
bearer_token = config.get("bearer_token")
if bearer_token:
self._headers["Authorization"] = f"Bearer {bearer_token}"
body_contains = config.get("body_contains")
if body_contains is not None and not isinstance(body_contains, str):
raise ValueError(
f"body_contains must be a string, got {type(body_contains).__name__}"
)
self._body_contains: str | None = body_contains
self._last_result: bool | None = None
def evaluate(self) -> bool:
try:
with requests.Session() as session:
response = session.request(
self._method,
self._url,
headers=self._headers,
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
)
met = response.status_code == self._expected_status
logging.debug(
f"http trigger: status={response.status_code} "
f"expected={self._expected_status} url='{self._url}'"
)
if met and self._body_contains is not None:
met = self._body_contains in response.text
if not met:
logging.debug(
f"http trigger: body_contains={self._body_contains!r} "
f"not found in response"
)
except requests.exceptions.Timeout:
logging.warning(
f"http trigger timed out after {HTTP_REQUEST_TIMEOUT_SECONDS}s: "
f"{self._url}"
)
met = False
except requests.exceptions.ConnectionError:
logging.warning(f"http trigger connection error: {self._url}")
met = False
except requests.exceptions.RequestException as e:
logging.error(f"http trigger request error: {e}: {self._url}")
met = False
except Exception as e:
logging.error(f"http trigger unexpected error: {e}: {self._url}")
met = False
# Log only on state change
if met != self._last_result:
if met:
logging.info(f"trigger condition satisfied: {self.describe()}")
else:
logging.info(f"trigger condition not satisfied: {self.describe()}")
self._last_result = met
return met
def describe(self) -> str:
return (
f"http trigger: {self._method} {self._url} "
f"expect={self._expected_status}"
)
@@ -16,6 +16,7 @@ import time
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
from krkn.scenario_plugins.triggers.command_trigger import CommandTrigger
from krkn.scenario_plugins.triggers.http_trigger import HttpTrigger
VALID_MODES = {"all_of", "any_of"}
VALID_ON_TIMEOUT = {"skip", "fail", "run_anyway"}
@@ -69,13 +70,9 @@ class TriggerManager:
)
if self._timeout <= 0:
raise ValueError(
f"timeout must be positive, got {self._timeout}"
)
raise ValueError(f"timeout must be positive, got {self._timeout}")
if self._interval <= 0:
raise ValueError(
f"interval must be positive, got {self._interval}"
)
raise ValueError(f"interval must be positive, got {self._interval}")
self._triggers: list[AbstractTrigger] = []
for condition in trigger_config["conditions"]:
@@ -98,6 +95,9 @@ class TriggerManager:
if trigger_type == "command":
return CommandTrigger(condition_config)
if trigger_type == "http":
return HttpTrigger(condition_config)
raise ValueError(f"unknown trigger type: '{trigger_type}'")
def wait_for_triggers(self) -> bool:
@@ -119,9 +119,7 @@ class TriggerManager:
self._trigger_states[i] = result
results.append(result)
logging.debug(
f"trigger poll: {[r for r in results]}"
)
logging.debug(f"trigger poll: {[r for r in results]}")
if self._mode == "all_of" and all(results):
logging.info("all trigger conditions satisfied")
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env python3
"""
Test suite for HttpTrigger class
Usage:
python -m coverage run -a -m unittest tests/test_triggers/test_http_trigger.py -v
Assisted By: Antigravity
"""
import unittest
from unittest.mock import MagicMock, patch
import requests
from krkn.scenario_plugins.triggers.http_trigger import (
HTTP_REQUEST_TIMEOUT_SECONDS, VALID_METHODS, HttpTrigger)
class TestHttpTrigger(unittest.TestCase):
def _make_trigger(self, **overrides):
"""Build an HttpTrigger with sensible defaults."""
config = {"url": "http://example.com/health", "expected_status": 200}
config.update(overrides)
return HttpTrigger(config)
def _mock_response(self, status_code=200, text="ok"):
"""Build a mock requests.Response."""
mock_resp = MagicMock()
mock_resp.status_code = status_code
mock_resp.text = text
return mock_resp
# ------------------------------------------------------------------
# evaluate() tests
# ------------------------------------------------------------------
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_evaluate_success(self, mock_session_cls):
"""Status matches expected_status -> returns True."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(200)
)
trigger = self._make_trigger(
url="http://example.com/health", expected_status=200
)
self.assertTrue(trigger.evaluate())
mock_session_cls.return_value.__enter__.return_value.request.assert_called_once_with(
"GET",
"http://example.com/health",
headers={},
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
)
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_evaluate_wrong_status(self, mock_session_cls):
"""Status does not match expected_status -> returns False."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(503)
)
trigger = self._make_trigger(expected_status=200)
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_evaluate_body_contains_match(self, mock_session_cls):
"""Status matches and body_contains substring found -> returns True."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(200, text='{"status": "healthy"}')
)
trigger = self._make_trigger(body_contains="healthy")
self.assertTrue(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_evaluate_body_contains_no_match(self, mock_session_cls):
"""Status matches but body_contains substring not found -> returns False."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(200, text='{"status": "degraded"}')
)
trigger = self._make_trigger(body_contains="healthy")
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_evaluate_body_contains_skipped_on_wrong_status(self, mock_session_cls):
"""body_contains is not checked when status does not match."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(503, text="healthy")
)
trigger = self._make_trigger(expected_status=200, body_contains="healthy")
# Should be False because status is wrong, body_contains not evaluated
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_evaluate_connection_error(self, mock_session_cls):
"""ConnectionError -> returns False, no exception propagated."""
mock_session_cls.return_value.__enter__.return_value.request.side_effect = (
requests.exceptions.ConnectionError("refused")
)
trigger = self._make_trigger()
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_evaluate_timeout(self, mock_session_cls):
"""Timeout -> returns False, no exception propagated."""
mock_session_cls.return_value.__enter__.return_value.request.side_effect = (
requests.exceptions.Timeout("timed out")
)
trigger = self._make_trigger()
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_evaluate_request_exception(self, mock_session_cls):
"""Generic RequestException -> returns False, no exception propagated."""
mock_session_cls.return_value.__enter__.return_value.request.side_effect = (
requests.exceptions.RequestException("generic error")
)
trigger = self._make_trigger()
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_evaluate_unexpected_exception(self, mock_session_cls):
"""Unexpected exception -> returns False, no exception propagated."""
mock_session_cls.return_value.__enter__.return_value.request.side_effect = (
RuntimeError("something unexpected")
)
trigger = self._make_trigger()
self.assertFalse(trigger.evaluate())
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_bearer_token_sets_authorization_header(self, mock_session_cls):
"""bearer_token -> Authorization: Bearer <token> header sent."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(200)
)
trigger = self._make_trigger(bearer_token="my-secret-token")
trigger.evaluate()
call_kwargs = (
mock_session_cls.return_value.__enter__.return_value.request.call_args[1]
)
self.assertEqual(
call_kwargs["headers"]["Authorization"], "Bearer my-secret-token"
)
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_custom_headers_merged(self, mock_session_cls):
"""headers dict -> merged into request headers."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(200)
)
trigger = self._make_trigger(headers={"X-Custom": "value"})
trigger.evaluate()
call_kwargs = (
mock_session_cls.return_value.__enter__.return_value.request.call_args[1]
)
self.assertEqual(call_kwargs["headers"]["X-Custom"], "value")
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_bearer_token_overrides_authorization_in_headers(self, mock_session_cls):
"""bearer_token applied after headers dict -> overrides any Authorization in headers."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(200)
)
trigger = self._make_trigger(
headers={"Authorization": "Bearer old-token"},
bearer_token="new-token",
)
trigger.evaluate()
call_kwargs = (
mock_session_cls.return_value.__enter__.return_value.request.call_args[1]
)
self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer new-token")
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_method_override_post(self, mock_session_cls):
"""method=POST -> POST used in session.request."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(201)
)
trigger = self._make_trigger(method="POST", expected_status=201)
trigger.evaluate()
args = mock_session_cls.return_value.__enter__.return_value.request.call_args[0]
self.assertEqual(args[0], "POST")
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_method_lowercased_input_normalized(self, mock_session_cls):
"""method='get' (lowercase) -> normalised to 'GET'."""
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(200)
)
trigger = self._make_trigger(method="get")
self.assertEqual(trigger._method, "GET")
trigger.evaluate()
args = mock_session_cls.return_value.__enter__.return_value.request.call_args[0]
self.assertEqual(args[0], "GET")
@patch("krkn.scenario_plugins.triggers.http_trigger.requests.Session")
def test_state_change_logging_not_satisfied_then_satisfied(self, mock_session_cls):
"""State-change logging fires on transition, not on repeat."""
trigger = self._make_trigger()
# First call: False -> logs "not satisfied"
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(503)
)
with self.assertLogs(level="INFO") as log_ctx:
trigger.evaluate()
self.assertTrue(
any("trigger condition not satisfied" in line for line in log_ctx.output)
)
# Second call: still False -> no INFO log (state unchanged)
# (INFO is only emitted on change)
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(503)
)
initial_last = trigger._last_result
with patch("logging.info") as mock_info:
trigger.evaluate()
mock_info.assert_not_called()
self.assertEqual(trigger._last_result, initial_last) # state unchanged
# Third call: True -> logs "satisfied"
mock_session_cls.return_value.__enter__.return_value.request.return_value = (
self._mock_response(200)
)
with self.assertLogs(level="INFO") as log_ctx:
trigger.evaluate()
self.assertTrue(
any("trigger condition satisfied" in line for line in log_ctx.output)
)
# ------------------------------------------------------------------
# describe() tests
# ------------------------------------------------------------------
def test_describe_default(self):
"""describe() returns string with method, url, and expected status."""
trigger = self._make_trigger(
url="http://nginx.default.svc:8080/health",
expected_status=200,
)
description = trigger.describe()
self.assertIn("http trigger", description)
self.assertIn("GET", description)
self.assertIn("http://nginx.default.svc:8080/health", description)
self.assertIn("200", description)
self.assertIsInstance(description, str)
def test_describe_post(self):
"""describe() reflects method override."""
trigger = self._make_trigger(method="POST", expected_status=201)
description = trigger.describe()
self.assertIn("POST", description)
self.assertIn("201", description)
# ------------------------------------------------------------------
# Validation tests
# ------------------------------------------------------------------
def test_invalid_body_contains_raises(self):
"""body_contains passed as non-string -> raises ValueError."""
with self.assertRaises(ValueError) as ctx:
self._make_trigger(body_contains={"key": "value"})
self.assertIn("body_contains", str(ctx.exception))
def test_missing_url_raises(self):
"""No 'url' field -> raises ValueError containing 'url'."""
with self.assertRaises(ValueError) as ctx:
HttpTrigger({})
self.assertIn("url", str(ctx.exception).lower())
def test_empty_url_raises(self):
"""url='' -> raises ValueError."""
with self.assertRaises(ValueError) as ctx:
HttpTrigger({"url": ""})
self.assertIn("url", str(ctx.exception).lower())
def test_invalid_method_raises(self):
"""method='BREW' -> raises ValueError."""
with self.assertRaises(ValueError) as ctx:
self._make_trigger(method="BREW")
self.assertIn("BREW", str(ctx.exception))
def test_valid_methods_accepted(self):
"""All methods in VALID_METHODS are accepted without raising."""
for method in VALID_METHODS:
trigger = self._make_trigger(method=method)
self.assertEqual(trigger._method, method)
def test_expected_status_string_coerced_to_int(self):
"""expected_status='200' (string) -> coerced to int 200."""
trigger = self._make_trigger(expected_status="200")
self.assertEqual(trigger._expected_status, 200)
self.assertIsInstance(trigger._expected_status, int)
def test_expected_status_invalid_string_raises(self):
"""expected_status='ok' -> raises ValueError."""
with self.assertRaises(ValueError) as ctx:
self._make_trigger(expected_status="ok")
self.assertIn("expected_status", str(ctx.exception))
def test_expected_status_below_range_raises(self):
"""expected_status=99 -> raises ValueError (valid range 100-599)."""
with self.assertRaises(ValueError) as ctx:
self._make_trigger(expected_status=99)
self.assertIn("100", str(ctx.exception))
def test_expected_status_above_range_raises(self):
"""expected_status=600 -> raises ValueError."""
with self.assertRaises(ValueError) as ctx:
self._make_trigger(expected_status=600)
self.assertIn("599", str(ctx.exception))
def test_default_expected_status_is_200(self):
"""No expected_status in config -> defaults to 200."""
trigger = HttpTrigger({"url": "http://example.com"})
self.assertEqual(trigger._expected_status, 200)
def test_default_method_is_get(self):
"""No method in config -> defaults to GET."""
trigger = HttpTrigger({"url": "http://example.com"})
self.assertEqual(trigger._method, "GET")
def test_no_bearer_token_no_authorization_header(self):
"""No bearer_token -> Authorization header not set."""
trigger = self._make_trigger()
self.assertNotIn("Authorization", trigger._headers)
def test_bearer_token_stored_in_headers(self):
"""bearer_token -> stored as Authorization header on the trigger."""
trigger = self._make_trigger(bearer_token="tok123")
self.assertEqual(trigger._headers["Authorization"], "Bearer tok123")
def test_last_result_initialised_to_none(self):
"""_last_result starts as None before first evaluate()."""
trigger = self._make_trigger()
self.assertIsNone(trigger._last_result)
if __name__ == "__main__":
unittest.main()
@@ -208,6 +208,16 @@ class TestTriggerManager(unittest.TestCase):
# Validation tests
# ------------------------------------------------------------------
def test_factory_wiring_returns_http_trigger(self):
"""TriggerManager with type: http returns an HttpTrigger."""
config = _make_config(
conditions=[{"type": "http", "url": "http://test.com"}],
)
manager = TriggerManager(config)
from krkn.scenario_plugins.triggers.http_trigger import HttpTrigger
self.assertIsInstance(manager._triggers[0], HttpTrigger)
def test_invalid_mode_raises(self):
"""mode='invalid' -> raises ValueError."""
config = _make_config(mode="invalid")