Skip to content

Commit c898237

Browse files
committed
feat(k8s): add Events-API node-disruption watcher and periodic LIST resync backstop
Watch cluster Events (involvedObject=Node) to surface node-disruption signals (Karpenter, cordon/drain) that the pod/node watchers miss, and add an opt-in periodic full-LIST resync so long-lived watch connections that silently drop events self-heal without a restart. Both are opt-in via config (events_watch_enabled, periodic_resync_interval_seconds).
1 parent 2745589 commit c898237

7 files changed

Lines changed: 1557 additions & 19 deletions

File tree

src/orb/providers/k8s/configuration/config.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,38 @@ class K8sProviderConfig(BaseSettings, BaseProviderConfig): # type: ignore[misc]
447447
),
448448
)
449449

450+
# Events API watching (node-disruption visibility)
451+
events_watch_enabled: bool = Field(
452+
False,
453+
description=(
454+
"Opt-in flag for the k8s Events API watch background task. When True, ORB "
455+
"starts a K8sEventsWatcher that streams ``CoreV1Api.list_event_for_all_namespaces`` "
456+
"filtered to ``involvedObject.kind=Node`` and caches Karpenter node-disruption "
457+
"events (e.g. 'Disrupting Node: Underutilized/Delete', 'Disrupting Node: "
458+
"Empty/Delete'). The cached disruption reason is available for surfacing in "
459+
"status responses. Default ``False`` because the events watcher requires "
460+
"an additional RBAC grant (``events: get/list/watch`` on the core API group) "
461+
"that may not exist in every cluster -- see "
462+
"``docs/root/providers/k8s/rbac.yaml`` for the required rule."
463+
),
464+
)
465+
466+
# Periodic full-LIST backstop for the pod watcher
467+
periodic_resync_interval_seconds: int = Field(
468+
0,
469+
description=(
470+
"Interval in seconds at which the pod watcher performs a full LIST of all "
471+
"managed pods and reconciles the in-process cache, independent of 410-Gone "
472+
"responses. Mirrors the legacy RefreshPodsTask (hfcron.py) which ran every "
473+
"~180 s as a correctness backstop against slow-drift apiservers. "
474+
"Default 0 (disabled) to avoid extra apiserver load -- opt in by setting a "
475+
"positive value (e.g. 180). When >0, a background asyncio task wakes every "
476+
"``periodic_resync_interval_seconds`` and calls the same _relist_snapshot "
477+
"path used on 410-Gone recovery, reconciling any cache drift that "
478+
"accumulated during a healthy watch session."
479+
),
480+
)
481+
450482
# Controller-status cache
451483
controller_status_cache_ttl_seconds: float = Field(
452484
5.0,

src/orb/providers/k8s/services/health_check_service.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,10 @@ def register_health_checks(
135135
health_check: "HealthCheck",
136136
kubernetes_client: "K8sClient",
137137
) -> None:
138-
"""Register Kubernetes-specific health checks if the client is reachable."""
139-
try:
140-
client = kubernetes_client
141-
except Exception as exc:
142-
self._logger.debug(
143-
"Skipping Kubernetes health-check registration: %s", exc, exc_info=True
144-
)
145-
return
146-
138+
"""Register Kubernetes-specific health checks."""
147139
from orb.providers.k8s.health import register_k8s_health_checks
148140

149-
register_k8s_health_checks(health_check, client)
141+
register_k8s_health_checks(health_check, kubernetes_client)
150142

151143

152144
__all__ = ["K8sHealthCheckService"]

src/orb/providers/k8s/strategy/k8s_provider_strategy.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
)
5959
from orb.providers.k8s.strategy.handler_registry import K8sHandlerRegistry
6060
from orb.providers.k8s.value_objects import KubernetesProviderApi
61+
from orb.providers.k8s.watch.events_watcher import K8sEventsWatcher, K8sNodeEventsCache
6162
from orb.providers.k8s.watch.multi_namespace import MultiNamespaceWatcher
6263
from orb.providers.k8s.watch.node_state_cache import K8sNodeStateCache
6364
from orb.providers.k8s.watch.node_watcher import K8sNodeWatcher
@@ -157,6 +158,8 @@ def __init__(
157158
orphan_gc: Optional[OrphanGarbageCollector] = None,
158159
node_watcher: Optional[K8sNodeWatcher] = None,
159160
node_state_cache: Optional[K8sNodeStateCache] = None,
161+
events_watcher: Optional[K8sEventsWatcher] = None,
162+
node_events_cache: Optional[K8sNodeEventsCache] = None,
160163
native_spec_service: Optional[Any] = None,
161164
) -> None:
162165
if not isinstance(config, K8sProviderConfig):
@@ -191,6 +194,12 @@ def __init__(
191194
# Tests inject both via the constructor kwargs to avoid real threads.
192195
self._node_state_cache: K8sNodeStateCache = node_state_cache or K8sNodeStateCache()
193196
self._node_watcher: Optional[K8sNodeWatcher] = node_watcher
197+
# Events API watching. When ``events_watch_enabled=True`` (opt-in via
198+
# K8sProviderConfig) the strategy starts a K8sEventsWatcher on a
199+
# background thread and populates K8sNodeEventsCache with Karpenter
200+
# node-disruption events. Tests inject both via constructor kwargs.
201+
self._node_events_cache: K8sNodeEventsCache = node_events_cache or K8sNodeEventsCache()
202+
self._events_watcher: Optional[K8sEventsWatcher] = events_watcher
194203
# Native-spec escape hatch. Resolved lazily on first handler
195204
# construction. ``None`` after resolution means the service is
196205
# unavailable (jinja2 missing, injected service not provided, etc.)
@@ -331,6 +340,7 @@ async def start_daemon_services(self) -> None:
331340
self._maybe_start_watch_manager()
332341
self._maybe_start_orphan_gc()
333342
self._maybe_start_node_watcher()
343+
self._maybe_start_events_watcher()
334344
self._daemon_services_started = True
335345

336346
def cleanup(self) -> None:
@@ -368,6 +378,15 @@ def cleanup(self) -> None:
368378
"Failed to stop Kubernetes node watcher during cleanup: %s", exc, exc_info=True
369379
)
370380

