Coverage for gco / services / webhook_dispatcher.py: 100.00%
416 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +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 httpx2
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-10-08T19:00:07Z
91# Generated from Git commit: 231725d929e3dfd3c3768eba41d413effef39d67
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 # typeshed (mypy 2.4) types every getaddrinfo result as AF_INET or
204 # AF_INET6, so it calls this branch dead; the resolver is C library
205 # code outside that model, and an SSRF guard fails closed on purpose.
206 if family not in {socket.AF_INET, socket.AF_INET6}:
207 return None, "DNS resolution returned an unsupported address family" # type: ignore[unreachable]
208 raw_address = sockaddr[0]
209 if not isinstance(raw_address, str):
210 return None, "DNS resolution returned a non-text IP address"
211 address = _globally_routable_unicast(raw_address)
212 if address is None:
213 return None, (
214 f"Resolved IP {raw_address} is blocked because it is not globally routable unicast"
215 )
216 addresses.add(str(address))
217 if not addresses:
218 return None, f"DNS resolution returned no usable addresses for {hostname}"
220 return (
221 _ValidatedWebhookTarget(
222 original_url=url,
223 parsed=parsed,
224 hostname=hostname,
225 port=port,
226 addresses=tuple(sorted(addresses)),
227 ),
228 None,
229 )
232def validate_webhook_url(
233 url: str, allowed_domains: list[str] | None = None
234) -> tuple[bool, str | None]:
235 """Validate HTTPS, allowlist, DNS, and blocked-network constraints."""
236 target, error = _resolve_webhook_target(url, allowed_domains)
237 return target is not None, error
240class WebhookEvent(StrEnum):
241 """Webhook event types."""
243 JOB_STARTED = "job.started"
244 JOB_COMPLETED = "job.completed"
245 JOB_FAILED = "job.failed"
248@dataclass
249class WebhookDeliveryResult:
250 """Result of a webhook delivery attempt."""
252 webhook_id: str
253 url: str
254 event: str
255 success: bool
256 status_code: int | None = None
257 error: str | None = None
258 attempts: int = 1
259 duration_ms: float = 0.0
262@dataclass
263class JobStateCache:
264 """Cache of job states to detect transitions."""
266 # Map of job_uid -> last known status
267 job_states: dict[str, str] = field(default_factory=dict)
269 def get_state(self, job_uid: str) -> str | None:
270 """Get cached state for a job."""
271 return self.job_states.get(job_uid)
273 def set_state(self, job_uid: str, state: str) -> str | None:
274 """Set state for a job, returns previous state."""
275 previous = self.job_states.get(job_uid)
276 self.job_states[job_uid] = state
277 return previous
279 def remove(self, job_uid: str) -> None:
280 """Remove a job from the cache."""
281 self.job_states.pop(job_uid, None)
284class WebhookDispatcher:
285 """
286 Dispatches webhook notifications for Kubernetes job events.
288 This class monitors job status changes and sends HTTP notifications
289 to registered webhook endpoints.
290 """
292 def __init__(
293 self,
294 cluster_id: str,
295 region: str,
296 webhook_store: WebhookStore | None = None,
297 timeout: int = 30,
298 max_retries: int = 3,
299 retry_delay: int = 5,
300 namespaces: list[str] | None = None,
301 allowed_domains: list[str] | None = None,
302 leader_lease: LeaseIdentity | None = None,
303 standby_poll_seconds: float = 5.0,
304 ):
305 """Initialize the webhook dispatcher.
307 Args:
308 cluster_id: EKS cluster identifier
309 region: AWS region
310 webhook_store: DynamoDB webhook store (uses singleton if None)
311 timeout: HTTP timeout for webhook calls in seconds
312 max_retries: Maximum total delivery attempts (initial request included)
313 retry_delay: Initial retry delay in seconds (doubles each retry)
314 namespaces: Namespaces to watch (None = all non-system namespaces)
315 allowed_domains: Optional list of allowed webhook domains for SSRF prevention
316 leader_lease: Lease to hold before watching and delivering; ``None``
317 runs single-replica mode (standalone use and unit tests)
318 standby_poll_seconds: how often a non-leader re-checks the Lease
319 """
320 self.cluster_id = cluster_id
321 self.region = region
322 self.webhook_store = webhook_store or get_webhook_store()
323 self.timeout = timeout
324 self.max_retries = max_retries
325 self.retry_delay = retry_delay
326 self.namespaces = namespaces or ["gco-jobs", "default"]
327 self.allowed_domains = allowed_domains or []
329 # Initialize Kubernetes client
330 try:
331 config.load_incluster_config()
332 logger.info("Loaded in-cluster Kubernetes configuration")
333 except config.ConfigException:
334 try:
335 config.load_kube_config()
336 logger.info("Loaded local Kubernetes configuration")
337 except config.ConfigException as e:
338 logger.error(f"Failed to load Kubernetes configuration: {e}")
339 raise
341 self.batch_v1 = client.BatchV1Api()
342 self.coordination_v1 = client.CoordinationV1Api()
344 # Timeout for Kubernetes API calls (seconds)
345 self._k8s_timeout = int(os.environ.get("K8S_API_TIMEOUT", "30"))
347 # Leader election (None = this process is the only deliverer)
348 self._leader_lease = leader_lease
349 self._standby_poll_seconds = standby_poll_seconds
350 self._is_leader = False
352 # State tracking
353 self._job_state_cache = JobStateCache()
354 self._running = False
355 self._watch_task: asyncio.Task[None] | None = None
357 # Metrics
358 self._deliveries_total = 0
359 self._deliveries_success = 0
360 self._deliveries_failed = 0
362 def _compute_job_status(self, job: V1Job) -> str:
363 """Compute the effective status of a Kubernetes job."""
364 status = job.status
365 conditions = status.conditions or []
367 for condition in conditions:
368 if condition.type == "Complete" and condition.status == "True":
369 return "succeeded"
370 if condition.type == "Failed" and condition.status == "True":
371 return "failed"
373 if (status.active or 0) > 0:
374 return "running"
376 if (status.succeeded or 0) > 0:
377 return "succeeded"
379 if (status.failed or 0) > 0:
380 return "failed"
382 return "pending"
384 def _determine_event(
385 self, previous_status: str | None, current_status: str
386 ) -> WebhookEvent | None:
387 """Determine which webhook event to fire based on status transition."""
388 if previous_status is None:
389 # New job - check if it's already running
390 if current_status == "running":
391 return WebhookEvent.JOB_STARTED
392 return None
394 # Status transitions
395 if previous_status in ("pending",) and current_status == "running":
396 return WebhookEvent.JOB_STARTED
398 if previous_status in ("pending", "running") and current_status == "succeeded":
399 return WebhookEvent.JOB_COMPLETED
401 if previous_status in ("pending", "running") and current_status == "failed":
402 return WebhookEvent.JOB_FAILED
404 return None
406 def _build_payload(self, event: WebhookEvent, job: V1Job) -> dict[str, Any]:
407 """Build the webhook payload for a job event."""
408 metadata = job.metadata
409 status = job.status
411 return {
412 "event": event.value,
413 "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
414 "cluster_id": self.cluster_id,
415 "region": self.region,
416 "job": {
417 "name": metadata.name,
418 "namespace": metadata.namespace,
419 "uid": metadata.uid,
420 "labels": metadata.labels or {},
421 "status": self._compute_job_status(job),
422 "start_time": (status.start_time.isoformat() if status.start_time else None),
423 "completion_time": (
424 status.completion_time.isoformat() if status.completion_time else None
425 ),
426 "active": status.active or 0,
427 "succeeded": status.succeeded or 0,
428 "failed": status.failed or 0,
429 },
430 }
432 def _sign_payload(self, payload: str, secret: str) -> str:
433 """Sign a payload using HMAC-SHA256."""
434 signature = hmac.new(
435 secret.encode("utf-8"),
436 payload.encode("utf-8"),
437 hashlib.sha256,
438 ).hexdigest()
439 return f"sha256={signature}"
441 async def _deliver_webhook(
442 self,
443 webhook: dict[str, Any],
444 payload: dict[str, Any],
445 ) -> WebhookDeliveryResult:
446 """Deliver one logical webhook with bounded, address-pinned attempts."""
447 raw_webhook_id = webhook.get("id")
448 webhook_id = raw_webhook_id if isinstance(raw_webhook_id, str) else "<unknown>"
449 raw_url = webhook.get("url")
450 url = raw_url if isinstance(raw_url, str) else ""
451 raw_event = payload.get("event")
452 event = raw_event if isinstance(raw_event, str) else "unknown"
453 secret = webhook.get("secret")
454 start_time = datetime.now(UTC)
455 self._deliveries_total += 1
457 target: _ValidatedWebhookTarget | None = None
458 attempts = 0
459 last_error: str | None = None
460 last_status_code: int | None = None
461 successful_status_code: int | None = None
462 validation_error: str | None = None
463 try:
464 if not isinstance(raw_url, str):
465 validation_error = "Webhook URL must be a string"
466 else:
467 target, validation_error = _resolve_webhook_target(
468 raw_url, self.allowed_domains or None
469 )
470 if target is None:
471 last_error = f"URL validation failed: {validation_error}"
472 logger.warning(
473 "Webhook URL validation failed: webhook_id=%s error=%s",
474 webhook_id,
475 validation_error,
476 )
477 else:
478 payload_json = json.dumps(payload)
479 headers = {
480 "Content-Type": "application/json",
481 "Host": target.host_header,
482 "User-Agent": f"GCO-Webhook/{self.cluster_id}",
483 "X-GCO-Event": event,
484 "X-GCO-Cluster": self.cluster_id,
485 "X-GCO-Region": self.region,
486 }
487 if secret:
488 headers["X-GCO-Signature"] = self._sign_payload(payload_json, secret)
490 # Public trust (the system store) and never a tracing transport:
491 # webhook URL paths and queries routinely carry credentials,
492 # and a client span would record the full URL.
493 async with httpx2.AsyncClient(timeout=self.timeout) as client:
494 while attempts < self.max_retries:
495 attempts += 1
496 try:
497 response = await client.post(
498 target.pinned_url(attempts),
499 content=payload_json,
500 headers=headers,
501 extensions={"sni_hostname": target.hostname},
502 )
503 last_status_code = response.status_code
504 if 200 <= response.status_code < 300:
505 successful_status_code = response.status_code
506 break
508 last_error = f"HTTP {response.status_code}"
509 if response.status_code >= 500:
510 logger.warning(
511 "Webhook attempt failed: webhook_id=%s %s status=%s attempt=%s",
512 webhook_id,
513 target.log_identity,
514 response.status_code,
515 attempts,
516 )
517 if attempts < self.max_retries:
518 await asyncio.sleep(self.retry_delay * (2 ** (attempts - 1)))
519 continue
520 break # Caller errors are terminal and must not be replayed.
522 except httpx2.TimeoutException:
523 last_error = "Request timed out"
524 logger.warning(
525 "Webhook attempt timed out: webhook_id=%s %s attempt=%s",
526 webhook_id,
527 target.log_identity,
528 attempts,
529 )
530 if attempts < self.max_retries:
531 await asyncio.sleep(self.retry_delay * (2 ** (attempts - 1)))
532 except httpx2.RequestError as exc:
533 last_error = type(exc).__name__
534 logger.warning(
535 "Webhook transport failed: webhook_id=%s %s "
536 "error_type=%s attempt=%s",
537 webhook_id,
538 target.log_identity,
539 type(exc).__name__,
540 attempts,
541 )
542 if attempts < self.max_retries:
543 await asyncio.sleep(self.retry_delay * (2 ** (attempts - 1)))
545 # Exiting AsyncClient may itself await and be cancelled. Commit
546 # success only after teardown completes so one delivery has
547 # exactly one terminal metric classification.
548 if successful_status_code is not None:
549 duration = (datetime.now(UTC) - start_time).total_seconds() * 1000
550 logger.info(
551 "Webhook delivered: webhook_id=%s %s status=%s attempts=%s",
552 webhook_id,
553 target.log_identity,
554 successful_status_code,
555 attempts,
556 )
557 self._deliveries_success += 1
558 return WebhookDeliveryResult(
559 webhook_id=webhook_id,
560 url=url,
561 event=event,
562 success=True,
563 status_code=successful_status_code,
564 attempts=attempts,
565 duration_ms=duration,
566 )
567 except asyncio.CancelledError:
568 # Cancellation is a terminal outcome for this logical delivery.
569 # Preserve task cancellation while keeping total == success + failed.
570 self._deliveries_failed += 1
571 logger.info("Webhook delivery cancelled: webhook_id=%s", webhook_id)
572 raise
573 except Exception as exc: # sanitize the logical delivery boundary
574 last_error = f"Delivery failure: {type(exc).__name__}"
575 logger.error(
576 "Webhook delivery raised: webhook_id=%s error_type=%s",
577 webhook_id,
578 type(exc).__name__,
579 )
581 duration = (datetime.now(UTC) - start_time).total_seconds() * 1000
582 log_identity = target.log_identity if target is not None else "target=unvalidated"
583 logger.error(
584 "Webhook delivery failed: webhook_id=%s %s attempts=%s status=%s error=%s",
585 webhook_id,
586 log_identity,
587 attempts,
588 last_status_code,
589 last_error,
590 )
591 self._deliveries_failed += 1
592 return WebhookDeliveryResult(
593 webhook_id=webhook_id,
594 url=url,
595 event=event,
596 success=False,
597 status_code=last_status_code,
598 error=last_error,
599 attempts=attempts,
600 duration_ms=duration,
601 )
603 async def _dispatch_event(self, event: WebhookEvent, job: V1Job) -> list[WebhookDeliveryResult]:
604 """Dispatch webhooks for a job event."""
605 namespace = job.metadata.namespace
606 payload = self._build_payload(event, job)
608 # Get webhooks subscribed to this event
609 try:
610 # Get webhooks for this specific namespace
611 namespace_webhooks = self.webhook_store.get_webhooks_for_event(
612 event.value, namespace=namespace
613 )
614 # Get global webhooks (no namespace filter)
615 global_webhooks = self.webhook_store.get_webhooks_for_event(event.value, namespace=None)
617 # Combine and deduplicate
618 all_webhooks = {w["id"]: w for w in namespace_webhooks}
619 for w in global_webhooks:
620 if w.get("namespace") is None: # Only add truly global webhooks
621 all_webhooks[w["id"]] = w
623 webhooks = list(all_webhooks.values())
625 except Exception as exc:
626 logger.error(
627 "Failed to get webhooks for event %s: error_type=%s",
628 event.value,
629 type(exc).__name__,
630 )
631 return []
633 if not webhooks:
634 logger.debug(f"No webhooks registered for event {event.value} in namespace {namespace}")
635 return []
637 logger.info(
638 f"Dispatching {len(webhooks)} webhooks for {event.value} "
639 f"(job={job.metadata.name}, namespace={namespace})"
640 )
642 # Dispatch all webhooks concurrently. _deliver_webhook owns normal
643 # exception conversion; this boundary protects accounting and redaction
644 # if a future regression (or an injected implementation) escapes it.
645 tasks = [self._deliver_webhook(webhook, payload) for webhook in webhooks]
646 results = await asyncio.gather(*tasks, return_exceptions=True)
648 delivery_results: list[WebhookDeliveryResult] = []
649 for webhook, result in zip(webhooks, results, strict=True):
650 if isinstance(result, WebhookDeliveryResult):
651 delivery_results.append(result)
652 continue
653 if isinstance(result, asyncio.CancelledError):
654 raise result
655 raw_webhook_id = webhook.get("id")
656 webhook_id = raw_webhook_id if isinstance(raw_webhook_id, str) else "<unknown>"
657 raw_url = webhook.get("url")
658 url = raw_url if isinstance(raw_url, str) else ""
659 logger.error(
660 "Webhook delivery escaped boundary: webhook_id=%s error_type=%s",
661 webhook_id,
662 type(result).__name__,
663 )
664 self._deliveries_total += 1
665 self._deliveries_failed += 1
666 delivery_results.append(
667 WebhookDeliveryResult(
668 webhook_id=webhook_id,
669 url=url,
670 event=event.value,
671 success=False,
672 error=f"Delivery failure: {type(result).__name__}",
673 attempts=0,
674 duration_ms=0.0,
675 )
676 )
678 return delivery_results
680 async def _process_job_event(self, event_type: str, job: V1Job) -> None:
681 """Process a Kubernetes job event."""
682 job_uid = job.metadata.uid
683 job_name = job.metadata.name
684 namespace = job.metadata.namespace
686 # Skip system namespaces
687 if namespace in ("kube-system", "kube-public", "kube-node-lease"):
688 return
690 # Skip if not in watched namespaces (if specified)
691 if self.namespaces and namespace not in self.namespaces:
692 return
694 current_status = self._compute_job_status(job)
696 if event_type == "DELETED":
697 self._job_state_cache.remove(job_uid)
698 return
700 # Get previous state and update cache
701 previous_status = self._job_state_cache.set_state(job_uid, current_status)
703 # Determine if we should fire an event
704 webhook_event = self._determine_event(previous_status, current_status)
706 if webhook_event:
707 logger.info(
708 f"Job status transition: {job_name} ({namespace}) "
709 f"{previous_status or 'new'} -> {current_status} "
710 f"-> firing {webhook_event.value}"
711 )
712 await self._dispatch_event(webhook_event, job)
714 def _sync_watch_jobs(self) -> list[tuple[str, Any]]:
715 """
716 Synchronous job watcher that yields batches of events.
717 This runs in a thread executor to avoid blocking the async event loop.
718 """
719 w = Watch()
720 events = []
722 try:
723 # Watch jobs with a short timeout to allow periodic returns
724 for event in w.stream(
725 self.batch_v1.list_job_for_all_namespaces,
726 timeout_seconds=30, # Short timeout to return control periodically
727 ):
728 if not self._running:
729 break
731 events.append((event["type"], event["object"]))
733 # Return batch after collecting some events or if we have any
734 if len(events) >= 10:
735 break
737 except ApiException as e:
738 if e.status == 410: # Gone - resource version too old
739 logger.warning("Watch expired, will restart...")
740 else:
741 logger.error(f"Kubernetes API error in job watcher: {e}")
742 raise
743 except Exception as e:
744 logger.error(f"Error in sync job watcher: {e}")
745 raise
747 return events
749 async def _acquire_leadership(self) -> bool:
750 """Hold (or keep holding) the deliverer Lease; ``True`` means deliver.
752 Off the event loop because the Lease read/replace is a blocking
753 Kubernetes round trip. Winning the Lease re-seeds the job-state cache:
754 the new leader must not replay transitions the old one already
755 delivered, and anything that happened while nobody held the Lease is
756 deliberately dropped rather than duplicated.
757 """
758 if self._leader_lease is None:
759 return True
760 held = await asyncio.to_thread(
761 try_acquire_lease,
762 self.coordination_v1,
763 self._leader_lease,
764 label="webhook",
765 )
766 if held and not self._is_leader:
767 logger.info(
768 "Webhook dispatcher %s became the deliverer; re-seeding job state cache",
769 self._leader_lease.holder,
770 )
771 await self._initialize_job_cache()
772 elif not held and self._is_leader:
773 logger.warning("Webhook dispatcher lost the deliverer Lease; standing by")
774 self._is_leader = held
775 return held
777 async def _watch_jobs(self) -> None:
778 """Watch Kubernetes jobs for status changes using thread executor."""
779 logger.info(f"Starting job watcher for namespaces: {self.namespaces}")
781 while self._running:
782 try:
783 if not await self._acquire_leadership():
784 await asyncio.sleep(self._standby_poll_seconds)
785 continue
787 # Run the synchronous watch in a thread executor
788 events = await asyncio.to_thread(self._sync_watch_jobs)
790 # Process collected events. ``stop()`` flips ``_running`` from
791 # another task while this one is awaiting the watch thread, so
792 # the flag is re-read per event; mypy still sees the ``while``
793 # condition's narrowing and calls the re-check unreachable.
794 for event_type, job in events:
795 if not self._running:
796 break # type: ignore[unreachable]
798 try:
799 await self._process_job_event(event_type, job)
800 except Exception as e:
801 logger.error(f"Error processing job event: {e}")
803 # Small delay between watch cycles if no events
804 if not events:
805 await asyncio.sleep(1)
807 except Exception as e:
808 logger.error(f"Error in job watcher: {e}")
809 await asyncio.sleep(5)
811 async def start(self) -> None:
812 """Start the webhook dispatcher."""
813 if self._running:
814 logger.warning("Webhook dispatcher already running")
815 return
817 self._running = True
818 logger.info(f"Starting webhook dispatcher for cluster {self.cluster_id}")
820 if self._leader_lease is None:
821 # Single deliverer: seed the job state cache with current jobs now.
822 # With a Lease the cache is seeded at the moment leadership is won,
823 # so a standby never fires transitions the leader already delivered.
824 await self._initialize_job_cache()
826 # Start the watch task
827 self._watch_task = asyncio.create_task(self._watch_jobs())
829 async def stop(self) -> None:
830 """Stop the webhook dispatcher."""
831 logger.info("Stopping webhook dispatcher")
832 self._running = False
834 if self._watch_task:
835 self._watch_task.cancel()
836 with contextlib.suppress(asyncio.CancelledError):
837 await self._watch_task
838 self._watch_task = None
840 async def _initialize_job_cache(self) -> None:
841 """Initialize the job state cache with current job states."""
842 try:
843 # Off the event loop: the synchronous client would otherwise stall
844 # every other task for the duration of the list (and its retries).
845 jobs = await asyncio.to_thread(
846 self.batch_v1.list_job_for_all_namespaces,
847 _request_timeout=self._k8s_timeout,
848 )
849 for job in jobs.items:
850 namespace = job.metadata.namespace
851 if namespace in ("kube-system", "kube-public", "kube-node-lease"):
852 continue
853 if self.namespaces and namespace not in self.namespaces:
854 continue
856 job_uid = job.metadata.uid
857 status = self._compute_job_status(job)
858 self._job_state_cache.set_state(job_uid, status)
860 logger.info(f"Initialized job cache with {len(self._job_state_cache.job_states)} jobs")
861 except Exception as e:
862 logger.error(f"Failed to initialize job cache: {e}")
864 def get_metrics(self) -> dict[str, Any]:
865 """Get dispatcher metrics."""
866 return {
867 "deliveries_total": self._deliveries_total,
868 "deliveries_success": self._deliveries_success,
869 "deliveries_failed": self._deliveries_failed,
870 "cached_jobs": len(self._job_state_cache.job_states),
871 "running": self._running,
872 "leader": self._leader_lease is None or self._is_leader,
873 }
876def create_webhook_dispatcher_from_env() -> WebhookDispatcher:
877 """Create WebhookDispatcher instance from environment variables."""
878 cluster_id = os.getenv("CLUSTER_NAME", "unknown-cluster")
879 region = os.getenv("REGION", "unknown-region")
880 timeout = int(os.getenv("WEBHOOK_TIMEOUT", "30"))
881 max_retries = int(os.getenv("WEBHOOK_MAX_RETRIES", "3"))
882 retry_delay = int(os.getenv("WEBHOOK_RETRY_DELAY", "5"))
884 # Parse namespaces from env
885 namespaces_str = os.getenv("ALLOWED_NAMESPACES", "gco-jobs,default")
886 namespaces = [ns.strip() for ns in namespaces_str.split(",") if ns.strip()]
888 # Parse allowed domains from env (comma-separated)
889 allowed_domains_str = os.getenv("WEBHOOK_ALLOWED_DOMAINS", "")
890 allowed_domains = [d.strip() for d in allowed_domains_str.split(",") if d.strip()]
892 # Deliverer election. The Lease is pre-created by 02-rbac.yaml; an empty
893 # WEBHOOK_LEASE_NAME opts out (single-replica or standalone runs).
894 lease_name = os.getenv("WEBHOOK_LEASE_NAME", "gco-health-monitor-webhooks").strip()
895 leader_lease = None
896 if lease_name:
897 leader_lease = LeaseIdentity(
898 name=lease_name,
899 namespace=os.getenv("POD_NAMESPACE", "gco-system"),
900 holder=(
901 os.getenv("POD_NAME")
902 or os.getenv("HOSTNAME")
903 or f"webhook-dispatcher-{os.getpid()}"
904 ),
905 duration_seconds=lease_duration_from_env(
906 os.getenv("WEBHOOK_LEASE_DURATION", "90"), label="WEBHOOK_LEASE_DURATION"
907 ),
908 )
910 return WebhookDispatcher(
911 cluster_id=cluster_id,
912 region=region,
913 timeout=timeout,
914 max_retries=max_retries,
915 retry_delay=retry_delay,
916 namespaces=namespaces,
917 allowed_domains=allowed_domains,
918 leader_lease=leader_lease,
919 )
922async def main() -> None:
923 """Main function for running the webhook dispatcher standalone."""
924 dispatcher = create_webhook_dispatcher_from_env()
926 try:
927 await dispatcher.start()
929 # Keep running until interrupted
930 while True:
931 await asyncio.sleep(60)
932 metrics = dispatcher.get_metrics()
933 logger.info(f"Webhook dispatcher metrics: {metrics}")
935 except KeyboardInterrupt:
936 logger.info("Webhook dispatcher stopped by user")
937 finally:
938 await dispatcher.stop()
941if __name__ == "__main__":
942 asyncio.run(main())