Coverage for gco / services / webhook_dispatcher.py: 100.00%
416 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""
2Webhook Dispatcher Service for GCO (Global Capacity Orchestrator on AWS).
4This service monitors Kubernetes job status changes and dispatches webhook
5notifications to registered endpoints. It runs as a background task alongside
6the health monitor or as a standalone service.
8Key Features:
9- Watches Kubernetes jobs for status changes (started, completed, failed)
10- Queries matching webhooks from DynamoDB based on event type and namespace
11- Dispatches HTTP POST requests with JSON payloads
12- Signs payloads with HMAC-SHA256 when a secret is configured
13- Implements retry logic with exponential backoff for failed deliveries
14- Publishes delivery metrics to CloudWatch
16Replica model: the health-monitor runs two replicas for availability, but a
17webhook must fire once per job transition, not once per replica. When a
18``LeaseIdentity`` is configured the dispatcher elects a single deliverer
19through the pre-created ``gco-health-monitor-webhooks`` Lease (see
20``gco.services.leader_lease``): only the holder watches and delivers, a
21standby re-checks the Lease every few seconds, and a replica that becomes
22leader re-seeds its job-state cache first so transitions the previous leader
23already delivered are not fired again. Delivery is therefore at-most-once
24across a failover; transitions inside the takeover window (bounded by the
25lease duration) are dropped rather than duplicated.
27Webhook Payload Format:
28 {
29 "event": "job.completed",
30 "timestamp": "2026-02-04T12:00:00Z",
31 "cluster_id": "gco-cluster-us-east-1",
32 "region": "us-east-1",
33 "job": {
34 "name": "my-job",
35 "namespace": "gco-jobs",
36 "uid": "abc-123",
37 "status": "succeeded",
38 "start_time": "2026-02-04T11:55:00Z",
39 "completion_time": "2026-02-04T12:00:00Z",
40 "succeeded": 1,
41 "failed": 0
42 }
43 }
45HMAC Signature:
46 When a webhook has a secret configured, the payload is signed using
47 HMAC-SHA256. The signature is included in the X-GCO-Signature header
48 as "sha256=<hex_digest>".
50Environment Variables:
51 CLUSTER_NAME: Name of the EKS cluster
52 REGION: AWS region of the cluster
53 WEBHOOK_TIMEOUT: HTTP timeout for webhook calls (default: 30)
54 WEBHOOK_MAX_RETRIES: Maximum total delivery attempts (default: 3)
55 WEBHOOK_RETRY_DELAY: Initial retry delay in seconds (default: 5)
56 WEBHOOKS_TABLE_NAME: DynamoDB table for webhooks
57"""
59from __future__ import annotations
61import asyncio
62import contextlib
63import hashlib
64import hmac
65import ipaddress
66import json
67import logging
68import os
69import socket
70from dataclasses import dataclass, field
71from datetime import UTC, datetime
72from enum import StrEnum
73from typing import Any
74from urllib.parse import ParseResult, urlparse
76import httpx
77from kubernetes import client, config
78from kubernetes.client.models import V1Job
79from kubernetes.client.rest import ApiException
80from kubernetes.watch import Watch
82from gco.services.leader_lease import (
83 LeaseIdentity,
84 lease_duration_from_env,
85 try_acquire_lease,
86)
87from gco.services.template_store import WebhookStore, get_webhook_store
89# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
90# Generated at (UTC): 2026-09-11T22:27:39Z
91# Generated from Git commit: 5d1a9122b6630246e01cafdaf458d11c2da8b4ae
92# Flowchart(s) generated from this file:
93# * ``WebhookDispatcher._deliver_webhook`` -> ``diagrams/code_diagrams/gco/services/webhook_dispatcher.WebhookDispatcher__deliver_webhook.html``
94# (PNG: ``diagrams/code_diagrams/gco/services/webhook_dispatcher.WebhookDispatcher__deliver_webhook.png``)
95# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
96# <pyflowchart-code-diagram> END
99logging.basicConfig(
100 level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
101)
102logger = logging.getLogger(__name__)
104# Networks blocked for SSRF prevention
105BLOCKED_NETWORKS = [
106 ipaddress.ip_network("10.0.0.0/8"),
107 ipaddress.ip_network("172.16.0.0/12"),
108 ipaddress.ip_network("192.168.0.0/16"),
109 ipaddress.ip_network("169.254.0.0/16"),
110 ipaddress.ip_network("127.0.0.0/8"),
111 ipaddress.ip_network("::1/128"),
112 ipaddress.ip_network("fc00::/7"),
113 ipaddress.ip_network("fe80::/10"),
114]
117@dataclass(frozen=True)
118class _ValidatedWebhookTarget:
119 """One DNS-validated destination whose transport cannot resolve again."""
121 original_url: str
122 parsed: ParseResult
123 hostname: str
124 port: int
125 addresses: tuple[str, ...]
127 @property
128 def log_identity(self) -> str:
129 """Return an operator-useful identity without path/query credentials."""
130 return f"host={self.hostname} port={self.port}"
132 @property
133 def host_header(self) -> str:
134 return self.parsed.netloc
136 def pinned_url(self, attempt: int) -> str:
137 address = self.addresses[(attempt - 1) % len(self.addresses)]
138 host = f"[{address}]" if ":" in address else address
139 return self.parsed._replace(netloc=f"{host}:{self.port}").geturl()
142def _globally_routable_unicast(
143 value: str,
144) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
145 """Return a canonical public-unicast address, including mapped IPv4.
147 ``is_global`` intentionally does not exclude multicast in ``ipaddress``.
148 Normalize IPv4-mapped IPv6 first, then require a globally routable,
149 non-multicast unicast address so loopback, private, link-local, shared,
150 documentation, reserved, unspecified, and multicast destinations all fail
151 closed.
152 """
153 try:
154 address = ipaddress.ip_address(value)
155 except ValueError:
156 return None
157 if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
158 address = address.ipv4_mapped
159 if (
160 not address.is_global
161 or address.is_multicast
162 or address.is_unspecified
163 or address.is_loopback
164 or address.is_link_local
165 or address.is_reserved
166 ):
167 return None
168 return address
171def _resolve_webhook_target(
172 url: str,
173 allowed_domains: list[str] | None = None,
174) -> tuple[_ValidatedWebhookTarget | None, str | None]:
175 """Resolve and approve every address before any outbound connection."""
176 parsed = urlparse(url)
177 if parsed.scheme != "https":
178 return None, "Only HTTPS webhook URLs are allowed"
179 if parsed.username is not None or parsed.password is not None:
180 return None, "Webhook URLs must not contain userinfo credentials"
182 hostname = parsed.hostname
183 if not hostname:
184 return None, "Webhook URL must include a valid hostname"
185 normalized_hostname = hostname.rstrip(".").lower()
186 normalized_allowed = {value.rstrip(".").lower() for value in allowed_domains or []}
187 if normalized_allowed and normalized_hostname not in normalized_allowed:
188 return None, f"Domain '{hostname}' not in allowed domains list"
190 try:
191 port = parsed.port or 443
192 except ValueError:
193 return None, "Webhook URL contains an invalid port"
194 try:
195 resolved = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP)
196 except socket.gaierror:
197 return None, f"DNS resolution failed for {hostname}"
198 if not resolved:
199 return None, f"DNS resolution returned no results for {hostname}"
201 addresses: set[str] = set()
202 for family, _type, _proto, _canonname, sockaddr in resolved:
203 if family not in {socket.AF_INET, socket.AF_INET6}:
204 return None, "DNS resolution returned an unsupported address family"
205 raw_address = sockaddr[0]
206 if not isinstance(raw_address, str):
207 return None, "DNS resolution returned a non-text IP address"
208 address = _globally_routable_unicast(raw_address)
209 if address is None:
210 return None, (
211 f"Resolved IP {raw_address} is blocked because it is not globally routable unicast"
212 )
213 addresses.add(str(address))
214 if not addresses:
215 return None, f"DNS resolution returned no usable addresses for {hostname}"
217 return (
218 _ValidatedWebhookTarget(
219 original_url=url,
220 parsed=parsed,
221 hostname=hostname,
222 port=port,
223 addresses=tuple(sorted(addresses)),
224 ),
225 None,
226 )
229def validate_webhook_url(
230 url: str, allowed_domains: list[str] | None = None
231) -> tuple[bool, str | None]:
232 """Validate HTTPS, allowlist, DNS, and blocked-network constraints."""
233 target, error = _resolve_webhook_target(url, allowed_domains)
234 return target is not None, error
237class WebhookEvent(StrEnum):
238 """Webhook event types."""
240 JOB_STARTED = "job.started"
241 JOB_COMPLETED = "job.completed"
242 JOB_FAILED = "job.failed"
245@dataclass
246class WebhookDeliveryResult:
247 """Result of a webhook delivery attempt."""
249 webhook_id: str
250 url: str
251 event: str
252 success: bool
253 status_code: int | None = None
254 error: str | None = None
255 attempts: int = 1
256 duration_ms: float = 0.0
259@dataclass
260class JobStateCache:
261 """Cache of job states to detect transitions."""
263 # Map of job_uid -> last known status
264 job_states: dict[str, str] = field(default_factory=dict)
266 def get_state(self, job_uid: str) -> str | None:
267 """Get cached state for a job."""
268 return self.job_states.get(job_uid)
270 def set_state(self, job_uid: str, state: str) -> str | None:
271 """Set state for a job, returns previous state."""
272 previous = self.job_states.get(job_uid)
273 self.job_states[job_uid] = state
274 return previous
276 def remove(self, job_uid: str) -> None:
277 """Remove a job from the cache."""
278 self.job_states.pop(job_uid, None)
281class WebhookDispatcher:
282 """
283 Dispatches webhook notifications for Kubernetes job events.
285 This class monitors job status changes and sends HTTP notifications
286 to registered webhook endpoints.
287 """
289 def __init__(
290 self,
291 cluster_id: str,
292 region: str,
293 webhook_store: WebhookStore | None = None,
294 timeout: int = 30,
295 max_retries: int = 3,
296 retry_delay: int = 5,
297 namespaces: list[str] | None = None,
298 allowed_domains: list[str] | None = None,
299 leader_lease: LeaseIdentity | None = None,
300 standby_poll_seconds: float = 5.0,
301 ):
302 """Initialize the webhook dispatcher.
304 Args:
305 cluster_id: EKS cluster identifier
306 region: AWS region
307 webhook_store: DynamoDB webhook store (uses singleton if None)
308 timeout: HTTP timeout for webhook calls in seconds
309 max_retries: Maximum total delivery attempts (initial request included)
310 retry_delay: Initial retry delay in seconds (doubles each retry)
311 namespaces: Namespaces to watch (None = all non-system namespaces)
312 allowed_domains: Optional list of allowed webhook domains for SSRF prevention
313 leader_lease: Lease to hold before watching and delivering; ``None``
314 runs single-replica mode (standalone use and unit tests)
315 standby_poll_seconds: how often a non-leader re-checks the Lease
316 """
317 self.cluster_id = cluster_id
318 self.region = region
319 self.webhook_store = webhook_store or get_webhook_store()
320 self.timeout = timeout
321 self.max_retries = max_retries
322 self.retry_delay = retry_delay
323 self.namespaces = namespaces or ["gco-jobs", "default"]
324 self.allowed_domains = allowed_domains or []
326 # Initialize Kubernetes client
327 try:
328 config.load_incluster_config()
329 logger.info("Loaded in-cluster Kubernetes configuration")
330 except config.ConfigException:
331 try:
332 config.load_kube_config()
333 logger.info("Loaded local Kubernetes configuration")
334 except config.ConfigException as e:
335 logger.error(f"Failed to load Kubernetes configuration: {e}")
336 raise
338 self.batch_v1 = client.BatchV1Api()
339 self.coordination_v1 = client.CoordinationV1Api()
341 # Timeout for Kubernetes API calls (seconds)
342 self._k8s_timeout = int(os.environ.get("K8S_API_TIMEOUT", "30"))
344 # Leader election (None = this process is the only deliverer)
345 self._leader_lease = leader_lease
346 self._standby_poll_seconds = standby_poll_seconds
347 self._is_leader = False
349 # State tracking
350 self._job_state_cache = JobStateCache()
351 self._running = False
352 self._watch_task: asyncio.Task[None] | None = None
354 # Metrics
355 self._deliveries_total = 0
356 self._deliveries_success = 0
357 self._deliveries_failed = 0
359 def _compute_job_status(self, job: V1Job) -> str:
360 """Compute the effective status of a Kubernetes job."""
361 status = job.status
362 conditions = status.conditions or []
364 for condition in conditions:
365 if condition.type == "Complete" and condition.status == "True":
366 return "succeeded"
367 if condition.type == "Failed" and condition.status == "True":
368 return "failed"
370 if (status.active or 0) > 0:
371 return "running"
373 if (status.succeeded or 0) > 0:
374 return "succeeded"
376 if (status.failed or 0) > 0:
377 return "failed"
379 return "pending"
381 def _determine_event(
382 self, previous_status: str | None, current_status: str
383 ) -> WebhookEvent | None:
384 """Determine which webhook event to fire based on status transition."""
385 if previous_status is None:
386 # New job - check if it's already running
387 if current_status == "running":
388 return WebhookEvent.JOB_STARTED
389 return None
391 # Status transitions
392 if previous_status in ("pending",) and current_status == "running":
393 return WebhookEvent.JOB_STARTED
395 if previous_status in ("pending", "running") and current_status == "succeeded":
396 return WebhookEvent.JOB_COMPLETED
398 if previous_status in ("pending", "running") and current_status == "failed":
399 return WebhookEvent.JOB_FAILED
401 return None
403 def _build_payload(self, event: WebhookEvent, job: V1Job) -> dict[str, Any]:
404 """Build the webhook payload for a job event."""
405 metadata = job.metadata
406 status = job.status
408 return {
409 "event": event.value,
410 "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
411 "cluster_id": self.cluster_id,
412 "region": self.region,
413 "job": {
414 "name": metadata.name,
415 "namespace": metadata.namespace,
416 "uid": metadata.uid,
417 "labels": metadata.labels or {},
418 "status": self._compute_job_status(job),
419 "start_time": (status.start_time.isoformat() if status.start_time else None),
420 "completion_time": (
421 status.completion_time.isoformat() if status.completion_time else None
422 ),
423 "active": status.active or 0,
424 "succeeded": status.succeeded or 0,
425 "failed": status.failed or 0,
426 },
427 }
429 def _sign_payload(self, payload: str, secret: str) -> str:
430 """Sign a payload using HMAC-SHA256."""
431 signature = hmac.new(
432 secret.encode("utf-8"),
433 payload.encode("utf-8"),
434 hashlib.sha256,
435 ).hexdigest()
436 return f"sha256={signature}"
438 async def _deliver_webhook(
439 self,
440 webhook: dict[str, Any],
441 payload: dict[str, Any],
442 ) -> WebhookDeliveryResult:
443 """Deliver one logical webhook with bounded, address-pinned attempts."""
444 raw_webhook_id = webhook.get("id")
445 webhook_id = raw_webhook_id if isinstance(raw_webhook_id, str) else "<unknown>"
446 raw_url = webhook.get("url")
447 url = raw_url if isinstance(raw_url, str) else ""
448 raw_event = payload.get("event")
449 event = raw_event if isinstance(raw_event, str) else "unknown"
450 secret = webhook.get("secret")
451 start_time = datetime.now(UTC)
452 self._deliveries_total += 1
454 target: _ValidatedWebhookTarget | None = None
455 attempts = 0
456 last_error: str | None = None
457 last_status_code: int | None = None
458 successful_status_code: int | None = None
459 validation_error: str | None = None
460 try:
461 if not isinstance(raw_url, str):
462 validation_error = "Webhook URL must be a string"
463 else:
464 target, validation_error = _resolve_webhook_target(
465 raw_url, self.allowed_domains or None
466 )
467 if target is None:
468 last_error = f"URL validation failed: {validation_error}"
469 logger.warning(
470 "Webhook URL validation failed: webhook_id=%s error=%s",
471 webhook_id,
472 validation_error,
473 )
474 else:
475 payload_json = json.dumps(payload)
476 headers = {
477 "Content-Type": "application/json",
478 "Host": target.host_header,
479 "User-Agent": f"GCO-Webhook/{self.cluster_id}",
480 "X-GCO-Event": event,
481 "X-GCO-Cluster": self.cluster_id,
482 "X-GCO-Region": self.region,
483 }
484 if secret:
485 headers["X-GCO-Signature"] = self._sign_payload(payload_json, secret)
487 async with httpx.AsyncClient(timeout=self.timeout) as client:
488 while attempts < self.max_retries:
489 attempts += 1
490 try:
491 response = await client.post(
492 target.pinned_url(attempts),
493 content=payload_json,
494 headers=headers,
495 extensions={"sni_hostname": target.hostname},
496 )
497 last_status_code = response.status_code
498 if 200 <= response.status_code < 300:
499 successful_status_code = response.status_code
500 break
502 last_error = f"HTTP {response.status_code}"
503 if response.status_code >= 500:
504 logger.warning(
505 "Webhook attempt failed: webhook_id=%s %s status=%s attempt=%s",
506 webhook_id,
507 target.log_identity,
508 response.status_code,
509 attempts,
510 )
511 if attempts < self.max_retries:
512 await asyncio.sleep(self.retry_delay * (2 ** (attempts - 1)))
513 continue
514 break # Caller errors are terminal and must not be replayed.
516 except httpx.TimeoutException:
517 last_error = "Request timed out"
518 logger.warning(
519 "Webhook attempt timed out: webhook_id=%s %s attempt=%s",
520 webhook_id,
521 target.log_identity,
522 attempts,
523 )
524 if attempts < self.max_retries:
525 await asyncio.sleep(self.retry_delay * (2 ** (attempts - 1)))
526 except httpx.RequestError as exc:
527 last_error = type(exc).__name__
528 logger.warning(
529 "Webhook transport failed: webhook_id=%s %s "
530 "error_type=%s attempt=%s",
531 webhook_id,
532 target.log_identity,
533 type(exc).__name__,
534 attempts,
535 )
536 if attempts < self.max_retries:
537 await asyncio.sleep(self.retry_delay * (2 ** (attempts - 1)))
539 # Exiting AsyncClient may itself await and be cancelled. Commit
540 # success only after teardown completes so one delivery has
541 # exactly one terminal metric classification.
542 if successful_status_code is not None:
543 duration = (datetime.now(UTC) - start_time).total_seconds() * 1000
544 logger.info(
545 "Webhook delivered: webhook_id=%s %s status=%s attempts=%s",
546 webhook_id,
547 target.log_identity,
548 successful_status_code,
549 attempts,
550 )
551 self._deliveries_success += 1
552 return WebhookDeliveryResult(
553 webhook_id=webhook_id,
554 url=url,
555 event=event,
556 success=True,
557 status_code=successful_status_code,
558 attempts=attempts,
559 duration_ms=duration,
560 )
561 except asyncio.CancelledError:
562 # Cancellation is a terminal outcome for this logical delivery.
563 # Preserve task cancellation while keeping total == success + failed.
564 self._deliveries_failed += 1
565 logger.info("Webhook delivery cancelled: webhook_id=%s", webhook_id)
566 raise
567 except Exception as exc: # noqa: BLE001 — sanitize the logical delivery boundary
568 last_error = f"Delivery failure: {type(exc).__name__}"
569 logger.error(
570 "Webhook delivery raised: webhook_id=%s error_type=%s",
571 webhook_id,
572 type(exc).__name__,
573 )
575 duration = (datetime.now(UTC) - start_time).total_seconds() * 1000
576 log_identity = target.log_identity if target is not None else "target=unvalidated"
577 logger.error(
578 "Webhook delivery failed: webhook_id=%s %s attempts=%s status=%s error=%s",
579 webhook_id,
580 log_identity,
581 attempts,
582 last_status_code,
583 last_error,
584 )
585 self._deliveries_failed += 1
586 return WebhookDeliveryResult(
587 webhook_id=webhook_id,
588 url=url,
589 event=event,
590 success=False,
591 status_code=last_status_code,
592 error=last_error,
593 attempts=attempts,
594 duration_ms=duration,
595 )
597 async def _dispatch_event(self, event: WebhookEvent, job: V1Job) -> list[WebhookDeliveryResult]:
598 """Dispatch webhooks for a job event."""
599 namespace = job.metadata.namespace
600 payload = self._build_payload(event, job)
602 # Get webhooks subscribed to this event
603 try:
604 # Get webhooks for this specific namespace
605 namespace_webhooks = self.webhook_store.get_webhooks_for_event(
606 event.value, namespace=namespace
607 )
608 # Get global webhooks (no namespace filter)
609 global_webhooks = self.webhook_store.get_webhooks_for_event(event.value, namespace=None)
611 # Combine and deduplicate
612 all_webhooks = {w["id"]: w for w in namespace_webhooks}
613 for w in global_webhooks:
614 if w.get("namespace") is None: # Only add truly global webhooks
615 all_webhooks[w["id"]] = w
617 webhooks = list(all_webhooks.values())
619 except Exception as exc:
620 logger.error(
621 "Failed to get webhooks for event %s: error_type=%s",
622 event.value,
623 type(exc).__name__,
624 )
625 return []
627 if not webhooks:
628 logger.debug(f"No webhooks registered for event {event.value} in namespace {namespace}")
629 return []
631 logger.info(
632 f"Dispatching {len(webhooks)} webhooks for {event.value} "
633 f"(job={job.metadata.name}, namespace={namespace})"
634 )
636 # Dispatch all webhooks concurrently. _deliver_webhook owns normal
637 # exception conversion; this boundary protects accounting and redaction
638 # if a future regression (or an injected implementation) escapes it.
639 tasks = [self._deliver_webhook(webhook, payload) for webhook in webhooks]
640 results = await asyncio.gather(*tasks, return_exceptions=True)
642 delivery_results: list[WebhookDeliveryResult] = []
643 for webhook, result in zip(webhooks, results, strict=True):
644 if isinstance(result, WebhookDeliveryResult):
645 delivery_results.append(result)
646 continue
647 if isinstance(result, asyncio.CancelledError):
648 raise result
649 raw_webhook_id = webhook.get("id")
650 webhook_id = raw_webhook_id if isinstance(raw_webhook_id, str) else "<unknown>"
651 raw_url = webhook.get("url")
652 url = raw_url if isinstance(raw_url, str) else ""
653 logger.error(
654 "Webhook delivery escaped boundary: webhook_id=%s error_type=%s",
655 webhook_id,
656 type(result).__name__,
657 )
658 self._deliveries_total += 1
659 self._deliveries_failed += 1
660 delivery_results.append(
661 WebhookDeliveryResult(
662 webhook_id=webhook_id,
663 url=url,
664 event=event.value,
665 success=False,
666 error=f"Delivery failure: {type(result).__name__}",
667 attempts=0,
668 duration_ms=0.0,
669 )
670 )
672 return delivery_results
674 async def _process_job_event(self, event_type: str, job: V1Job) -> None:
675 """Process a Kubernetes job event."""
676 job_uid = job.metadata.uid
677 job_name = job.metadata.name
678 namespace = job.metadata.namespace
680 # Skip system namespaces
681 if namespace in ("kube-system", "kube-public", "kube-node-lease"):
682 return
684 # Skip if not in watched namespaces (if specified)
685 if self.namespaces and namespace not in self.namespaces:
686 return
688 current_status = self._compute_job_status(job)
690 if event_type == "DELETED":
691 self._job_state_cache.remove(job_uid)
692 return
694 # Get previous state and update cache
695 previous_status = self._job_state_cache.set_state(job_uid, current_status)
697 # Determine if we should fire an event
698 webhook_event = self._determine_event(previous_status, current_status)
700 if webhook_event:
701 logger.info(
702 f"Job status transition: {job_name} ({namespace}) "
703 f"{previous_status or 'new'} -> {current_status} "
704 f"-> firing {webhook_event.value}"
705 )
706 await self._dispatch_event(webhook_event, job)
708 def _sync_watch_jobs(self) -> list[tuple[str, Any]]:
709 """
710 Synchronous job watcher that yields batches of events.
711 This runs in a thread executor to avoid blocking the async event loop.
712 """
713 w = Watch()
714 events = []
716 try:
717 # Watch jobs with a short timeout to allow periodic returns
718 for event in w.stream(
719 self.batch_v1.list_job_for_all_namespaces,
720 timeout_seconds=30, # Short timeout to return control periodically
721 ):
722 if not self._running:
723 break
725 events.append((event["type"], event["object"]))
727 # Return batch after collecting some events or if we have any
728 if len(events) >= 10:
729 break
731 except ApiException as e:
732 if e.status == 410: # Gone - resource version too old
733 logger.warning("Watch expired, will restart...")
734 else:
735 logger.error(f"Kubernetes API error in job watcher: {e}")
736 raise
737 except Exception as e:
738 logger.error(f"Error in sync job watcher: {e}")
739 raise
741 return events
743 async def _acquire_leadership(self) -> bool:
744 """Hold (or keep holding) the deliverer Lease; ``True`` means deliver.
746 Off the event loop because the Lease read/replace is a blocking
747 Kubernetes round trip. Winning the Lease re-seeds the job-state cache:
748 the new leader must not replay transitions the old one already
749 delivered, and anything that happened while nobody held the Lease is
750 deliberately dropped rather than duplicated.
751 """
752 if self._leader_lease is None:
753 return True
754 held = await asyncio.to_thread(
755 try_acquire_lease,
756 self.coordination_v1,
757 self._leader_lease,
758 label="webhook",
759 )
760 if held and not self._is_leader:
761 logger.info(
762 "Webhook dispatcher %s became the deliverer; re-seeding job state cache",
763 self._leader_lease.holder,
764 )
765 await self._initialize_job_cache()
766 elif not held and self._is_leader:
767 logger.warning("Webhook dispatcher lost the deliverer Lease; standing by")
768 self._is_leader = held
769 return held
771 async def _watch_jobs(self) -> None:
772 """Watch Kubernetes jobs for status changes using thread executor."""
773 logger.info(f"Starting job watcher for namespaces: {self.namespaces}")
775 while self._running:
776 try:
777 if not await self._acquire_leadership():
778 await asyncio.sleep(self._standby_poll_seconds)
779 continue
781 # Run the synchronous watch in a thread executor
782 events = await asyncio.to_thread(self._sync_watch_jobs)
784 # Process collected events
785 for event_type, job in events:
786 if not self._running:
787 break
789 try:
790 await self._process_job_event(event_type, job)
791 except Exception as e:
792 logger.error(f"Error processing job event: {e}")
794 # Small delay between watch cycles if no events
795 if not events:
796 await asyncio.sleep(1)
798 except Exception as e:
799 logger.error(f"Error in job watcher: {e}")
800 await asyncio.sleep(5)
802 async def start(self) -> None:
803 """Start the webhook dispatcher."""
804 if self._running:
805 logger.warning("Webhook dispatcher already running")
806 return
808 self._running = True
809 logger.info(f"Starting webhook dispatcher for cluster {self.cluster_id}")
811 if self._leader_lease is None:
812 # Single deliverer: seed the job state cache with current jobs now.
813 # With a Lease the cache is seeded at the moment leadership is won,
814 # so a standby never fires transitions the leader already delivered.
815 await self._initialize_job_cache()
817 # Start the watch task
818 self._watch_task = asyncio.create_task(self._watch_jobs())
820 async def stop(self) -> None:
821 """Stop the webhook dispatcher."""
822 logger.info("Stopping webhook dispatcher")
823 self._running = False
825 if self._watch_task:
826 self._watch_task.cancel()
827 with contextlib.suppress(asyncio.CancelledError):
828 await self._watch_task
829 self._watch_task = None
831 async def _initialize_job_cache(self) -> None:
832 """Initialize the job state cache with current job states."""
833 try:
834 # Off the event loop: the synchronous client would otherwise stall
835 # every other task for the duration of the list (and its retries).
836 jobs = await asyncio.to_thread(
837 self.batch_v1.list_job_for_all_namespaces,
838 _request_timeout=self._k8s_timeout,
839 )
840 for job in jobs.items:
841 namespace = job.metadata.namespace
842 if namespace in ("kube-system", "kube-public", "kube-node-lease"):
843 continue
844 if self.namespaces and namespace not in self.namespaces:
845 continue
847 job_uid = job.metadata.uid
848 status = self._compute_job_status(job)
849 self._job_state_cache.set_state(job_uid, status)
851 logger.info(f"Initialized job cache with {len(self._job_state_cache.job_states)} jobs")
852 except Exception as e:
853 logger.error(f"Failed to initialize job cache: {e}")
855 def get_metrics(self) -> dict[str, Any]:
856 """Get dispatcher metrics."""
857 return {
858 "deliveries_total": self._deliveries_total,
859 "deliveries_success": self._deliveries_success,
860 "deliveries_failed": self._deliveries_failed,
861 "cached_jobs": len(self._job_state_cache.job_states),
862 "running": self._running,
863 "leader": self._leader_lease is None or self._is_leader,
864 }
867def create_webhook_dispatcher_from_env() -> WebhookDispatcher:
868 """Create WebhookDispatcher instance from environment variables."""
869 cluster_id = os.getenv("CLUSTER_NAME", "unknown-cluster")
870 region = os.getenv("REGION", "unknown-region")
871 timeout = int(os.getenv("WEBHOOK_TIMEOUT", "30"))
872 max_retries = int(os.getenv("WEBHOOK_MAX_RETRIES", "3"))
873 retry_delay = int(os.getenv("WEBHOOK_RETRY_DELAY", "5"))
875 # Parse namespaces from env
876 namespaces_str = os.getenv("ALLOWED_NAMESPACES", "gco-jobs,default")
877 namespaces = [ns.strip() for ns in namespaces_str.split(",") if ns.strip()]
879 # Parse allowed domains from env (comma-separated)
880 allowed_domains_str = os.getenv("WEBHOOK_ALLOWED_DOMAINS", "")
881 allowed_domains = [d.strip() for d in allowed_domains_str.split(",") if d.strip()]
883 # Deliverer election. The Lease is pre-created by 02-rbac.yaml; an empty
884 # WEBHOOK_LEASE_NAME opts out (single-replica or standalone runs).
885 lease_name = os.getenv("WEBHOOK_LEASE_NAME", "gco-health-monitor-webhooks").strip()
886 leader_lease = None
887 if lease_name:
888 leader_lease = LeaseIdentity(
889 name=lease_name,
890 namespace=os.getenv("POD_NAMESPACE", "gco-system"),
891 holder=(
892 os.getenv("POD_NAME")
893 or os.getenv("HOSTNAME")
894 or f"webhook-dispatcher-{os.getpid()}"
895 ),
896 duration_seconds=lease_duration_from_env(
897 os.getenv("WEBHOOK_LEASE_DURATION", "90"), label="WEBHOOK_LEASE_DURATION"
898 ),
899 )
901 return WebhookDispatcher(
902 cluster_id=cluster_id,
903 region=region,
904 timeout=timeout,
905 max_retries=max_retries,
906 retry_delay=retry_delay,
907 namespaces=namespaces,
908 allowed_domains=allowed_domains,
909 leader_lease=leader_lease,
910 )
913async def main() -> None:
914 """Main function for running the webhook dispatcher standalone."""
915 dispatcher = create_webhook_dispatcher_from_env()
917 try:
918 await dispatcher.start()
920 # Keep running until interrupted
921 while True:
922 await asyncio.sleep(60)
923 metrics = dispatcher.get_metrics()
924 logger.info(f"Webhook dispatcher metrics: {metrics}")
926 except KeyboardInterrupt:
927 logger.info("Webhook dispatcher stopped by user")
928 finally:
929 await dispatcher.stop()
932if __name__ == "__main__":
933 asyncio.run(main())