diff --git a/config/trigger_http_example.yaml b/config/trigger_http_example.yaml new file mode 100644 index 00000000..1a80c35a --- /dev/null +++ b/config/trigger_http_example.yaml @@ -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 diff --git a/containers/krknctl-input.json b/containers/krknctl-input.json index 58b9e398..bafc8f43 100644 --- a/containers/krknctl-input.json +++ b/containers/krknctl-input.json @@ -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" } ] diff --git a/krkn/scenario_plugins/triggers/__init__.py b/krkn/scenario_plugins/triggers/__init__.py index 215c8548..913c3f69 100644 --- a/krkn/scenario_plugins/triggers/__init__.py +++ b/krkn/scenario_plugins/triggers/__init__.py @@ -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"] diff --git a/krkn/scenario_plugins/triggers/http_trigger.py b/krkn/scenario_plugins/triggers/http_trigger.py new file mode 100644 index 00000000..64356d8c --- /dev/null +++ b/krkn/scenario_plugins/triggers/http_trigger.py @@ -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}" + ) diff --git a/krkn/scenario_plugins/triggers/trigger_manager.py b/krkn/scenario_plugins/triggers/trigger_manager.py index 5d6e6e88..29052d55 100644 --- a/krkn/scenario_plugins/triggers/trigger_manager.py +++ b/krkn/scenario_plugins/triggers/trigger_manager.py @@ -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") diff --git a/tests/test_triggers/test_http_trigger.py b/tests/test_triggers/test_http_trigger.py new file mode 100644 index 00000000..1a29a57d --- /dev/null +++ b/tests/test_triggers/test_http_trigger.py @@ -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 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() diff --git a/tests/test_triggers/test_trigger_manager.py b/tests/test_triggers/test_trigger_manager.py index 446df883..78ad3354 100644 --- a/tests/test_triggers/test_trigger_manager.py +++ b/tests/test_triggers/test_trigger_manager.py @@ -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")