diff --git a/docs/playbook-reference/kubernetes-examples/kubernetes-generic-example.rst b/docs/playbook-reference/kubernetes-examples/kubernetes-generic-example.rst new file mode 100644 index 000000000..44f5ca534 --- /dev/null +++ b/docs/playbook-reference/kubernetes-examples/kubernetes-generic-example.rst @@ -0,0 +1,84 @@ +Generic resource tracking +=============== + +Track changes to any Kubernetes resource + +Track HTTPRoute Change +-------------------------------- + +For example, get notified when an HTTPRoute resource changes. + +**Setup Steps**: + +1. **Grant Permissions to Robusta**: By default, Robusta does not have permission to read it +2. **Configure Kubewatch**: Set up Kubewatch to monitor HTTPRoute resources +3. **Create Custom Playbook**: Define notification rules + +**1. Grant Permissions to Robusta** + +Create a YAML file named ``kubewatch-httproute-permissions.yaml`` with the following content: + +.. code-block:: yaml + + kind: ClusterRole + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: kubewatch-custom-role + rules: + - apiGroups: + - "gateway.networking.k8s.io" + resources: + - httproutes + verbs: + - get + - list + - watch + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: kubewatch-custom-role-binding + roleRef: + kind: ClusterRole + name: kubewatch-custom-role + subjects: + - kind: ServiceAccount + name: robusta-forwarder-service-account + namespace: default + +Apply the permissions: + +.. code-block:: bash + + kubectl apply -f kubewatch-httproute-permissions.yaml + +**2. Configure Kubewatch to Monitor HTTPRoute** + +Add the following to the ``kubewatch`` section in your ``generated_values.yaml``: + +.. code-block:: yaml + + kubewatch: + config: + customresources: + - group: gateway.networking.k8s.io + version: v1 + resource: httproutes + +**3. Create Custom Playbook** + +Add the following to the ``customPlaybooks`` section in your ``generated_values.yaml``: + +.. code-block:: yaml + + customPlaybooks: + - triggers: + - on_kubernetes_resource_operation: + namespace_prefix: "monitoring" + name_prefix: "grafana-" + actions: + - create_finding: # + title: "resource $name in namespace $namespace was modified" + aggregation_key: "resource_modified" + +Then perform a :ref:`Helm Upgrade `. diff --git a/src/robusta/core/playbooks/playbooks_event_handler_impl.py b/src/robusta/core/playbooks/playbooks_event_handler_impl.py index 110190b4a..f2c87fa04 100644 --- a/src/robusta/core/playbooks/playbooks_event_handler_impl.py +++ b/src/robusta/core/playbooks/playbooks_event_handler_impl.py @@ -64,6 +64,7 @@ def handle_trigger(self, trigger_event: TriggerEvent) -> Optional[Dict[str, Any] f"Failed to build execution event for {trigger_event.get_event_description()}, Event: {trigger_event}", exc_info=True, ) + execution_event = None if execution_event: # might not exist for unsupported k8s types execution_event.named_sinks = ( diff --git a/src/robusta/integrations/kubernetes/base_event.py b/src/robusta/integrations/kubernetes/base_event.py index b29bdeb1f..b0922d3b8 100644 --- a/src/robusta/integrations/kubernetes/base_event.py +++ b/src/robusta/integrations/kubernetes/base_event.py @@ -5,7 +5,7 @@ from robusta.core.model.events import ExecutionBaseEvent from robusta.core.model.k8s_operation_type import K8sOperationType -from robusta.core.reporting import Finding, FindingSource +from robusta.core.reporting import Finding, FindingSource, FindingSubject, FindingSubjectType @dataclass @@ -31,3 +31,39 @@ def create_default_finding(self) -> Finding: @classmethod def get_source(cls) -> FindingSource: return FindingSource.KUBERNETES_API_SERVER + + def get_subject(self) -> FindingSubject: + """Get subject information from the Kubernetes object, including custom resources.""" + if not self.obj: + return FindingSubject(name="Unresolved", subject_type=FindingSubjectType.TYPE_NONE) + + # Handle both Hikaru objects and GenericKubernetesObject + if hasattr(self.obj, 'metadata') and self.obj.metadata: + metadata = self.obj.metadata + name = getattr(metadata, 'name', None) or 'Unresolved' + namespace = getattr(metadata, 'namespace', None) + labels = getattr(metadata, 'labels', {}) or {} + annotations = getattr(metadata, 'annotations', {}) or {} + else: + name = 'Unresolved' + namespace = None + labels = {} + annotations = {} + + # Get the kind from the object + kind = getattr(self.obj, 'kind', None) or 'Unknown' + + # Determine the subject type from the kind + try: + subject_type = FindingSubjectType.from_kind(kind) + except: + # Fallback for unknown kinds + subject_type = FindingSubjectType.TYPE_NONE + + return FindingSubject( + name=name, + subject_type=subject_type, + namespace=namespace, + labels=labels, + annotations=annotations, + ) diff --git a/src/robusta/integrations/kubernetes/base_triggers.py b/src/robusta/integrations/kubernetes/base_triggers.py index 515f77b47..09104c649 100644 --- a/src/robusta/integrations/kubernetes/base_triggers.py +++ b/src/robusta/integrations/kubernetes/base_triggers.py @@ -18,6 +18,26 @@ from robusta.utils.common import duplicate_without_fields, is_matching_diff from robusta.utils.scope import BaseScopeMatcher, ScopeParams + +class GenericKubernetesObject: + """Wrapper for unsupported Kubernetes resources that provides basic metadata access.""" + + def __init__(self, raw_data: Dict[str, Any]): + self._raw_data = raw_data + self.kind = raw_data.get('kind', 'Unknown') + # Create a simple metadata object for compatibility + metadata_data = raw_data.get('metadata', {}) + self.metadata = type('Metadata', (), { + 'name': metadata_data.get('name'), + 'namespace': metadata_data.get('namespace'), + 'labels': metadata_data.get('labels', {}), + 'annotations': metadata_data.get('annotations', {}), + })() + + def __getattr__(self, name): + """Fallback to raw data for any missing attributes.""" + return self._raw_data.get(name) + OBJ = "obj" OLD_OBJ = "old_obj" @@ -219,13 +239,20 @@ def __load_hikaru_obj(cls, obj: Dict[Any, Any], model_class: type) -> HikaruBase @classmethod def __parse_kubernetes_objs(cls, k8s_payload: IncomingK8sEventPayload): - model_class = get_api_version(k8s_payload.apiVersion).get(k8s_payload.kind) + api_version_models = get_api_version(k8s_payload.apiVersion) + model_class = None + if api_version_models: + model_class = api_version_models.get(k8s_payload.kind) + if model_class is None: msg = ( - f"classes for kind {k8s_payload.kind} cannot be found. skipping. description {k8s_payload.description}" + f"classes for kind {k8s_payload.kind} cannot be found. using generic model. description {k8s_payload.description}" ) - logging.error(msg) - raise ModelNotFoundException(msg) + logging.warning(msg) + # For unsupported kinds, we'll create a generic wrapper object from the raw data + obj = GenericKubernetesObject(k8s_payload.obj) + old_obj = GenericKubernetesObject(k8s_payload.oldObj) if k8s_payload.oldObj is not None else None + return obj, old_obj obj = cls.__load_hikaru_obj(k8s_payload.obj, model_class) @@ -247,9 +274,9 @@ def build_execution_event( event_class = KIND_TO_EVENT_CLASS.get(event.k8s_payload.kind.lower()) if event_class is None: logging.info( - f"classes for kind {event.k8s_payload.kind} cannot be found. skipping. description {event.k8s_payload.description}" + f"classes for kind {event.k8s_payload.kind} cannot be found. using KubernetesAnyChangeEvent. description {event.k8s_payload.description}" ) - return None + event_class = KubernetesAnyChangeEvent if build_context and OBJ in build_context.keys() and OLD_OBJ in build_context.keys(): obj = build_context.get(OBJ) old_obj = build_context.get(OLD_OBJ) @@ -285,6 +312,17 @@ def check_change_filters(self, execution_event: ExecutionBaseEvent): result = True filtered_diffs = [] + + # For GenericKubernetesObject, we can't use the duplicate_without_fields method + # so we just set the filtered objects to be the same as the originals + if isinstance(execution_event.obj, GenericKubernetesObject): + obj_filtered = execution_event.obj + old_obj_filtered = execution_event.old_obj + execution_event.obj_filtered = obj_filtered + execution_event.old_obj_filtered = old_obj_filtered + execution_event.filtered_diffs = [] + return True + obj_filtered = duplicate_without_fields(execution_event.obj, self.change_filters.ignore) old_obj_filtered = duplicate_without_fields(execution_event.old_obj, self.change_filters.ignore)