feat: add k8s trigger type for event-driven chaos (#1515)

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

Closes #1496

Signed-off-by: Saurabh Wattamwar <swattamw@redhat.com>

* fix: address review comments on k8s trigger

- Validate namespace against resource_api.namespaced to fail fast
  when a namespaced resource is missing the namespace config
- Use numeric equality first in _compare for == and != operators
  so that 1.0 == 1 works consistently with >= and <=
- Fix krknctl-input.json description for apiVersion field

Signed-off-by: Saurabh Wattamwar <swattamw@redhat.com>

* feat: add optional context field to k8s trigger

Allows selecting a specific kubeconfig context for cross-cluster
triggers. If omitted, uses the default context as before.

Signed-off-by: Saurabh Wattamwar <swattamw@redhat.com>

* fix: use kubecli.dyn_client instead of raw DynamicClient and catch ValueError

- K8sTrigger now receives kubecli from TriggerManager and uses
  kubecli.dyn_client for centralized config and proxy support
- TriggerManager passes kubecli through _build_trigger to K8sTrigger
- run_kraken.py passes kubecli to TriggerManager
- Added ValueError to the except clause in evaluate() to handle
  non-numeric comparisons and namespace validation errors gracefully

Signed-off-by: Saurabh Wattamwar <swattamw@redhat.com>

* docs: add k8s trigger example config

Signed-off-by: Saurabh Wattamwar <swattamw@redhat.com>

* fix: make kubecli required and remove unused context field from k8s trigger

  Address remaining review comments on PR #1515:
  - Make kubecli a required argument with early validation instead of
    silently failing when None
  - Remove _context field since _get_client() delegates to kubecli.dyn_client
    which is already initialized with the correct context
  - Remove TRIGGER_K8S_CONTEXT from krknctl-input.json
  - Update tests to pass mock kubecli and cover missing-kubecli validation

Signed-off-by: Saurabh Wattamwar <swattamw@redhat.com>

---------

Signed-off-by: Saurabh Wattamwar <swattamw@redhat.com>
This commit is contained in:
Saurabh Wattamwar
2026-08-04 14:13:15 -04:00
committed by GitHub
parent be36abd53d
commit a6855f219f
7 changed files with 821 additions and 5 deletions
+17
View File
@@ -0,0 +1,17 @@
kraken:
chaos_scenarios:
- pod_disruption_scenarios:
- scenarios/kube/pod.yml
triggers:
mode: all_of
timeout: 120
interval: 5
on_timeout: fail
conditions:
- type: k8s
apiVersion: apps/v1
kind: Deployment
name: nginx
namespace: default
condition: "status.readyReplicas >= 1"
+50
View File
@@ -823,5 +823,55 @@
"default": "",
"required": "false",
"group": "triggers"
},
{
"name": "trigger-k8s-api-version",
"short_description": "K8s trigger API version",
"description": "Kubernetes API version for the resource to watch (e.g. apps/v1, kubevirt.io/v1). Required when using a k8s trigger condition.",
"variable": "TRIGGER_K8S_API_VERSION",
"type": "string",
"default": "",
"required": "false",
"group": "triggers"
},
{
"name": "trigger-k8s-kind",
"short_description": "K8s trigger resource kind",
"description": "Kubernetes resource kind to watch (e.g. Deployment, Pod, VirtualMachineInstanceMigration)",
"variable": "TRIGGER_K8S_KIND",
"type": "string",
"default": "",
"required": "false",
"group": "triggers"
},
{
"name": "trigger-k8s-namespace",
"short_description": "K8s trigger namespace",
"description": "Namespace of the resource to watch. Leave empty for cluster-scoped resources like Nodes.",
"variable": "TRIGGER_K8S_NAMESPACE",
"type": "string",
"default": "",
"required": "false",
"group": "triggers"
},
{
"name": "trigger-k8s-name",
"short_description": "K8s trigger resource name",
"description": "Name of the specific Kubernetes resource to watch",
"variable": "TRIGGER_K8S_NAME",
"type": "string",
"default": "",
"required": "false",
"group": "triggers"
},
{
"name": "trigger-k8s-condition",
"short_description": "K8s trigger condition",
"description": "Condition expression to evaluate against the resource (e.g. status.phase == Running, status.readyReplicas >= 1)",
"variable": "TRIGGER_K8S_CONDITION",
"type": "string",
"default": "",
"required": "false",
"group": "triggers"
}
]
+2 -1
View File
@@ -14,6 +14,7 @@
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.k8s_trigger import K8sTrigger
from krkn.scenario_plugins.triggers.trigger_manager import TriggerManager
__all__ = ["AbstractTrigger", "CommandTrigger", "HttpTrigger", "TriggerManager"]
__all__ = ["AbstractTrigger", "CommandTrigger", "HttpTrigger", "K8sTrigger", "TriggerManager"]
@@ -0,0 +1,240 @@
# 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 re
from kubernetes.dynamic import DynamicClient
from kubernetes.dynamic.exceptions import (
NotFoundError,
ResourceNotFoundError,
)
from krkn.scenario_plugins.triggers.abstract_trigger import AbstractTrigger
OPERATORS = ("==", "!=", ">=", "<=", ">", "<")
OPERATOR_RE = re.compile(r"\s*(==|!=|>=|<=|>|<)\s*")
def _resolve_path(obj, path: str):
"""Walk a dot-separated path on a dict/ResourceInstance.
Returns the value at the path, or raises KeyError / IndexError
if any segment is missing.
"""
current = obj
for segment in path.split("."):
if isinstance(current, dict):
current = current[segment]
elif isinstance(current, (list, tuple)):
current = current[int(segment)]
else:
current = getattr(current, segment)
return current
def _coerce(value: str):
"""Coerce a string value to int, float, bool, None, or leave as str."""
if value.lower() == "true":
return True
if value.lower() == "false":
return False
if value.lower() == "none" or value.lower() == "null":
return None
try:
return int(value)
except ValueError:
pass
try:
return float(value)
except ValueError:
pass
return value
def _compare(actual, operator: str, expected):
"""Compare actual value against expected using the given operator."""
try:
actual_num = float(actual)
expected_num = float(expected)
is_numeric = True
except (TypeError, ValueError):
is_numeric = False
if operator == "==":
if is_numeric:
return actual_num == expected_num
return str(actual) == str(expected)
if operator == "!=":
if is_numeric:
return actual_num != expected_num
return str(actual) != str(expected)
if not is_numeric:
raise ValueError(
f"cannot compare non-numeric values with '{operator}': "
f"actual={actual!r}, expected={expected!r}"
)
if operator == ">=":
return actual_num >= expected_num
if operator == "<=":
return actual_num <= expected_num
if operator == ">":
return actual_num > expected_num
if operator == "<":
return actual_num < expected_num
raise ValueError(f"unsupported operator: '{operator}'")
def _parse_condition(condition: str) -> tuple:
"""Parse 'field.path == value' into (path, operator, expected_value)."""
match = OPERATOR_RE.search(condition)
if not match:
raise ValueError(
f"condition must contain an operator "
f"({', '.join(OPERATORS)}): got {condition!r}"
)
operator = match.group(1)
path = condition[: match.start()].strip()
raw_value = condition[match.end() :].strip()
if not path:
raise ValueError(f"condition is missing a field path: {condition!r}")
if not raw_value:
raise ValueError(f"condition is missing an expected value: {condition!r}")
return path, operator, _coerce(raw_value)
class K8sTrigger(AbstractTrigger):
"""Trigger that waits for a Kubernetes resource to match a condition.
Kind-agnostic: works with built-in resources and CRDs through the
same code path using the Kubernetes dynamic client.
"""
def __init__(self, trigger_config: dict, kubecli):
if kubecli is None:
raise ValueError("k8s trigger requires a kubecli instance")
self._api_version = trigger_config.get("apiVersion")
if not self._api_version:
raise ValueError("k8s trigger requires 'apiVersion'")
self._kind = trigger_config.get("kind")
if not self._kind:
raise ValueError("k8s trigger requires 'kind'")
self._name = trigger_config.get("name")
if not self._name:
raise ValueError("k8s trigger requires 'name'")
raw_condition = trigger_config.get("condition")
if not raw_condition:
raise ValueError("k8s trigger requires 'condition'")
self._namespace = trigger_config.get("namespace")
self._path, self._operator, self._expected = _parse_condition(
raw_condition
)
self._raw_condition = raw_condition
self._kubecli = kubecli
self._last_result: bool | None = None
def _get_client(self) -> DynamicClient:
return self._kubecli.dyn_client
def evaluate(self) -> bool:
try:
dyn = self._get_client()
resource_api = dyn.resources.get(
api_version=self._api_version, kind=self._kind
)
if resource_api.namespaced and not self._namespace:
raise ValueError(
f"{self._api_version}/{self._kind} is namespaced "
f"but no namespace was specified"
)
if self._namespace:
resource = resource_api.get(
name=self._name, namespace=self._namespace
)
else:
resource = resource_api.get(name=self._name)
actual = _resolve_path(resource, self._path)
met = _compare(actual, self._operator, self._expected)
logging.debug(
"k8s trigger: %s/%s %s.%s=%r (condition: %s) -> %s",
self._kind,
self._name,
self._path,
self._operator,
actual,
self._raw_condition,
met,
)
except NotFoundError:
logging.debug(
"k8s trigger: resource %s/%s not found yet",
self._kind,
self._name,
)
met = False
except ResourceNotFoundError:
logging.error(
"k8s trigger: API resource %s %s not registered on cluster",
self._api_version,
self._kind,
)
met = False
except (KeyError, IndexError, AttributeError, ValueError) as e:
logging.debug(
"k8s trigger: field path '%s' not present on %s/%s: %s",
self._path,
self._kind,
self._name,
e,
)
met = False
except Exception as e:
logging.error("k8s trigger unexpected error: %s", e)
met = False
if met != self._last_result:
if met:
logging.info(
"trigger condition satisfied: %s/%s %s",
self._kind,
self._name,
self._raw_condition,
)
else:
logging.info(
"trigger condition not satisfied: %s/%s %s",
self._kind,
self._name,
self._raw_condition,
)
self._last_result = met
return met
def describe(self) -> str:
ns_part = f" namespace={self._namespace}" if self._namespace else ""
return (
f"k8s trigger: {self._api_version}/{self._kind} "
f"'{self._name}'{ns_part} "
f"(condition: {self._raw_condition})"
)
@@ -17,6 +17,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
from krkn.scenario_plugins.triggers.k8s_trigger import K8sTrigger
VALID_MODES = {"all_of", "any_of"}
VALID_ON_TIMEOUT = {"skip", "fail", "run_anyway"}
@@ -30,7 +31,7 @@ DEFAULT_ON_TIMEOUT = "skip"
class TriggerManager:
"""Orchestrates polling across multiple triggers."""
def __init__(self, trigger_config: dict):
def __init__(self, trigger_config: dict, kubecli=None):
conditions = trigger_config.get("conditions")
if not conditions:
raise ValueError(
@@ -74,9 +75,13 @@ class TriggerManager:
if self._interval <= 0:
raise ValueError(f"interval must be positive, got {self._interval}")
self._kubecli = kubecli
self._triggers: list[AbstractTrigger] = []
for condition in trigger_config["conditions"]:
self._triggers.append(self._build_trigger(condition))
self._triggers.append(
self._build_trigger(condition, kubecli=kubecli)
)
# Track per-trigger satisfaction state for get_status
self._trigger_states: list[bool | None] = [None] * len(self._triggers)
@@ -86,7 +91,9 @@ class TriggerManager:
return self._on_timeout
@staticmethod
def _build_trigger(condition_config: dict) -> AbstractTrigger:
def _build_trigger(
condition_config: dict, kubecli=None
) -> AbstractTrigger:
"""Factory method that creates a trigger from a condition config."""
trigger_type = condition_config.get("type")
if not trigger_type:
@@ -98,6 +105,9 @@ class TriggerManager:
if trigger_type == "http":
return HttpTrigger(condition_config)
if trigger_type == "k8s":
return K8sTrigger(condition_config, kubecli=kubecli)
raise ValueError(f"unknown trigger type: '{trigger_type}'")
def wait_for_triggers(self) -> bool:
+1 -1
View File
@@ -455,7 +455,7 @@ def main(options, command: Optional[str], out: Optional[dict] = None) -> int:
trigger_config = config.get("triggers")
if trigger_config:
try:
trigger_manager = TriggerManager(trigger_config)
trigger_manager = TriggerManager(trigger_config, kubecli=kubecli)
logging.info(
"waiting for triggers before starting chaos:\n%s",
trigger_manager.describe(),
+498
View File
@@ -0,0 +1,498 @@
#!/usr/bin/env python3
"""
Test suite for K8sTrigger class
Usage:
python -m coverage run -a -m unittest tests/test_triggers/test_k8s_trigger.py -v
Assisted By: Claude Code
"""
import unittest
from unittest.mock import MagicMock, patch
from krkn.scenario_plugins.triggers.k8s_trigger import (
K8sTrigger,
_coerce,
_compare,
_parse_condition,
_resolve_path,
)
from kubernetes.dynamic.exceptions import (
NotFoundError,
ResourceNotFoundError,
)
class TestResolvePath(unittest.TestCase):
"""Tests for the _resolve_path helper."""
def test_simple_dict_path(self):
obj = {"status": {"phase": "Running"}}
self.assertEqual(_resolve_path(obj, "status.phase"), "Running")
def test_nested_dict(self):
obj = {"spec": {"replicas": 3}}
self.assertEqual(_resolve_path(obj, "spec.replicas"), 3)
def test_list_index(self):
obj = {"items": ["a", "b", "c"]}
self.assertEqual(_resolve_path(obj, "items.1"), "b")
def test_missing_key_raises(self):
obj = {"status": {}}
with self.assertRaises(KeyError):
_resolve_path(obj, "status.phase")
def test_attribute_access(self):
inner = MagicMock()
inner.phase = "Succeeded"
obj = {"status": inner}
self.assertEqual(_resolve_path(obj, "status.phase"), "Succeeded")
def test_single_segment(self):
obj = {"phase": "Running"}
self.assertEqual(_resolve_path(obj, "phase"), "Running")
class TestCoerce(unittest.TestCase):
"""Tests for the _coerce helper."""
def test_integer(self):
self.assertEqual(_coerce("42"), 42)
self.assertIsInstance(_coerce("42"), int)
def test_float(self):
self.assertEqual(_coerce("3.14"), 3.14)
self.assertIsInstance(_coerce("3.14"), float)
def test_true(self):
self.assertTrue(_coerce("true"))
self.assertTrue(_coerce("True"))
self.assertTrue(_coerce("TRUE"))
def test_false(self):
self.assertFalse(_coerce("false"))
self.assertFalse(_coerce("False"))
def test_none(self):
self.assertIsNone(_coerce("none"))
self.assertIsNone(_coerce("null"))
self.assertIsNone(_coerce("None"))
def test_string(self):
self.assertEqual(_coerce("Running"), "Running")
class TestCompare(unittest.TestCase):
"""Tests for the _compare helper."""
def test_eq_string(self):
self.assertTrue(_compare("Running", "==", "Running"))
self.assertFalse(_compare("Pending", "==", "Running"))
def test_ne_string(self):
self.assertTrue(_compare("Pending", "!=", "Running"))
self.assertFalse(_compare("Running", "!=", "Running"))
def test_eq_numeric_as_string(self):
self.assertTrue(_compare(3, "==", 3))
self.assertTrue(_compare("3", "==", "3"))
def test_eq_numeric_float_int(self):
"""1.0 == 1 should be True via numeric comparison."""
self.assertTrue(_compare(1.0, "==", 1))
self.assertTrue(_compare("1.0", "==", "1"))
def test_ne_numeric_float_int(self):
"""1.0 != 1 should be False via numeric comparison."""
self.assertFalse(_compare(1.0, "!=", 1))
def test_gte(self):
self.assertTrue(_compare(3, ">=", 3))
self.assertTrue(_compare(4, ">=", 3))
self.assertFalse(_compare(2, ">=", 3))
def test_lte(self):
self.assertTrue(_compare(3, "<=", 3))
self.assertTrue(_compare(2, "<=", 3))
self.assertFalse(_compare(4, "<=", 3))
def test_gt(self):
self.assertTrue(_compare(4, ">", 3))
self.assertFalse(_compare(3, ">", 3))
def test_lt(self):
self.assertTrue(_compare(2, "<", 3))
self.assertFalse(_compare(3, "<", 3))
def test_numeric_comparison_non_numeric_raises(self):
with self.assertRaises(ValueError):
_compare("abc", ">", 3)
class TestParseCondition(unittest.TestCase):
"""Tests for the _parse_condition helper."""
def test_eq(self):
path, op, val = _parse_condition("status.phase == Running")
self.assertEqual(path, "status.phase")
self.assertEqual(op, "==")
self.assertEqual(val, "Running")
def test_ne(self):
path, op, val = _parse_condition("status.phase != Pending")
self.assertEqual(path, "status.phase")
self.assertEqual(op, "!=")
self.assertEqual(val, "Pending")
def test_gte_with_int(self):
path, op, val = _parse_condition("status.readyReplicas >= 1")
self.assertEqual(path, "status.readyReplicas")
self.assertEqual(op, ">=")
self.assertEqual(val, 1)
def test_no_spaces(self):
path, op, val = _parse_condition("status.phase==Running")
self.assertEqual(path, "status.phase")
self.assertEqual(op, "==")
self.assertEqual(val, "Running")
def test_missing_operator_raises(self):
with self.assertRaises(ValueError) as ctx:
_parse_condition("status.phase Running")
self.assertIn("operator", str(ctx.exception))
def test_missing_path_raises(self):
with self.assertRaises(ValueError) as ctx:
_parse_condition("== Running")
self.assertIn("field path", str(ctx.exception))
def test_missing_value_raises(self):
with self.assertRaises(ValueError) as ctx:
_parse_condition("status.phase ==")
self.assertIn("expected value", str(ctx.exception))
def test_boolean_value(self):
_, _, val = _parse_condition("status.ready == true")
self.assertIs(val, True)
class TestK8sTriggerInit(unittest.TestCase):
"""Tests for K8sTrigger constructor validation."""
def _mock_kubecli(self):
kubecli = MagicMock()
kubecli.dyn_client = MagicMock()
return kubecli
def test_missing_kubecli_raises(self):
with self.assertRaises(ValueError) as ctx:
K8sTrigger({
"type": "k8s",
"apiVersion": "apps/v1",
"kind": "Deployment",
"name": "nginx",
"condition": "status.phase == Running",
}, kubecli=None)
self.assertIn("kubecli", str(ctx.exception))
def test_missing_api_version_raises(self):
with self.assertRaises(ValueError) as ctx:
K8sTrigger({
"type": "k8s",
"kind": "Deployment",
"name": "nginx",
"condition": "status.phase == Running",
}, kubecli=self._mock_kubecli())
self.assertIn("apiVersion", str(ctx.exception))
def test_missing_kind_raises(self):
with self.assertRaises(ValueError) as ctx:
K8sTrigger({
"type": "k8s",
"apiVersion": "apps/v1",
"name": "nginx",
"condition": "status.phase == Running",
}, kubecli=self._mock_kubecli())
self.assertIn("kind", str(ctx.exception))
def test_missing_name_raises(self):
with self.assertRaises(ValueError) as ctx:
K8sTrigger({
"type": "k8s",
"apiVersion": "apps/v1",
"kind": "Deployment",
"condition": "status.phase == Running",
}, kubecli=self._mock_kubecli())
self.assertIn("name", str(ctx.exception))
def test_missing_condition_raises(self):
with self.assertRaises(ValueError) as ctx:
K8sTrigger({
"type": "k8s",
"apiVersion": "apps/v1",
"kind": "Deployment",
"name": "nginx",
}, kubecli=self._mock_kubecli())
self.assertIn("condition", str(ctx.exception))
def test_valid_config(self):
trigger = K8sTrigger({
"type": "k8s",
"apiVersion": "apps/v1",
"kind": "Deployment",
"name": "nginx",
"namespace": "default",
"condition": "status.readyReplicas >= 1",
}, kubecli=self._mock_kubecli())
self.assertEqual(trigger._api_version, "apps/v1")
self.assertEqual(trigger._kind, "Deployment")
self.assertEqual(trigger._name, "nginx")
self.assertEqual(trigger._namespace, "default")
self.assertEqual(trigger._path, "status.readyReplicas")
self.assertEqual(trigger._operator, ">=")
self.assertEqual(trigger._expected, 1)
def test_namespace_optional(self):
trigger = K8sTrigger({
"type": "k8s",
"apiVersion": "v1",
"kind": "Node",
"name": "worker-1",
"condition": "status.phase == Ready",
}, kubecli=self._mock_kubecli())
self.assertIsNone(trigger._namespace)
class TestK8sTriggerEvaluate(unittest.TestCase):
"""Tests for K8sTrigger.evaluate() with mocked kubecli."""
def _make_kubecli(self):
"""Create a mock kubecli with a mock dyn_client."""
kubecli = MagicMock()
kubecli.dyn_client = MagicMock()
return kubecli
def _make_trigger(self, kubecli=None, **overrides):
if kubecli is None:
kubecli = self._make_kubecli()
config = {
"type": "k8s",
"apiVersion": "apps/v1",
"kind": "Deployment",
"name": "nginx",
"namespace": "default",
"condition": "status.readyReplicas >= 1",
}
config.update(overrides)
return K8sTrigger(config, kubecli=kubecli), kubecli
def test_condition_met(self):
"""Resource matches condition -> returns True."""
trigger, kubecli = self._make_trigger()
mock_api = MagicMock()
kubecli.dyn_client.resources.get.return_value = mock_api
mock_api.get.return_value = {"status": {"readyReplicas": 3}}
self.assertTrue(trigger.evaluate())
kubecli.dyn_client.resources.get.assert_called_once_with(
api_version="apps/v1", kind="Deployment"
)
mock_api.get.assert_called_once_with(
name="nginx", namespace="default"
)
def test_condition_not_met(self):
"""Resource does not match condition -> returns False."""
trigger, kubecli = self._make_trigger()
mock_api = MagicMock()
kubecli.dyn_client.resources.get.return_value = mock_api
mock_api.get.return_value = {"status": {"readyReplicas": 0}}
self.assertFalse(trigger.evaluate())
def test_string_equality(self):
"""String equality condition works."""
trigger, kubecli = self._make_trigger(
condition="status.phase == Running",
kind="VirtualMachineInstanceMigration",
apiVersion="kubevirt.io/v1",
)
mock_api = MagicMock()
kubecli.dyn_client.resources.get.return_value = mock_api
mock_api.get.return_value = {"status": {"phase": "Running"}}
self.assertTrue(trigger.evaluate())
def test_resource_not_found(self):
"""Resource does not exist yet -> returns False."""
trigger, kubecli = self._make_trigger()
mock_api = MagicMock()
kubecli.dyn_client.resources.get.return_value = mock_api
mock_api.get.side_effect = NotFoundError(MagicMock(status=404))
self.assertFalse(trigger.evaluate())
def test_api_resource_not_registered(self):
"""CRD not installed on cluster -> returns False."""
trigger, kubecli = self._make_trigger(
apiVersion="kubevirt.io/v1",
kind="VirtualMachineInstanceMigration",
)
kubecli.dyn_client.resources.get.side_effect = ResourceNotFoundError(
"Resource not found"
)
self.assertFalse(trigger.evaluate())
def test_field_path_missing(self):
"""Field path doesn't exist on resource -> returns False."""
trigger, kubecli = self._make_trigger()
mock_api = MagicMock()
kubecli.dyn_client.resources.get.return_value = mock_api
mock_api.get.return_value = {"status": {}}
self.assertFalse(trigger.evaluate())
def test_unexpected_error(self):
"""Unexpected API error -> returns False, no crash."""
trigger, kubecli = self._make_trigger()
kubecli.dyn_client.resources.get.side_effect = ConnectionError("refused")
self.assertFalse(trigger.evaluate())
def test_namespaced_resource_without_namespace(self):
"""Namespaced resource with no namespace configured -> returns False."""
kubecli = self._make_kubecli()
mock_api = MagicMock()
mock_api.namespaced = True
kubecli.dyn_client.resources.get.return_value = mock_api
trigger = K8sTrigger({
"type": "k8s",
"apiVersion": "apps/v1",
"kind": "Deployment",
"name": "nginx",
"condition": "status.readyReplicas >= 1",
}, kubecli=kubecli)
self.assertFalse(trigger.evaluate())
mock_api.get.assert_not_called()
def test_cluster_scoped_resource(self):
"""No namespace -> calls get() without namespace kwarg."""
kubecli = self._make_kubecli()
mock_api = MagicMock()
mock_api.namespaced = False
kubecli.dyn_client.resources.get.return_value = mock_api
mock_api.get.return_value = {"status": {"phase": "Ready"}}
trigger = K8sTrigger({
"type": "k8s",
"apiVersion": "v1",
"kind": "Node",
"name": "worker-1",
"condition": "status.phase == Ready",
}, kubecli=kubecli)
trigger.evaluate()
mock_api.get.assert_called_once_with(name="worker-1")
def test_crd_same_code_path(self):
"""CRD uses the exact same code path as built-in resources."""
kubecli = self._make_kubecli()
mock_api = MagicMock()
kubecli.dyn_client.resources.get.return_value = mock_api
mock_api.get.return_value = {"status": {"phase": "Running"}}
trigger = K8sTrigger({
"type": "k8s",
"apiVersion": "kubevirt.io/v1",
"kind": "VirtualMachineInstanceMigration",
"name": "test-migration",
"namespace": "vm-ns",
"condition": "status.phase == Running",
}, kubecli=kubecli)
self.assertTrue(trigger.evaluate())
kubecli.dyn_client.resources.get.assert_called_once_with(
api_version="kubevirt.io/v1",
kind="VirtualMachineInstanceMigration",
)
def test_value_error_from_compare(self):
"""ValueError from _compare (non-numeric > operator) -> returns False."""
trigger, kubecli = self._make_trigger(
condition="status.phase > 1",
)
mock_api = MagicMock()
kubecli.dyn_client.resources.get.return_value = mock_api
mock_api.get.return_value = {"status": {"phase": "Running"}}
self.assertFalse(trigger.evaluate())
class TestK8sTriggerDescribe(unittest.TestCase):
"""Tests for K8sTrigger.describe()."""
def _mock_kubecli(self):
kubecli = MagicMock()
kubecli.dyn_client = MagicMock()
return kubecli
def test_describe_with_namespace(self):
trigger = K8sTrigger({
"type": "k8s",
"apiVersion": "apps/v1",
"kind": "Deployment",
"name": "nginx",
"namespace": "default",
"condition": "status.readyReplicas >= 1",
}, kubecli=self._mock_kubecli())
desc = trigger.describe()
self.assertIn("apps/v1", desc)
self.assertIn("Deployment", desc)
self.assertIn("nginx", desc)
self.assertIn("default", desc)
self.assertIn("readyReplicas >= 1", desc)
def test_describe_without_namespace(self):
trigger = K8sTrigger({
"type": "k8s",
"apiVersion": "v1",
"kind": "Node",
"name": "worker-1",
"condition": "status.phase == Ready",
}, kubecli=self._mock_kubecli())
desc = trigger.describe()
self.assertIn("Node", desc)
self.assertIn("worker-1", desc)
self.assertNotIn("namespace", desc)
class TestK8sTriggerClientInit(unittest.TestCase):
"""Tests for K8sTrigger._get_client() delegation to kubecli."""
def test_get_client_returns_kubecli_dyn_client(self):
"""_get_client() returns kubecli.dyn_client."""
kubecli = MagicMock()
mock_dyn = MagicMock()
kubecli.dyn_client = mock_dyn
trigger = K8sTrigger({
"type": "k8s",
"apiVersion": "v1",
"kind": "Pod",
"name": "test",
"namespace": "default",
"condition": "status.phase == Running",
}, kubecli=kubecli)
result = trigger._get_client()
self.assertIs(result, mock_dyn)
if __name__ == "__main__":
unittest.main()