381+
try:
382+
if self._events_watcher is not None:
383+
self._events_watcher.stop()
384+
self._events_watcher = None
385+
except Exception as exc:
386+
self._logger.warning(
387+
"Failed to stop Kubernetes events watcher during cleanup: %s", exc, exc_info=True
388+
)
389+
371390
# Stage 4: client cleanup. Only clear ``_initialized`` when the
372391
# client is successfully cleaned up — if this stage fails the
373392
# provider can still serve reads via the existing connection while
@@ -707,6 +726,48 @@ def _maybe_start_node_watcher(self) -> None:
707726
except Exception as exc:
708727
self._logger.warning("Failed to start Kubernetes node watcher: %s", exc, exc_info=True)
709728

729+
# ------------------------------------------------------------------
730+
# Events watcher lifecycle
731+
# ------------------------------------------------------------------
732+
733+
@property
734+
def node_events_cache(self) -> K8sNodeEventsCache:
735+
"""The shared node-events cache populated by the Events API watcher.
736+
737+
Always present (never ``None``) -- when ``events_watch_enabled`` is
738+
``False`` it is simply an empty cache that returns ``None`` for
739+
every lookup.
740+
"""
741+
return self._node_events_cache
742+
743+
def _maybe_start_events_watcher(self) -> None:
744+
"""Start the Events API watcher when enabled by config.
745+
746+
Like the node watcher, the events watcher runs on a plain
747+
background daemon thread (not in the asyncio event loop) so it
748+
can start from both synchronous (CLI bootstrap) and async
749+
(daemon) contexts.
750+
751+
Requires the operator to have granted the ``events: get/list/watch``
752+
RBAC verb on the core API group -- see
753+
``docs/root/providers/k8s/rbac.yaml``.
754+
"""
755+
if not self._k8s_config.events_watch_enabled:
756+
return
757+
if self._events_watcher is None:
758+
self._events_watcher = K8sEventsWatcher(
759+
kubernetes_client=self.kubernetes_client,
760+
cache=self._node_events_cache,
761+
logger=self._logger,
762+
)
763+
try:
764+
self._events_watcher.start()
765+
self._logger.info("Kubernetes events watcher started (events_watch_enabled=True)")
766+
except Exception as exc:
767+
self._logger.warning(
768+
"Failed to start Kubernetes events watcher: %s", exc, exc_info=True
769+
)
770+
710771
# ------------------------------------------------------------------
711772
# Operation dispatch
712773
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)