Coverage for lambda / ga-registration / handler.py: 100.00%
418 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"""Gateway ALB registration and endpoint-publication Lambda handler.
3The handler converges the regional Gateway API ALB after the Gateway manifests
4have been applied:
61. Read the exact ``gco-system/gco-gateway`` Gateway status address.
72. Resolve that address to an active internal ALB, with an exact-tag fallback
8 while the Gateway status is still being populated.
93. When a Global Accelerator endpoint group is configured, register only that
10 ALB, remove stale endpoints, and enforce the HTTPS health-check contract.
114. Always publish the selected ALB hostname to the SSM endpoint registry.
13``EndpointGroupArn`` is optional. Deployments without Global Accelerator still
14publish the Gateway hostname.
16Entrypoints:
17 - ``handle_task`` is the final Step Functions convergence task.
18 - ``lambda_handler`` preserves the legacy raw CloudFormation custom-resource
19 protocol and dispatches Step Functions events carrying ``Action``.
20 - ``on_delete_event`` is the CDK provider delete guard.
22The endpoint registry parameter is always:
23``/{ProjectName}/alb-hostname-{Region}`` in ``RegistryRegion``. The legacy
24``GlobalRegion`` property remains accepted for existing deployments.
25"""
27import base64
28import json
29import logging
30import os
31import tempfile
32import time
33from contextlib import suppress
34from typing import Any
36import boto3
37import urllib3
38from botocore.exceptions import ClientError
40# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
41# Generated at (UTC): 2026-10-22T03:05:59Z
42# Generated from Git commit: 503e80ca9adfd5d217fe28b2f27f74fdcdb775a0
43# Flowchart(s) generated from this file:
44# * ``lambda_handler`` -> ``diagrams/code_diagrams/lambda/ga-registration/handler.lambda_handler.html``
45# (PNG: ``diagrams/code_diagrams/lambda/ga-registration/handler.lambda_handler.png``)
46# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
47# <pyflowchart-code-diagram> END
50logger = logging.getLogger()
51logger.setLevel(logging.INFO)
53MAX_WAIT_SECONDS = 840
54ALB_POLL_INTERVAL = 5
55GA_DEPLOYED_WAIT_SECONDS = 720
56GA_DEPLOYED_POLL_INTERVAL = 15
57DEFAULT_REGISTRY_REGION = "us-east-2"
59GATEWAY_NAMESPACE = "gco-system"
60GATEWAY_NAME = "gco-gateway"
61GATEWAY_REFERENCE = f"{GATEWAY_NAMESPACE}/{GATEWAY_NAME}"
62GATEWAY_TAG = "gco.aws/gateway"
63CLUSTER_TAG = "elbv2.k8s.aws/cluster"
64GATEWAY_API_PATH = (
65 f"/apis/gateway.networking.k8s.io/v1/namespaces/{GATEWAY_NAMESPACE}/gateways/{GATEWAY_NAME}"
66)
69def send_response(
70 event: dict[str, Any],
71 context: Any,
72 status: str,
73 data: dict[str, Any],
74 physical_id: str,
75 reason: str | None = None,
76) -> None:
77 """Send a response to a raw CloudFormation custom resource."""
78 response_body = {
79 "Status": status,
80 "Reason": reason or f"See CloudWatch Log Stream: {context.log_stream_name}",
81 "PhysicalResourceId": physical_id,
82 "StackId": event["StackId"],
83 "RequestId": event["RequestId"],
84 "LogicalResourceId": event["LogicalResourceId"],
85 "Data": data,
86 }
87 logger.info("Sending CFN response: Status=%s, PhysicalResourceId=%s", status, physical_id)
88 http = urllib3.PoolManager()
89 try:
90 http.request(
91 "PUT",
92 event["ResponseURL"],
93 body=json.dumps(response_body).encode("utf-8"),
94 headers={"Content-Type": "application/json"},
95 timeout=10.0,
96 )
97 except Exception as exc: # callback failure can only be logged
98 logger.error("Failed to send CloudFormation response: %s", exc)
101def _remove_temporary_ca_file(ca_path: str | None) -> None:
102 """Unlink a temporary Kubernetes CA file without masking the real result."""
103 if not ca_path:
104 return
105 try:
106 os.unlink(ca_path)
107 except FileNotFoundError:
108 return
109 except OSError as exc:
110 logger.warning("Failed to remove temporary Kubernetes CA file: %s", exc)
113def get_k8s_client(cluster_name: str, region: str) -> tuple[str, str, str]:
114 """Return ``(endpoint, bearer_token, temporary_ca_path)`` for an EKS cluster.
116 The caller owns the returned CA path and must remove it in a ``finally``
117 block. ``mkstemp`` prevents name races; the explicit mode keeps the
118 certificate readable only by the Lambda process.
119 """
120 eks = boto3.client("eks", region_name=region)
121 cluster_info = eks.describe_cluster(name=cluster_name)["cluster"]
123 session = boto3.Session()
124 sts_client = session.client("sts", region_name=region)
125 sts_endpoint = str(sts_client.meta.endpoint_url).rstrip("/")
126 sts_url = f"{sts_endpoint}/?Action=GetCallerIdentity&Version=2011-06-15"
127 signed_url = sts_client._request_signer.generate_presigned_url(
128 request_dict={
129 "method": "GET",
130 "url": sts_url,
131 "body": {},
132 "headers": {"x-k8s-aws-id": cluster_name},
133 "context": {},
134 },
135 operation_name="GetCallerIdentity",
136 expires_in=60,
137 )
138 token = "k8s-aws-v1." + base64.urlsafe_b64encode(signed_url.encode()).decode().rstrip("=")
140 ca_cert = base64.b64decode(cluster_info["certificateAuthority"]["data"])
141 fd, ca_path = tempfile.mkstemp(suffix=".crt")
142 try:
143 os.fchmod(fd, 0o600)
144 with os.fdopen(fd, "wb") as ca_file:
145 ca_file.write(ca_cert)
146 except Exception:
147 with suppress(OSError):
148 os.close(fd)
149 _remove_temporary_ca_file(ca_path)
150 raise
152 return str(cluster_info["endpoint"]), token, ca_path
155def _response_json(response: Any) -> dict[str, Any]:
156 """Decode a Kubernetes JSON response, returning an object mapping."""
157 raw_data = response.data
158 if isinstance(raw_data, bytes):
159 raw_data = raw_data.decode("utf-8")
160 document = json.loads(raw_data)
161 return document if isinstance(document, dict) else {}
164def find_gateway_address(
165 http: urllib3.PoolManager,
166 endpoint: str,
167 headers: dict[str, str],
168) -> str | None:
169 """Read the nonempty hostname from the exact GCO Gateway status."""
170 try:
171 response = http.request(
172 "GET",
173 f"{endpoint}{GATEWAY_API_PATH}",
174 headers=headers,
175 timeout=10.0,
176 )
177 if response.status == 404:
178 logger.debug("Gateway %s not found yet", GATEWAY_REFERENCE)
179 return None
180 if response.status != 200:
181 logger.warning("Gateway status request returned HTTP %s", response.status)
182 return None
184 addresses = _response_json(response).get("status", {}).get("addresses", [])
185 for address in addresses:
186 if not isinstance(address, dict):
187 continue
188 address_type = address.get("type", "Hostname")
189 value = address.get("value")
190 if address_type == "Hostname" and isinstance(value, str) and value.strip():
191 return value.strip()
192 except Exception as exc: # polling falls back to exact tags
193 logger.warning("Error checking Gateway status: %s", exc)
194 return None
197def _list_load_balancers(elb_client: Any) -> list[dict[str, Any]]:
198 """List every load balancer, following ELBv2 marker pagination."""
199 load_balancers: list[dict[str, Any]] = []
200 marker: str | None = None
201 seen_markers: set[str] = set()
202 while True:
203 kwargs = {"Marker": marker} if marker else {}
204 response = elb_client.describe_load_balancers(**kwargs)
205 load_balancers.extend(response.get("LoadBalancers", []))
206 next_marker = response.get("NextMarker")
207 if not isinstance(next_marker, str) or not next_marker:
208 return load_balancers
209 if next_marker in seen_markers:
210 raise RuntimeError(f"ELB pagination repeated marker {next_marker!r}")
211 seen_markers.add(next_marker)
212 marker = next_marker
215def find_alb_by_gateway_hostname(
216 elb_client: Any, hostname: str, cluster_name: str
217) -> tuple[str | None, str | None, str | None]:
218 """Resolve a Gateway hostname only to its exactly owned internal ALB."""
219 try:
220 candidates = [
221 load_balancer
222 for load_balancer in _list_load_balancers(elb_client)
223 if load_balancer.get("Type") == "application"
224 and load_balancer.get("Scheme") == "internal"
225 and load_balancer.get("DNSName") == hostname
226 ]
227 if not candidates:
228 return None, None, None
230 arns = [str(load_balancer["LoadBalancerArn"]) for load_balancer in candidates]
231 tags_by_arn = _describe_tags(elb_client, arns)
232 for load_balancer in candidates:
233 arn = str(load_balancer["LoadBalancerArn"])
234 tags = tags_by_arn.get(arn, {})
235 if not (
236 tags.get(GATEWAY_TAG) == GATEWAY_REFERENCE and tags.get(CLUSTER_TAG) == cluster_name
237 ):
238 logger.warning("Rejecting hostname-matched ALB without exact ownership: %s", arn)
239 continue
240 state = str(load_balancer.get("State", {}).get("Code", "unknown"))
241 logger.info(
242 "Found exactly owned Gateway ALB by hostname: %s (state: %s)",
243 load_balancer.get("LoadBalancerName", "<unknown>"),
244 state,
245 )
246 return str(load_balancer["DNSName"]), arn, state
247 except Exception as exc: # discovery polling retries
248 logger.warning("Error finding Gateway ALB by hostname: %s", exc)
249 return None, None, None
252def _describe_tags(elb_client: Any, load_balancer_arns: list[str]) -> dict[str, dict[str, str]]:
253 """Return ELB tags by ARN, respecting the API's 20-resource limit."""
254 tags_by_arn: dict[str, dict[str, str]] = {}
255 for index in range(0, len(load_balancer_arns), 20):
256 response = elb_client.describe_tags(ResourceArns=load_balancer_arns[index : index + 20])
257 for description in response.get("TagDescriptions", []):
258 arn = description.get("ResourceArn")
259 if not isinstance(arn, str):
260 continue
261 tags_by_arn[arn] = {
262 str(tag["Key"]): str(tag["Value"])
263 for tag in description.get("Tags", [])
264 if "Key" in tag and "Value" in tag
265 }
266 return tags_by_arn
269def find_platform_alb_by_tags(
270 elb_client: Any, cluster_name: str
271) -> tuple[str | None, str | None, str | None]:
272 """Find the Gateway ALB only when both exact ownership tags match.
274 This fallback is used only while the exact Gateway status has no address.
275 Cluster-only matches, alternative cluster tags, NLBs, and internet-facing
276 ALBs are deliberately rejected.
277 """
278 try:
279 load_balancers = [
280 load_balancer
281 for load_balancer in _list_load_balancers(elb_client)
282 if load_balancer.get("Type") == "application"
283 and load_balancer.get("Scheme") == "internal"
284 ]
285 if not load_balancers:
286 return None, None, None
288 arns = [str(load_balancer["LoadBalancerArn"]) for load_balancer in load_balancers]
289 tags_by_arn = _describe_tags(elb_client, arns)
290 for load_balancer in load_balancers:
291 arn = str(load_balancer["LoadBalancerArn"])
292 tags = tags_by_arn.get(arn, {})
293 if not (
294 tags.get(GATEWAY_TAG) == GATEWAY_REFERENCE and tags.get(CLUSTER_TAG) == cluster_name
295 ):
296 continue
297 state = str(load_balancer.get("State", {}).get("Code", "unknown"))
298 logger.info(
299 "Found Gateway ALB by exact tags: %s (state: %s)",
300 load_balancer.get("LoadBalancerName", "<unknown>"),
301 state,
302 )
303 return str(load_balancer["DNSName"]), arn, state
304 except Exception as exc: # discovery polling retries
305 logger.warning("Error finding Gateway ALB by tags: %s", exc)
306 return None, None, None
309def find_active_alb(
310 elb_client: Any,
311 http: urllib3.PoolManager,
312 k8s_endpoint: str,
313 k8s_headers: dict[str, str],
314 cluster_name: str,
315) -> tuple[str | None, str | None]:
316 """Find the active ALB owned by the exact GCO Gateway.
318 A nonempty Gateway status address is authoritative. Tag fallback is allowed
319 only when that address is absent; it never overrides a provisioning or
320 otherwise unresolved ALB named by Gateway status.
321 """
322 hostname = find_gateway_address(http, k8s_endpoint, k8s_headers)
323 if hostname:
324 dns_name, arn, state = find_alb_by_gateway_hostname(elb_client, hostname, cluster_name)
325 if arn and state == "active":
326 logger.info("Found active ALB from Gateway status: %s", hostname)
327 return dns_name, arn
328 if arn:
329 logger.info("Gateway ALB state is %r; waiting for 'active'", state)
330 return None, None
332 dns_name, arn, state = find_platform_alb_by_tags(elb_client, cluster_name)
333 if arn and state == "active":
334 return dns_name, arn
335 if arn:
336 logger.info("Gateway ALB found by tags but state is %r; waiting for 'active'", state)
337 return None, None
340def check_existing_ga_endpoint(ga_client: Any, endpoint_group_arn: str, alb_arn: str) -> bool:
341 """Return whether the exact ALB is already registered with GA."""
342 try:
343 endpoint_group = ga_client.describe_endpoint_group(EndpointGroupArn=endpoint_group_arn)
344 endpoints = endpoint_group.get("EndpointGroup", {}).get("EndpointDescriptions", [])
345 if any(endpoint.get("EndpointId") == alb_arn for endpoint in endpoints):
346 logger.info("ALB %s is already registered with GA", alb_arn)
347 return True
348 except Exception as exc: # add_endpoints remains authoritative
349 logger.warning("Error checking existing GA endpoints: %s", exc)
350 return False
353def scrub_stale_ga_endpoints(ga_client: Any, endpoint_group_arn: str, correct_alb_arn: str) -> None:
354 """Remove every GA endpoint other than the exact Gateway ALB."""
355 try:
356 endpoint_group = ga_client.describe_endpoint_group(EndpointGroupArn=endpoint_group_arn)
357 endpoints = endpoint_group.get("EndpointGroup", {}).get("EndpointDescriptions", [])
358 for endpoint in endpoints:
359 endpoint_id = endpoint.get("EndpointId", "")
360 if not endpoint_id or endpoint_id == correct_alb_arn:
361 continue
362 logger.warning(
363 "Removing stale GA endpoint %s; exact Gateway ALB is %s",
364 endpoint_id,
365 correct_alb_arn,
366 )
367 try:
368 ga_client.remove_endpoints(
369 EndpointGroupArn=endpoint_group_arn,
370 EndpointIdentifiers=[{"EndpointId": endpoint_id}],
371 )
372 except ClientError as exc:
373 error_code = exc.response.get("Error", {}).get("Code", "")
374 if error_code == "EndpointNotFoundException":
375 logger.info("GA endpoint %s was already absent", endpoint_id)
376 else:
377 logger.error("Failed to remove stale GA endpoint %s: %s", endpoint_id, exc)
378 raise
379 except Exception as exc:
380 logger.error("Error scrubbing stale GA endpoints: %s", exc)
381 raise
384def register_alb_with_ga(ga_client: Any, endpoint_group_arn: str, alb_arn: str) -> None:
385 """Register the exact Gateway ALB with GA, idempotently."""
386 if check_existing_ga_endpoint(ga_client, endpoint_group_arn, alb_arn):
387 return
388 try:
389 ga_client.add_endpoints(
390 EndpointGroupArn=endpoint_group_arn,
391 EndpointConfigurations=[
392 {
393 "EndpointId": alb_arn,
394 "Weight": 100,
395 "ClientIPPreservationEnabled": True,
396 }
397 ],
398 )
399 logger.info("Registered Gateway ALB %s with Global Accelerator", alb_arn)
400 except ClientError as exc:
401 if exc.response.get("Error", {}).get("Code") == "EndpointAlreadyExists":
402 logger.info("Gateway ALB was already registered with Global Accelerator")
403 return
404 raise
407def ensure_https_health_check(
408 ga_client: Any,
409 endpoint_group_arn: str,
410 health_check_path: str = "/api/v1/health",
411 expected_alb_arn: str | None = None,
412 health_check_interval: int = 30,
413 health_check_threshold: int = 3,
414) -> None:
415 """Enforce the endpoint group's configured HTTPS/443 health-check contract.
417 Interval and threshold come from the cdk.json ``global_accelerator`` block
418 (baked into the convergence task payload at synth time); the defaults
419 match the values this handler historically hardcoded, so legacy payloads
420 without the keys keep converging identically. Comparing them here also
421 means a non-default configured interval is no longer silently reset to 30
422 whenever the path or protocol drifts.
424 When ``expected_alb_arn`` is supplied, only that endpoint is preserved in an
425 update, preventing an eventually consistent stale endpoint description from
426 being reintroduced after the scrub.
427 """
428 try:
429 endpoint_group = ga_client.describe_endpoint_group(EndpointGroupArn=endpoint_group_arn)
430 group = endpoint_group.get("EndpointGroup", {})
431 current_protocol = group.get("HealthCheckProtocol", "TCP")
432 current_port = int(group.get("HealthCheckPort", 0))
433 current_path = group.get("HealthCheckPath", "")
434 current_interval = int(group.get("HealthCheckIntervalSeconds", 0))
435 current_threshold = int(group.get("ThresholdCount", 0))
436 if (
437 current_protocol == "HTTPS"
438 and current_port == 443
439 and current_path == health_check_path
440 and current_interval == health_check_interval
441 and current_threshold == health_check_threshold
442 ):
443 return
445 existing_endpoints = [
446 {
447 "EndpointId": endpoint["EndpointId"],
448 "Weight": endpoint.get("Weight", 100),
449 "ClientIPPreservationEnabled": endpoint.get("ClientIPPreservationEnabled", True),
450 }
451 for endpoint in group.get("EndpointDescriptions", [])
452 if endpoint.get("EndpointId")
453 and (expected_alb_arn is None or endpoint.get("EndpointId") == expected_alb_arn)
454 ]
455 # EndpointConfigurations replaces the group's endpoint list. Right after
456 # AddEndpoints the description can still omit the ALB this run just
457 # registered, and sending the filtered list as-is would then remove
458 # every endpoint and leave the Region out of Global Accelerator.
459 if expected_alb_arn is not None and not existing_endpoints:
460 existing_endpoints = [
461 {
462 "EndpointId": expected_alb_arn,
463 "Weight": 100,
464 "ClientIPPreservationEnabled": True,
465 }
466 ]
467 ga_client.update_endpoint_group(
468 EndpointGroupArn=endpoint_group_arn,
469 HealthCheckPort=443,
470 HealthCheckProtocol="HTTPS",
471 HealthCheckPath=health_check_path,
472 HealthCheckIntervalSeconds=health_check_interval,
473 ThresholdCount=health_check_threshold,
474 EndpointConfigurations=existing_endpoints,
475 )
476 logger.info(
477 "Global Accelerator health check set to HTTPS/443 %s (interval %ss, threshold %s)",
478 health_check_path,
479 health_check_interval,
480 health_check_threshold,
481 )
482 except ClientError as exc:
483 logger.error("Failed to enforce GA health-check configuration: %s", exc)
484 raise
487def _get_registry_region(properties: dict[str, Any], default: str | None = None) -> str | None:
488 """Return ``RegistryRegion``, accepting ``GlobalRegion`` for compatibility."""
489 value = properties.get("RegistryRegion") or properties.get("GlobalRegion") or default
490 return str(value) if value else None
493def store_alb_hostname_in_ssm(
494 region: str, alb_hostname: str, registry_region: str, project_name: str
495) -> None:
496 """Publish the Gateway ALB hostname to the regional endpoint registry."""
497 ssm_client = boto3.client("ssm", region_name=registry_region)
498 parameter_name = f"/{project_name}/alb-hostname-{region}"
499 ssm_client.put_parameter(
500 Name=parameter_name,
501 Value=alb_hostname,
502 Type="String",
503 Overwrite=True,
504 Description=f"ALB hostname for {region} regional cluster",
505 )
506 logger.info("Stored Gateway ALB hostname in SSM: %s = %s", parameter_name, alb_hostname)
509def delete_alb_hostname_from_ssm(
510 region: str,
511 registry_region: str,
512 project_name: str,
513 *,
514 strict: bool = False,
515) -> None:
516 """Remove this region's endpoint-registry parameter during cleanup."""
517 ssm_client = boto3.client("ssm", region_name=registry_region)
518 parameter_name = f"/{project_name}/alb-hostname-{region}"
519 try:
520 ssm_client.delete_parameter(Name=parameter_name)
521 logger.info("Deleted Gateway ALB hostname from SSM: %s", parameter_name)
522 except ClientError as exc:
523 if exc.response.get("Error", {}).get("Code") == "ParameterNotFound":
524 logger.info("SSM parameter %s was already absent", parameter_name)
525 elif strict:
526 raise
527 else:
528 logger.warning("Failed to delete Gateway ALB hostname from SSM: %s", exc)
531def remove_ga_endpoints(
532 ga_client: Any,
533 endpoint_group_arn: str,
534 *,
535 strict: bool = False,
536) -> None:
537 """Remove every endpoint from one regional GA group."""
538 try:
539 endpoint_group = ga_client.describe_endpoint_group(EndpointGroupArn=endpoint_group_arn)
540 endpoints = endpoint_group.get("EndpointGroup", {}).get("EndpointDescriptions", [])
541 for endpoint in endpoints:
542 endpoint_id = endpoint.get("EndpointId")
543 if not endpoint_id:
544 continue
545 try:
546 ga_client.remove_endpoints(
547 EndpointGroupArn=endpoint_group_arn,
548 EndpointIdentifiers=[{"EndpointId": endpoint_id}],
549 )
550 except ClientError as exc:
551 if exc.response.get("Error", {}).get("Code") == "EndpointNotFoundException":
552 continue
553 if strict:
554 raise
555 logger.warning("Failed to remove GA endpoint %s: %s", endpoint_id, exc)
556 except ClientError as exc:
557 code = exc.response.get("Error", {}).get("Code")
558 if code in {"EndpointGroupNotFoundException", "AcceleratorNotFoundException"}:
559 logger.info("Global Accelerator endpoint group was already absent")
560 return
561 if strict:
562 raise
563 logger.warning("Failed to clean up GA endpoints: %s", exc)
564 except Exception as exc: # delete guards remain best effort
565 if strict:
566 raise
567 logger.warning("Failed to clean up GA endpoints: %s", exc)
570def _accelerator_arn_from_endpoint_group(endpoint_group_arn: str) -> str:
571 """Derive an accelerator ARN from one of its endpoint-group ARNs."""
572 return endpoint_group_arn.split("/listener/")[0]
575def wait_for_accelerator_deployed(
576 ga_client: Any,
577 endpoint_group_arn: str,
578 timeout_seconds: int = GA_DEPLOYED_WAIT_SECONDS,
579 *,
580 strict: bool = False,
581) -> bool:
582 """Wait until GA finishes redeploying and releases its managed ENIs.
584 In strict mode a describe failure raises immediately instead of being
585 reported as a timeout; a permissions gap must surface as itself.
586 """
587 accelerator_arn = _accelerator_arn_from_endpoint_group(endpoint_group_arn)
588 start_time = time.time()
589 while time.time() - start_time < timeout_seconds:
590 try:
591 accelerator = ga_client.describe_accelerator(AcceleratorArn=accelerator_arn)
592 status = accelerator.get("Accelerator", {}).get("Status", "")
593 except ClientError as exc:
594 if exc.response.get("Error", {}).get("Code") == "AcceleratorNotFoundException":
595 return True
596 if strict:
597 raise
598 logger.warning("Failed to describe accelerator status: %s", exc)
599 return False
600 if status == "DEPLOYED":
601 return True
602 logger.info("Accelerator status=%r; waiting for DEPLOYED", status)
603 # nosemgrep: arbitrary-sleep - intentional GA redeployment polling
604 time.sleep(GA_DEPLOYED_POLL_INTERVAL)
605 logger.warning("Timed out waiting for Global Accelerator to reach DEPLOYED")
606 return False
609def deregister_alb_from_ga(
610 ga_client: Any,
611 endpoint_group_arn: str,
612 *,
613 strict: bool = False,
614) -> None:
615 """Remove regional GA endpoints, then wait for managed ENI release."""
616 if strict:
617 remove_ga_endpoints(ga_client, endpoint_group_arn, strict=True)
618 else:
619 remove_ga_endpoints(ga_client, endpoint_group_arn)
620 deployed = wait_for_accelerator_deployed(ga_client, endpoint_group_arn, strict=strict)
621 if strict and not deployed:
622 raise TimeoutError("Global Accelerator did not reach DEPLOYED after endpoint removal")
625def _optional_endpoint_group_arn(properties: dict[str, Any]) -> str | None:
626 """Normalize an optional endpoint-group property."""
627 value = properties.get("EndpointGroupArn")
628 if value is None:
629 return None
630 normalized = str(value).strip()
631 return normalized or None
634def _health_check_contract(properties: dict[str, Any]) -> dict[str, Any]:
635 """Extract the optional ``GaHealthCheck*`` payload keys with legacy defaults.
637 The regional stack bakes the configured contract into the convergence
638 payload at synth time. Payloads persisted before these keys existed (and
639 raw CloudFormation properties from older deployments) fall back to the
640 values this handler historically hardcoded.
641 """
642 return {
643 "health_check_path": str(properties.get("GaHealthCheckPath", "/api/v1/health")),
644 "health_check_interval": int(properties.get("GaHealthCheckInterval", 30)),
645 "health_check_threshold": int(properties.get("GaHealthCheckThreshold", 3)),
646 }
649def register_ga_endpoint(
650 cluster_name: str,
651 region: str,
652 endpoint_group_arn: str | None = None,
653 registry_region: str = DEFAULT_REGISTRY_REGION,
654 project_name: str = "gco",
655 *,
656 health_check_path: str = "/api/v1/health",
657 health_check_interval: int = 30,
658 health_check_threshold: int = 3,
659) -> dict[str, str]:
660 """Converge the Gateway ALB, optional GA endpoint, registry, and migration."""
661 ca_path: str | None = None
662 try:
663 k8s_endpoint, token, ca_path = get_k8s_client(cluster_name, region)
664 http = urllib3.PoolManager(cert_reqs="CERT_REQUIRED", ca_certs=ca_path)
665 k8s_headers = {"Authorization": f"Bearer {token}"}
666 elb_client = boto3.client("elbv2", region_name=region)
668 logger.info("Waiting for active %s ALB", GATEWAY_REFERENCE)
669 start_time = time.time()
670 last_log_time = start_time
671 alb_hostname: str | None = None
672 alb_arn: str | None = None
673 while time.time() - start_time < MAX_WAIT_SECONDS:
674 alb_hostname, alb_arn = find_active_alb(
675 elb_client,
676 http,
677 k8s_endpoint,
678 k8s_headers,
679 cluster_name,
680 )
681 if alb_arn:
682 break
683 if time.time() - last_log_time >= 30:
684 elapsed = int(time.time() - start_time)
685 logger.info("Still waiting for Gateway ALB (%ss elapsed)", elapsed)
686 last_log_time = time.time()
687 # nosemgrep: arbitrary-sleep - intentional Gateway/ALB polling
688 time.sleep(ALB_POLL_INTERVAL)
690 if not alb_arn or not alb_hostname:
691 elapsed = int(time.time() - start_time)
692 raise TimeoutError(
693 f"Timed out waiting for active {GATEWAY_REFERENCE} ALB after {elapsed} seconds"
694 )
696 normalized_endpoint_group = (
697 str(endpoint_group_arn).strip() if endpoint_group_arn is not None else ""
698 )
699 if normalized_endpoint_group:
700 ga_client = boto3.client("globalaccelerator", region_name="us-west-2")
701 register_alb_with_ga(ga_client, normalized_endpoint_group, alb_arn)
702 scrub_stale_ga_endpoints(ga_client, normalized_endpoint_group, alb_arn)
703 ensure_https_health_check(
704 ga_client,
705 normalized_endpoint_group,
706 health_check_path=health_check_path,
707 expected_alb_arn=alb_arn,
708 health_check_interval=health_check_interval,
709 health_check_threshold=health_check_threshold,
710 )
711 # AddEndpoints/RemoveEndpoints/UpdateEndpointGroup only submit a
712 # configuration change; the accelerator serves it from its edge
713 # locations only after returning to DEPLOYED. Returning success
714 # earlier reports the deployment as complete while brand-new
715 # connections to the global endpoint still black-hole for several
716 # minutes, which a live release run proved by timing out on the
717 # first health probe after deploy. Wait strictly, within whatever
718 # remains of this handler's wall-clock budget.
719 remaining_budget = min(
720 GA_DEPLOYED_WAIT_SECONDS,
721 int(MAX_WAIT_SECONDS - (time.time() - start_time)),
722 )
723 if remaining_budget <= 0 or not wait_for_accelerator_deployed(
724 ga_client,
725 normalized_endpoint_group,
726 timeout_seconds=remaining_budget,
727 strict=True,
728 ):
729 raise TimeoutError(
730 "Global Accelerator did not reach DEPLOYED after endpoint registration"
731 )
732 else:
733 logger.info("EndpointGroupArn is not configured; skipping Global Accelerator")
735 # Publication is mandatory even when Global Accelerator is disabled.
736 store_alb_hostname_in_ssm(
737 region,
738 alb_hostname,
739 registry_region,
740 project_name,
741 )
742 return {"AlbArn": alb_arn, "AlbHostname": alb_hostname}
743 finally:
744 _remove_temporary_ca_file(ca_path)
747def cleanup_gateway_endpoint(
748 *,
749 region: str,
750 endpoint_group_arn: str | None,
751 registry_region: str,
752 project_name: str,
753) -> dict[str, bool]:
754 """Strictly fence endpoint publication before Gateway deletion."""
755 delete_alb_hostname_from_ssm(
756 region,
757 registry_region,
758 project_name,
759 strict=True,
760 )
761 if endpoint_group_arn:
762 ga_client = boto3.client("globalaccelerator", region_name="us-west-2")
763 deregister_alb_from_ga(ga_client, endpoint_group_arn, strict=True)
764 return {
765 "RegistryParameterDeleted": True,
766 "GlobalAcceleratorDeregistered": bool(endpoint_group_arn),
767 }
770def handle_task(event: dict[str, Any]) -> dict[str, Any]:
771 """Handle Step Functions convergence and teardown task invocations."""
772 if event.get("Action") == "cleanup_gateway_endpoint":
773 return cleanup_gateway_endpoint(
774 region=str(event["Region"]),
775 endpoint_group_arn=_optional_endpoint_group_arn(event),
776 registry_region=_get_registry_region(event, DEFAULT_REGISTRY_REGION)
777 or DEFAULT_REGISTRY_REGION,
778 project_name=str(event.get("ProjectName", "gco")),
779 )
781 return register_ga_endpoint(
782 cluster_name=str(event["ClusterName"]),
783 region=str(event["Region"]),
784 endpoint_group_arn=_optional_endpoint_group_arn(event),
785 registry_region=_get_registry_region(event, DEFAULT_REGISTRY_REGION)
786 or DEFAULT_REGISTRY_REGION,
787 project_name=str(event.get("ProjectName", "gco")),
788 **_health_check_contract(event),
789 )
792def handle_create_update(
793 event: dict[str, Any], context: Any, props: dict[str, Any], physical_id: str
794) -> None:
795 """Handle Create/Update through the raw CloudFormation protocol."""
796 data = register_ga_endpoint(
797 cluster_name=str(props["ClusterName"]),
798 region=str(props["Region"]),
799 endpoint_group_arn=_optional_endpoint_group_arn(props),
800 registry_region=_get_registry_region(props, DEFAULT_REGISTRY_REGION)
801 or DEFAULT_REGISTRY_REGION,
802 project_name=str(props.get("ProjectName", "gco")),
803 **_health_check_contract(props),
804 )
805 send_response(event, context, "SUCCESS", data, physical_id)
808def handle_delete(
809 event: dict[str, Any], context: Any, props: dict[str, Any], physical_id: str
810) -> None:
811 """Always remove SSM and conditionally deregister/wait for GA."""
812 endpoint_group_arn = _optional_endpoint_group_arn(props)
813 if endpoint_group_arn:
814 try:
815 ga_client = boto3.client("globalaccelerator", region_name="us-west-2")
816 deregister_alb_from_ga(ga_client, endpoint_group_arn)
817 except Exception as exc: # Delete must continue to SSM cleanup
818 logger.error("GA deregistration failed during Delete: %s", exc, exc_info=True)
820 region = str(props["Region"])
821 registry_region = _get_registry_region(props, DEFAULT_REGISTRY_REGION)
822 assert registry_region is not None
823 project_name = str(props.get("ProjectName", "gco"))
824 try:
825 delete_alb_hostname_from_ssm(region, registry_region, project_name)
826 except Exception as exc: # raw Delete must always respond success
827 logger.error("SSM registry cleanup failed during Delete: %s", exc, exc_info=True)
829 send_response(event, context, "SUCCESS", {}, physical_id)
832def on_delete_event(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
833 """CDK provider guard: no-op on Create/Update, cleanup on Delete."""
834 request_type = event.get("RequestType")
835 props = event.get("ResourceProperties", {})
836 physical_id = event.get("PhysicalResourceId") or f"ga-dereg-{props.get('Region', 'unknown')}"
837 if request_type != "Delete":
838 return {"PhysicalResourceId": physical_id}
840 endpoint_group_arn = _optional_endpoint_group_arn(props)
841 if endpoint_group_arn:
842 try:
843 ga_client = boto3.client("globalaccelerator", region_name="us-west-2")
844 deregister_alb_from_ga(ga_client, endpoint_group_arn)
845 except Exception as exc: # provider Delete must never wedge the stack
846 logger.error("GA deregistration guard failed: %s", exc, exc_info=True)
848 region = props.get("Region")
849 if region:
850 registry_region = _get_registry_region(props, DEFAULT_REGISTRY_REGION)
851 assert registry_region is not None
852 try:
853 delete_alb_hostname_from_ssm(
854 str(region),
855 registry_region,
856 str(props.get("ProjectName", "gco")),
857 )
858 except Exception as exc: # provider Delete must never wedge the stack
859 logger.error("SSM registry cleanup guard failed: %s", exc, exc_info=True)
860 else:
861 logger.warning("No Region supplied; cannot identify the SSM registry parameter")
863 return {"PhysicalResourceId": physical_id}
866def lambda_handler(event: dict[str, Any], context: Any) -> Any:
867 """Dispatch Step Functions tasks or raw CloudFormation resource events."""
868 if event.get("Action"):
869 logger.info("Task event: %s", json.dumps(event))
870 return handle_task(event)
872 logger.info("CloudFormation event: %s", json.dumps(event))
873 request_type = event["RequestType"]
874 props = event["ResourceProperties"]
875 physical_id = event.get("PhysicalResourceId", f"ga-reg-{props['ClusterName']}")
876 try:
877 if request_type == "Delete":
878 handle_delete(event, context, props, physical_id)
879 else:
880 handle_create_update(event, context, props, physical_id)
881 except Exception as exc: # must answer the raw custom resource
882 logger.error("Registration handler failed: %s", exc, exc_info=True)
883 if request_type == "Delete":
884 send_response(event, context, "SUCCESS", {}, physical_id)
885 else:
886 send_response(event, context, "FAILED", {}, physical_id, str(exc))