Coverage for gco/stacks/global_stack.py: 94.96%
242 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""
2Global shared-resources stack with optional commercial-partition routing.
4This stack always creates partition-wide shared state: SSM registries, DynamoDB
5tables, ECR/S3 resources, backups, and optional capacity history. In the
6commercial ``aws`` partition it also creates AWS Global Accelerator, its
7TCP/443 listener, and one endpoint group per workload region. Other AWS
8partitions omit those unavailable resources and use regional IAM APIs.
10Regional ALB registration is performed separately by each regional stack only
11when the accelerator topology exists.
12"""
14from typing import Any
16from aws_cdk import (
17 CfnOutput,
18 Duration,
19 Fn,
20 RemovalPolicy,
21 Stack,
22)
23from aws_cdk import aws_backup as backup
24from aws_cdk import aws_dynamodb as dynamodb
25from aws_cdk import aws_ecr as ecr
26from aws_cdk import aws_events as events
27from aws_cdk import aws_globalaccelerator as ga
28from aws_cdk import aws_iam as iam
29from aws_cdk import aws_kms as kms
30from aws_cdk import aws_lambda as lambda_
31from aws_cdk import aws_s3 as s3
32from aws_cdk import aws_ssm as ssm
33from constructs import Construct
35from gco.config.config_loader import ConfigLoader
36from gco.stacks.constants import (
37 LAMBDA_PYTHON_RUNTIME,
38 cluster_shared_bucket_name_prefix,
39 cluster_shared_ssm_parameter_prefix,
40)
42# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
43# Generated at (UTC): 2026-07-18T01:03:40Z
44# Flowchart(s) generated from this file:
45# * ``GCOGlobalStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/global_stack.GCOGlobalStack___init__.html``
46# (PNG: ``diagrams/code_diagrams/gco/stacks/global_stack.GCOGlobalStack___init__.png``)
47# * ``GCOGlobalStack._create_image_replication_rule`` -> ``diagrams/code_diagrams/gco/stacks/global_stack.GCOGlobalStack__create_image_replication_rule.html``
48# (PNG: ``diagrams/code_diagrams/gco/stacks/global_stack.GCOGlobalStack__create_image_replication_rule.png``)
49# Regenerate with ``python diagrams/code_diagrams/generate.py``.
50# <pyflowchart-code-diagram> END
53# Default values for the ``images`` cdk.json block. The defaults match the
54# documented retention posture: repos survive a stack destroy by default
55# (``retain``), non-empty repos block destroy unless the operator explicitly
56# flips ``empty_on_delete`` to true, lifecycle keeps the latest 20 tagged
57# images and expires untagged ones after 7 days, and replication is enabled
58# by default to every deployed region.
59_IMAGES_DEFAULT_REMOVAL_POLICY = "retain"
60_IMAGES_DEFAULT_EMPTY_ON_DELETE = False
61_IMAGES_DEFAULT_KEEP_TAGGED = 20
62_IMAGES_DEFAULT_EXPIRE_UNTAGGED_DAYS = 7
63_IMAGES_DEFAULT_REPLICATION_ENABLED = True
64_IMAGES_DEFAULT_REPLICATION_DESTINATIONS = "all_deployed_regions"
66_IMAGES_VALID_REMOVAL_POLICIES = ("retain", "destroy")
69def _parse_images_config(cdk_context: dict[str, Any] | None) -> dict[str, Any]:
70 """Parse the ``images`` block from cdk.json with defaults applied.
72 Returns a normalized dict shape that the rest of the global stack
73 can consume without re-parsing. Validates ``removal_policy`` against
74 the set ``{"retain", "destroy"}`` and ``replication.destinations``
75 against either the literal string ``"all_deployed_regions"`` or a
76 ``list[str]``.
78 Args:
79 cdk_context: The dict returned by ``self.node.try_get_context("images")``.
80 ``None`` (the key being absent) is equivalent to an empty dict.
82 Returns:
83 A dict with keys ``removal_policy``, ``empty_on_delete``,
84 ``lifecycle`` (with ``keep_tagged`` and ``expire_untagged_days``),
85 and ``replication`` (with ``enabled`` and ``destinations``).
86 """
87 raw = cdk_context or {}
89 removal_policy = raw.get("removal_policy", _IMAGES_DEFAULT_REMOVAL_POLICY)
90 if not isinstance(removal_policy, str) or removal_policy not in _IMAGES_VALID_REMOVAL_POLICIES:
91 raise ValueError(
92 f"images.removal_policy must be 'retain' or 'destroy', got {removal_policy!r}"
93 )
95 empty_on_delete = bool(raw.get("empty_on_delete", _IMAGES_DEFAULT_EMPTY_ON_DELETE))
97 lifecycle_raw = raw.get("lifecycle") or {}
98 if not isinstance(lifecycle_raw, dict):
99 raise ValueError(f"images.lifecycle must be a mapping, got {type(lifecycle_raw).__name__}")
100 keep_tagged = int(lifecycle_raw.get("keep_tagged", _IMAGES_DEFAULT_KEEP_TAGGED))
101 expire_untagged_days = int(
102 lifecycle_raw.get("expire_untagged_days", _IMAGES_DEFAULT_EXPIRE_UNTAGGED_DAYS)
103 )
105 replication_raw = raw.get("replication") or {}
106 if not isinstance(replication_raw, dict):
107 raise ValueError(
108 f"images.replication must be a mapping, got {type(replication_raw).__name__}"
109 )
110 replication_enabled = bool(replication_raw.get("enabled", _IMAGES_DEFAULT_REPLICATION_ENABLED))
111 destinations = replication_raw.get("destinations", _IMAGES_DEFAULT_REPLICATION_DESTINATIONS)
112 if isinstance(destinations, str):
113 if destinations != _IMAGES_DEFAULT_REPLICATION_DESTINATIONS:
114 raise ValueError(
115 "images.replication.destinations must be the string "
116 f"{_IMAGES_DEFAULT_REPLICATION_DESTINATIONS!r} or a list of region names, "
117 f"got {destinations!r}"
118 )
119 elif isinstance(destinations, list):
120 if not all(isinstance(item, str) for item in destinations):
121 raise ValueError(
122 "images.replication.destinations list must contain only region name strings"
123 )
124 else:
125 raise ValueError(
126 "images.replication.destinations must be the string "
127 f"{_IMAGES_DEFAULT_REPLICATION_DESTINATIONS!r} or a list of region names, "
128 f"got {type(destinations).__name__}"
129 )
131 return {
132 "removal_policy": removal_policy,
133 "empty_on_delete": empty_on_delete,
134 "lifecycle": {
135 "keep_tagged": keep_tagged,
136 "expire_untagged_days": expire_untagged_days,
137 },
138 "replication": {
139 "enabled": replication_enabled,
140 "destinations": destinations,
141 },
142 }
145class GCOGlobalStack(Stack):
146 """Global shared resources with optional AWS Global Accelerator.
148 This stack must be deployed before regional stacks. In the commercial
149 ``aws`` partition, regional stacks register their ALBs with endpoint groups
150 created here. Other partitions omit the accelerator topology.
152 Attributes:
153 accelerator: Optional Global Accelerator resource.
154 listener: Optional TCP/443 listener.
155 endpoint_groups: Region-to-endpoint-group mapping; empty without GA.
156 templates_table: DynamoDB table for job templates.
157 webhooks_table: DynamoDB table for webhooks.
158 missions_table: DynamoDB table for mission session state.
159 """
161 def __init__(
162 self, scope: Construct, construct_id: str, config: ConfigLoader, **kwargs: Any
163 ) -> None:
164 super().__init__(scope, construct_id, **kwargs)
166 self.config = config
167 self.regional_endpoints: dict[str, str] = {}
168 self.endpoint_groups: dict[str, ga.EndpointGroup] = {}
169 supports_global_accelerator = getattr(config, "supports_global_accelerator", None)
170 self.global_accelerator_enabled = (
171 bool(supports_global_accelerator()) if callable(supports_global_accelerator) else True
172 )
174 ga_config = self.config.get_global_accelerator_config()
176 # Store the accelerator name for reference by other stacks. Defaults
177 # to ``<project_name>-accelerator`` when not pinned in cdk.json so a
178 # second deployment gets its own project-scoped name (#139); an explicit
179 # ``global_accelerator.name`` still overrides.
180 self.accelerator_name = (
181 ga_config.get("name") or f"{self.config.get_project_name()}-accelerator"
182 )
184 # Create DynamoDB tables for templates and webhooks
185 self._create_dynamodb_tables()
187 # Create S3 bucket for model weights
188 self._create_model_bucket()
190 # Create always-on Cluster_Shared_Bucket + KMS key + SSM parameters.
191 # These run unconditionally (no feature toggle) — they are consumed by
192 # every Regional_Stack and, when analytics is enabled, by GCOAnalyticsStack.
193 self._create_cluster_shared_kms_key()
194 self._create_cluster_shared_bucket()
195 self._publish_cluster_shared_bucket_ssm_params()
197 # Create AWS Backup plan for DynamoDB tables
198 self._create_backup_plan()
200 # Container image registry — parses the cdk.json ``images`` block,
201 # provisions the optional ECR replication rule for ``gco/*`` repos,
202 # and creates the lookup-or-create custom resource Lambda that
203 # ``cli images init`` will invoke per-repo on demand. The Lambda
204 # construct is created here regardless of replication settings so
205 # the function ARN is available for downstream invocations.
206 self.images_config = _parse_images_config(self.node.try_get_context("images"))
207 self._create_image_replication_rule()
208 self._create_image_lookup_lambda()
210 # Optional Historical Capacity Surface add-on (gated by historical.enabled
211 # in cdk.json). Folded into the global stack rather than a separate stack
212 # so it reuses the global DynamoDB/encryption conventions.
213 if self.config.get_capacity_history_enabled():
214 self._create_capacity_poller()
216 # Global Accelerator is available only in the commercial ``aws``
217 # partition. Other coherent AWS partitions retain all shared resources
218 # and use their IAM-authenticated regional API bridges directly rather
219 # than synthesizing an unavailable global service.
220 self.accelerator: ga.Accelerator | None = None
221 self.listener: ga.Listener | None = None
222 self.accelerator_id: str | None = None
223 if self.global_accelerator_enabled:
224 # There is deliberately no port-80 listener, so the backend path
225 # cannot downgrade from authenticated TLS.
226 self.accelerator = ga.Accelerator(
227 self,
228 "GCOAccelerator",
229 accelerator_name=self.accelerator_name,
230 enabled=True,
231 )
232 self.accelerator_id = Fn.select(
233 1,
234 Fn.split("/", self.accelerator.accelerator_arn),
235 )
236 self.listener = self.accelerator.add_listener(
237 "GCOListener",
238 port_ranges=[ga.PortRange(from_port=443, to_port=443)],
239 protocol=ga.ConnectionProtocol.TCP,
240 client_affinity=self._resolve_client_affinity(ga_config),
241 )
242 for region in self.config.get_regions():
243 self._create_endpoint_group(region)
244 self._create_outputs()
246 # Apply cdk-nag suppressions
247 self._apply_nag_suppressions()
249 @staticmethod
250 def _resolve_client_affinity(ga_config: dict[str, Any]) -> ga.ClientAffinity:
251 """Map the ``client_affinity`` cdk.json knob to a CDK enum.
253 Global Accelerator supports two client-affinity modes:
255 - ``NONE`` (default): each new connection may be routed to any
256 healthy endpoint, maximising even load distribution.
257 - ``SOURCE_IP``: connections from the same source IP are pinned to
258 the same endpoint, which is useful for workloads that keep
259 per-client state on a single region.
261 The value is validated up front by
262 ``ConfigLoader._validate_global_accelerator_config`` so an unknown
263 string never reaches this point; the fallback to ``NONE`` keeps the
264 stack synthesizable even when the key is omitted entirely.
265 """
266 affinity = str(ga_config.get("client_affinity", "NONE")).upper()
267 mapping = {
268 "NONE": ga.ClientAffinity.NONE,
269 "SOURCE_IP": ga.ClientAffinity.SOURCE_IP,
270 }
271 return mapping.get(affinity, ga.ClientAffinity.NONE)
273 def _create_capacity_poller(self) -> None:
274 """Create the optional Historical Capacity Surface add-on.
276 This is an optional add-on to the global stack (not a separate stack),
277 gated by ``historical.enabled`` in cdk.json. It provisions a DynamoDB
278 time-series table plus an EventBridge-scheduled Lambda that snapshots
279 capacity signals (spot score, spot price, AZ coverage, capacity-block
280 availability) for the watched instance types across the enabled regions,
281 reusing this stack's DynamoDB/encryption conventions.
282 """
283 from aws_cdk import aws_events_targets as events_targets
284 from aws_cdk import aws_sqs as sqs
286 from gco.stacks.nag_suppressions import acknowledge_nag_findings
288 project_name = self.config.get_project_name()
289 historical = self.config.get_capacity_history_config()
290 retention_days = int(historical["retention_days"])
291 poll_interval_minutes = int(historical["poll_interval_minutes"])
292 watch_instance_types = list(historical["watch_instance_types"])
293 enabled_regions = list(historical["enabled_regions"]) or self.config.get_regions()
294 # Capacity Block probe durations. ``get_capacity_history_config`` always
295 # supplies both keys (defaults merged in), so index directly like the
296 # sibling fields above.
297 block_duration_hours = int(historical["capacity_block_duration_hours"])
298 long_block_duration_hours = int(historical["capacity_block_long_duration_hours"])
300 self.capacity_history_table = dynamodb.Table(
301 self,
302 "CapacityHistoryTable",
303 table_name=f"{project_name}-capacity-history",
304 partition_key=dynamodb.Attribute(name="pk", type=dynamodb.AttributeType.STRING),
305 sort_key=dynamodb.Attribute(name="sk", type=dynamodb.AttributeType.STRING),
306 billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
307 removal_policy=RemovalPolicy.DESTROY,
308 point_in_time_recovery_specification=dynamodb.PointInTimeRecoverySpecification(
309 point_in_time_recovery_enabled=True
310 ),
311 encryption=dynamodb.TableEncryption.AWS_MANAGED,
312 time_to_live_attribute="ttl",
313 )
314 self.capacity_history_table.add_global_secondary_index(
315 index_name="by-timestamp",
316 partition_key=dynamodb.Attribute(
317 name="instance_type", type=dynamodb.AttributeType.STRING
318 ),
319 sort_key=dynamodb.Attribute(name="sk", type=dynamodb.AttributeType.STRING),
320 projection_type=dynamodb.ProjectionType.ALL,
321 )
323 poller_role = iam.Role(
324 self,
325 "CapacityPollerRole",
326 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
327 managed_policies=[
328 iam.ManagedPolicy.from_aws_managed_policy_name(
329 "service-role/AWSLambdaBasicExecutionRole"
330 )
331 ],
332 )
333 poller_role.add_to_policy(
334 iam.PolicyStatement(
335 effect=iam.Effect.ALLOW,
336 actions=["dynamodb:PutItem", "dynamodb:BatchWriteItem"],
337 resources=[
338 self.capacity_history_table.table_arn,
339 f"{self.capacity_history_table.table_arn}/index/*",
340 ],
341 )
342 )
343 poller_role.add_to_policy(
344 iam.PolicyStatement(
345 effect=iam.Effect.ALLOW,
346 actions=[
347 "ec2:DescribeSpotPriceHistory",
348 "ec2:GetSpotPlacementScores",
349 "ec2:DescribeCapacityBlockOfferings",
350 "ec2:DescribeCapacityReservations",
351 "ec2:DescribeAvailabilityZones",
352 ],
353 resources=["*"],
354 )
355 )
357 self.capacity_poller_lambda = lambda_.Function(
358 self,
359 "CapacityPollerFunction",
360 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
361 handler="handler.lambda_handler",
362 code=lambda_.Code.from_asset("lambda/capacity-poller"),
363 timeout=Duration.minutes(14),
364 memory_size=256,
365 role=poller_role,
366 environment={
367 "CAPACITY_HISTORY_TABLE_NAME": self.capacity_history_table.table_name,
368 "WATCH_INSTANCE_TYPES": ",".join(watch_instance_types),
369 "ENABLED_REGIONS": ",".join(enabled_regions),
370 "CAPACITY_HISTORY_RETENTION_DAYS": str(retention_days),
371 "CAPACITY_BLOCK_DURATION_HOURS": str(block_duration_hours),
372 "CAPACITY_BLOCK_LONG_DURATION_HOURS": str(long_block_duration_hours),
373 },
374 tracing=lambda_.Tracing.ACTIVE,
375 description=(
376 "Historical Capacity Surface poller (optional global-stack add-on): "
377 "snapshots capacity signals into the capacity-history table."
378 ),
379 )
381 poller_dlq = sqs.Queue(
382 self,
383 "CapacityPollerRuleDlq",
384 retention_period=Duration.days(14),
385 enforce_ssl=True,
386 encryption=sqs.QueueEncryption.SQS_MANAGED,
387 removal_policy=RemovalPolicy.DESTROY,
388 )
389 events.Rule(
390 self,
391 "CapacityPollerSchedule",
392 description=(
393 f"Capacity poller for {project_name} history surface "
394 f"(every {poll_interval_minutes} min)"
395 ),
396 schedule=events.Schedule.rate(Duration.minutes(poll_interval_minutes)),
397 targets=[
398 events_targets.LambdaFunction(
399 self.capacity_poller_lambda, dead_letter_queue=poller_dlq, retry_attempts=2
400 )
401 ],
402 )
404 ssm.StringParameter(
405 self,
406 "CapacityHistoryTableNameParam",
407 parameter_name=f"/{project_name}/capacity-history-table-name",
408 string_value=self.capacity_history_table.table_name,
409 description="DynamoDB table name for historical capacity snapshots",
410 )
411 CfnOutput(
412 self,
413 "CapacityHistoryTableName",
414 value=self.capacity_history_table.table_name,
415 description="DynamoDB table name for historical capacity snapshots",
416 export_name=f"{project_name}-capacity-history-table-name",
417 )
418 CfnOutput(
419 self,
420 "CapacityHistoryTableArn",
421 value=self.capacity_history_table.table_arn,
422 description="DynamoDB table ARN for historical capacity snapshots",
423 export_name=f"{project_name}-capacity-history-table-arn",
424 )
426 acknowledge_nag_findings(
427 poller_role,
428 [
429 {
430 "id": "AwsSolutions-IAM4",
431 "reason": (
432 "AWSLambdaBasicExecutionRole provides the standard CloudWatch "
433 "Logs permissions every Lambda needs."
434 ),
435 },
436 {
437 "id": "AwsSolutions-IAM5",
438 "reason": (
439 "The EC2 capacity describe/get APIs (DescribeSpotPriceHistory, "
440 "GetSpotPlacementScores, DescribeCapacityBlockOfferings, "
441 "DescribeCapacityReservations, DescribeAvailabilityZones) do not "
442 "support resource-level permissions and require a wildcard "
443 "resource. The DynamoDB index wildcard is scoped to this table's "
444 "own indexes."
445 ),
446 "appliesTo": [
447 "Resource::*",
448 "Resource::<CapacityHistoryTable506A0FBA.Arn>/index/*",
449 ],
450 },
451 ],
452 )
453 acknowledge_nag_findings(
454 poller_dlq,
455 [
456 {
457 "id": "AwsSolutions-SQS3",
458 "reason": (
459 "This queue is the dead-letter queue for the "
460 "CapacityPollerSchedule EventBridge rule; a DLQ for a DLQ is "
461 "circular."
462 ),
463 },
464 {
465 "id": "Serverless-SQSRedrivePolicy",
466 "reason": (
467 "This queue is itself the dead-letter queue for the "
468 "CapacityPollerSchedule EventBridge rule, so it does not need "
469 "its own redrive policy; a DLQ for a DLQ is circular."
470 ),
471 },
472 ],
473 )
474 acknowledge_nag_findings(
475 self.capacity_history_table,
476 [
477 {
478 "id": "HIPAA.Security-DynamoDBInBackupPlan",
479 "reason": (
480 "The capacity-history table holds ephemeral, reconstructable "
481 "telemetry snapshots with a TTL (default 90 days) and "
482 "point-in-time recovery enabled. The poller re-collects this "
483 "data continuously, so an AWS Backup plan is unnecessary for "
484 "this optional add-on."
485 ),
486 },
487 {
488 "id": "NIST.800.53.R5-DynamoDBInBackupPlan",
489 "reason": (
490 "The capacity-history table holds ephemeral, reconstructable "
491 "telemetry snapshots with a TTL (default 90 days) and "
492 "point-in-time recovery enabled. The poller re-collects this "
493 "data continuously, so an AWS Backup plan is unnecessary for "
494 "this optional add-on."
495 ),
496 },
497 ],
498 )
500 def _create_outputs(self) -> None:
501 """Create CloudFormation outputs for cross-stack references."""
502 if self.accelerator is None or self.listener is None: 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true
503 raise RuntimeError("Global Accelerator outputs require the commercial AWS partition")
504 project_name = self.config.get_project_name()
506 CfnOutput(
507 self,
508 "GlobalAcceleratorDnsName",
509 value=self.accelerator.dns_name,
510 description="Global Accelerator DNS name for global endpoint",
511 export_name=f"{project_name}-global-accelerator-dns",
512 )
514 CfnOutput(
515 self,
516 "GlobalAcceleratorArn",
517 value=self.accelerator.accelerator_arn,
518 description="Global Accelerator ARN",
519 export_name=f"{project_name}-global-accelerator-arn",
520 )
522 CfnOutput(
523 self,
524 "GlobalAcceleratorListenerArn",
525 value=self.listener.listener_arn,
526 description="Global Accelerator Listener ARN",
527 export_name=f"{project_name}-global-accelerator-listener-arn",
528 )
530 def _apply_nag_suppressions(self) -> None:
531 """Apply cdk-nag suppressions for this stack."""
532 from gco.stacks.nag_suppressions import apply_all_suppressions
534 apply_all_suppressions(
535 self, stack_type="global", project_name=self.config.get_project_name()
536 )
538 def _create_endpoint_group(self, region: str) -> None:
539 """
540 Create an endpoint group for a specific region.
542 Configures an HTTPS/443 health-check contract matching the ALB's only
543 listener. Global Accelerator derives ALB endpoint health from the ALB
544 target groups, but keeping the endpoint-group settings aligned prevents
545 an accidental plaintext fallback if the endpoint type changes later.
547 Also stores the endpoint group ARN in SSM Parameter Store for
548 cross-region access by regional stacks.
550 Args:
551 region: AWS region name (e.g., 'us-east-1')
552 """
553 if self.listener is None: 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true
554 raise RuntimeError(
555 "Global Accelerator endpoint groups are unavailable in this partition"
556 )
557 project_name = self.config.get_project_name()
558 region_id = region.replace("-", "").title()
559 ga_config = self.config.get_global_accelerator_config()
561 # Keep the endpoint-group contract aligned with the HTTPS-only ALB.
562 # For ALB endpoints GA uses target-group health rather than actively
563 # applying these probe settings, but 443/HTTPS remains the safe default
564 # if an endpoint type is changed in a future deployment.
565 endpoint_group = self.listener.add_endpoint_group(
566 f"EndpointGroup{region_id}",
567 region=region,
568 health_check_port=443,
569 health_check_protocol=ga.HealthCheckProtocol.HTTPS,
570 health_check_path=ga_config.get("health_check_path", "/api/v1/health"),
571 health_check_interval=Duration.seconds(ga_config.get("health_check_interval", 30)),
572 health_check_threshold=3,
573 )
575 self.endpoint_groups[region] = endpoint_group
577 # Export endpoint group ARN for regional stacks
578 CfnOutput(
579 self,
580 f"EndpointGroup{region_id}Arn",
581 value=endpoint_group.endpoint_group_arn,
582 description=f"Endpoint group ARN for {region}",
583 export_name=f"{project_name}-endpoint-group-{region}-arn",
584 )
586 # Store endpoint group ARN in SSM Parameter Store for cross-region access
587 # Regional stacks read this to register their ALBs with Global Accelerator
588 ssm.StringParameter(
589 self,
590 f"EndpointGroup{region_id}ArnParam",
591 parameter_name=f"/{project_name}/endpoint-group-{region}-arn",
592 string_value=endpoint_group.endpoint_group_arn,
593 description=f"Global Accelerator endpoint group ARN for {region}",
594 )
596 def add_regional_endpoint(self, region: str, alb_arn: str) -> None:
597 """Add a regional ALB endpoint to the Global Accelerator.
599 Note: Due to cross-region reference limitations in CDK, the actual endpoint
600 registration is handled by a custom resource in the regional stack.
601 This method stores the ARN for reference but doesn't directly register it.
603 The regional stack should use the endpoint group ARN exported by this stack
604 to register its ALB via an AwsCustomResource.
605 """
606 self.regional_endpoints[region] = alb_arn
607 # Actual registration happens in regional stack via custom resource
609 def get_accelerator_dns_name(self) -> str | None:
610 """Return the Global Accelerator DNS name when this partition supports it."""
611 return str(self.accelerator.dns_name) if self.accelerator is not None else None
613 def get_accelerator_arn(self) -> str:
614 """Get the Global Accelerator ARN."""
615 if self.accelerator is None: 615 ↛ 616line 615 didn't jump to line 616 because the condition on line 615 was never true
616 raise RuntimeError("Global Accelerator is unavailable in this partition")
617 return str(self.accelerator.accelerator_arn)
619 def get_listener_arn(self) -> str:
620 """Get the Global Accelerator Listener ARN."""
621 if self.listener is None: 621 ↛ 622line 621 didn't jump to line 622 because the condition on line 621 was never true
622 raise RuntimeError("Global Accelerator is unavailable in this partition")
623 return str(self.listener.listener_arn)
625 def get_endpoint_group_arn(self, region: str) -> str:
626 """Get the endpoint group ARN for a specific region"""
627 if region in self.endpoint_groups:
628 return str(self.endpoint_groups[region].endpoint_group_arn)
629 raise ValueError(f"No endpoint group found for region: {region}")
631 def _create_dynamodb_tables(self) -> None:
632 """Create DynamoDB tables for templates, webhooks, jobs, inference endpoints, and missions."""
633 project_name = self.config.get_project_name()
635 # Job Templates table - stores reusable job templates
636 self.templates_table = dynamodb.Table(
637 self,
638 "JobTemplatesTable",
639 table_name=f"{project_name}-job-templates",
640 partition_key=dynamodb.Attribute(
641 name="template_name",
642 type=dynamodb.AttributeType.STRING,
643 ),
644 billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
645 removal_policy=RemovalPolicy.DESTROY,
646 point_in_time_recovery_specification=dynamodb.PointInTimeRecoverySpecification(
647 point_in_time_recovery_enabled=True
648 ),
649 encryption=dynamodb.TableEncryption.AWS_MANAGED,
650 )
652 # Webhooks table - stores webhook registrations
653 self.webhooks_table = dynamodb.Table(
654 self,
655 "WebhooksTable",
656 table_name=f"{project_name}-webhooks",
657 partition_key=dynamodb.Attribute(
658 name="webhook_id",
659 type=dynamodb.AttributeType.STRING,
660 ),
661 billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
662 removal_policy=RemovalPolicy.DESTROY,
663 point_in_time_recovery_specification=dynamodb.PointInTimeRecoverySpecification(
664 point_in_time_recovery_enabled=True
665 ),
666 encryption=dynamodb.TableEncryption.AWS_MANAGED,
667 )
669 # Add GSI for querying webhooks by namespace
670 self.webhooks_table.add_global_secondary_index(
671 index_name="namespace-index",
672 partition_key=dynamodb.Attribute(
673 name="namespace",
674 type=dynamodb.AttributeType.STRING,
675 ),
676 projection_type=dynamodb.ProjectionType.ALL,
677 )
679 # Jobs table - centralized job tracking and queue
680 # This enables global job submission with regional pickup
681 self.jobs_table = dynamodb.Table(
682 self,
683 "JobsTable",
684 table_name=f"{project_name}-jobs",
685 partition_key=dynamodb.Attribute(
686 name="job_id",
687 type=dynamodb.AttributeType.STRING,
688 ),
689 billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
690 removal_policy=RemovalPolicy.DESTROY,
691 point_in_time_recovery_specification=dynamodb.PointInTimeRecoverySpecification(
692 point_in_time_recovery_enabled=True
693 ),
694 encryption=dynamodb.TableEncryption.AWS_MANAGED,
695 time_to_live_attribute="ttl", # Auto-cleanup old completed jobs
696 )
698 # Legacy GSI retained for compatibility with existing deployments and
699 # ad-hoc operational queries.
700 self.jobs_table.add_global_secondary_index(
701 index_name="region-status-index",
702 partition_key=dynamodb.Attribute(
703 name="target_region",
704 type=dynamodb.AttributeType.STRING,
705 ),
706 sort_key=dynamodb.Attribute(
707 name="status",
708 type=dynamodb.AttributeType.STRING,
709 ),
710 projection_type=dynamodb.ProjectionType.ALL,
711 )
713 # ``work_sort`` is priority/FIFO for queued records and lease expiry for
714 # claimed or applying records. This unified worker index is the only GSI
715 # added by this release because DynamoDB permits only one GSI creation or
716 # deletion per table update. Workers repeatedly backfill legacy rows
717 # through the retained region-status-index during mixed-version rollouts.
718 self.jobs_table.add_global_secondary_index(
719 index_name="region-status-work-index",
720 partition_key=dynamodb.Attribute(
721 name="region_status",
722 type=dynamodb.AttributeType.STRING,
723 ),
724 sort_key=dynamodb.Attribute(
725 name="work_sort",
726 type=dynamodb.AttributeType.STRING,
727 ),
728 projection_type=dynamodb.ProjectionType.ALL,
729 )
731 # GSI for querying jobs by namespace
732 self.jobs_table.add_global_secondary_index(
733 index_name="namespace-index",
734 partition_key=dynamodb.Attribute(
735 name="namespace",
736 type=dynamodb.AttributeType.STRING,
737 ),
738 sort_key=dynamodb.Attribute(
739 name="submitted_at",
740 type=dynamodb.AttributeType.STRING,
741 ),
742 projection_type=dynamodb.ProjectionType.ALL,
743 )
745 # GSI for querying jobs by status globally
746 self.jobs_table.add_global_secondary_index(
747 index_name="status-index",
748 partition_key=dynamodb.Attribute(
749 name="status",
750 type=dynamodb.AttributeType.STRING,
751 ),
752 sort_key=dynamodb.Attribute(
753 name="submitted_at",
754 type=dynamodb.AttributeType.STRING,
755 ),
756 projection_type=dynamodb.ProjectionType.ALL,
757 )
759 # Export table names and ARNs for regional stacks
760 CfnOutput(
761 self,
762 "TemplatesTableName",
763 value=self.templates_table.table_name,
764 description="DynamoDB table name for job templates",
765 export_name=f"{project_name}-templates-table-name",
766 )
768 CfnOutput(
769 self,
770 "TemplatesTableArn",
771 value=self.templates_table.table_arn,
772 description="DynamoDB table ARN for job templates",
773 export_name=f"{project_name}-templates-table-arn",
774 )
776 CfnOutput(
777 self,
778 "WebhooksTableName",
779 value=self.webhooks_table.table_name,
780 description="DynamoDB table name for webhooks",
781 export_name=f"{project_name}-webhooks-table-name",
782 )
784 CfnOutput(
785 self,
786 "WebhooksTableArn",
787 value=self.webhooks_table.table_arn,
788 description="DynamoDB table ARN for webhooks",
789 export_name=f"{project_name}-webhooks-table-arn",
790 )
792 CfnOutput(
793 self,
794 "JobsTableName",
795 value=self.jobs_table.table_name,
796 description="DynamoDB table name for centralized job tracking",
797 export_name=f"{project_name}-jobs-table-name",
798 )
800 CfnOutput(
801 self,
802 "JobsTableArn",
803 value=self.jobs_table.table_arn,
804 description="DynamoDB table ARN for centralized job tracking",
805 export_name=f"{project_name}-jobs-table-arn",
806 )
808 # Inference Endpoints table - stores desired state for inference deployments
809 # The inference_monitor in each regional cluster polls this table
810 self.inference_endpoints_table = dynamodb.Table(
811 self,
812 "InferenceEndpointsTable",
813 table_name=f"{project_name}-inference-endpoints",
814 partition_key=dynamodb.Attribute(
815 name="endpoint_name",
816 type=dynamodb.AttributeType.STRING,
817 ),
818 billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
819 removal_policy=RemovalPolicy.DESTROY,
820 point_in_time_recovery_specification=dynamodb.PointInTimeRecoverySpecification(
821 point_in_time_recovery_enabled=True
822 ),
823 encryption=dynamodb.TableEncryption.AWS_MANAGED,
824 )
826 CfnOutput(
827 self,
828 "InferenceEndpointsTableName",
829 value=self.inference_endpoints_table.table_name,
830 description="DynamoDB table name for inference endpoint state",
831 export_name=f"{project_name}-inference-endpoints-table-name",
832 )
834 CfnOutput(
835 self,
836 "InferenceEndpointsTableArn",
837 value=self.inference_endpoints_table.table_arn,
838 description="DynamoDB table ARN for inference endpoint state",
839 export_name=f"{project_name}-inference-endpoints-table-arn",
840 )
842 # Missions table - persists goal-directed iteration session state
843 # Partition by session_id; the status-index GSI supports paginated
844 # listing by status (e.g. running, completed, terminated, failed).
845 self.missions_table = dynamodb.Table(
846 self,
847 "MissionsTable",
848 table_name=f"{project_name}-missions",
849 partition_key=dynamodb.Attribute(
850 name="session_id",
851 type=dynamodb.AttributeType.STRING,
852 ),
853 billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
854 removal_policy=RemovalPolicy.DESTROY,
855 point_in_time_recovery_specification=dynamodb.PointInTimeRecoverySpecification(
856 point_in_time_recovery_enabled=True
857 ),
858 encryption=dynamodb.TableEncryption.AWS_MANAGED,
859 )
861 # GSI for paginating sessions by status (sorted by creation time)
862 self.missions_table.add_global_secondary_index(
863 index_name="status-index",
864 partition_key=dynamodb.Attribute(
865 name="status",
866 type=dynamodb.AttributeType.STRING,
867 ),
868 sort_key=dynamodb.Attribute(
869 name="created_at",
870 type=dynamodb.AttributeType.STRING,
871 ),
872 projection_type=dynamodb.ProjectionType.ALL,
873 )
875 CfnOutput(
876 self,
877 "MissionsTableName",
878 value=self.missions_table.table_name,
879 description="DynamoDB table name for mission session state",
880 export_name=f"{project_name}-missions-table-name",
881 )
883 CfnOutput(
884 self,
885 "MissionsTableArn",
886 value=self.missions_table.table_arn,
887 description="DynamoDB table ARN for mission session state",
888 export_name=f"{project_name}-missions-table-arn",
889 )
891 # Store table names in SSM for cross-region access
892 ssm.StringParameter(
893 self,
894 "TemplatesTableNameParam",
895 parameter_name=f"/{project_name}/templates-table-name",
896 string_value=self.templates_table.table_name,
897 description="DynamoDB table name for job templates",
898 )
900 ssm.StringParameter(
901 self,
902 "WebhooksTableNameParam",
903 parameter_name=f"/{project_name}/webhooks-table-name",
904 string_value=self.webhooks_table.table_name,
905 description="DynamoDB table name for webhooks",
906 )
908 ssm.StringParameter(
909 self,
910 "JobsTableNameParam",
911 parameter_name=f"/{project_name}/jobs-table-name",
912 string_value=self.jobs_table.table_name,
913 description="DynamoDB table name for centralized job tracking",
914 )
916 ssm.StringParameter(
917 self,
918 "InferenceEndpointsTableNameParam",
919 parameter_name=f"/{project_name}/inference-endpoints-table-name",
920 string_value=self.inference_endpoints_table.table_name,
921 description="DynamoDB table name for inference endpoint state",
922 )
924 ssm.StringParameter(
925 self,
926 "MissionsTableNameParam",
927 parameter_name=f"/{project_name}/missions-table-name",
928 string_value=self.missions_table.table_name,
929 description="DynamoDB table name for mission session state",
930 )
932 def _create_model_bucket(self) -> None:
933 """Create S3 bucket for model weights.
935 This bucket serves as the central model registry. Users upload model
936 weights here once, and the inference_monitor's init containers sync
937 them to each region's local EFS at pod startup.
939 The bucket name is auto-generated by CDK to avoid naming collisions.
940 It's exported via CfnOutput and SSM for CLI discovery.
941 """
942 project_name = self.config.get_project_name()
944 # KMS key for model bucket encryption
945 self.model_bucket_key = kms.Key(
946 self,
947 "ModelBucketKey",
948 description="KMS key for GCO model weights bucket",
949 enable_key_rotation=True,
950 removal_policy=RemovalPolicy.DESTROY,
951 )
953 # Access logs bucket (required for compliance)
954 # Retention is configurable via cdk.json context field `s3_access_logs.retention_days`
955 # (default: 90 days). Logs older than the configured retention are expired.
956 s3_access_logs_ctx = self.node.try_get_context("s3_access_logs") or {}
957 access_logs_retention_days = int(s3_access_logs_ctx.get("retention_days", 90))
959 self.model_bucket_access_logs = s3.Bucket(
960 self,
961 "ModelWeightsAccessLogsBucket",
962 encryption=s3.BucketEncryption.S3_MANAGED,
963 block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
964 enforce_ssl=True,
965 versioned=True,
966 removal_policy=RemovalPolicy.DESTROY,
967 auto_delete_objects=True,
968 lifecycle_rules=[
969 s3.LifecycleRule(
970 id="ExpireAccessLogs",
971 enabled=True,
972 expiration=Duration.days(access_logs_retention_days),
973 )
974 ],
975 )
977 # Model weights bucket
978 self.model_bucket = s3.Bucket(
979 self,
980 "ModelWeightsBucket",
981 encryption=s3.BucketEncryption.KMS,
982 encryption_key=self.model_bucket_key,
983 bucket_key_enabled=True,
984 block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
985 enforce_ssl=True,
986 versioned=True,
987 removal_policy=RemovalPolicy.DESTROY,
988 auto_delete_objects=True,
989 server_access_logs_bucket=self.model_bucket_access_logs,
990 server_access_logs_prefix="model-bucket-logs/",
991 )
993 # CDK-nag suppressions — only replication (not needed for model weights)
994 from gco.stacks.nag_suppressions import acknowledge_nag_findings
996 replication_reason = (
997 "Model weights are user-uploaded artifacts that can be re-uploaded. "
998 "Cross-region replication is not required; the inference_monitor "
999 "syncs models from S3 to each region's EFS at pod startup."
1000 )
1002 acknowledge_nag_findings(
1003 self.model_bucket,
1004 [
1005 {
1006 "id": "HIPAA.Security-S3BucketReplicationEnabled",
1007 "reason": replication_reason,
1008 },
1009 {
1010 "id": "NIST.800.53.R5-S3BucketReplicationEnabled",
1011 "reason": replication_reason,
1012 },
1013 {
1014 "id": "PCI.DSS.321-S3BucketReplicationEnabled",
1015 "reason": replication_reason,
1016 },
1017 ],
1018 )
1020 logs_reason = "This is the server access logs destination bucket."
1021 acknowledge_nag_findings(
1022 self.model_bucket_access_logs,
1023 [
1024 {"id": "AwsSolutions-S1", "reason": logs_reason},
1025 {"id": "HIPAA.Security-S3BucketLoggingEnabled", "reason": logs_reason},
1026 {
1027 "id": "HIPAA.Security-S3BucketReplicationEnabled",
1028 "reason": "Access logs do not require replication.",
1029 },
1030 {
1031 "id": "HIPAA.Security-S3DefaultEncryptionKMS",
1032 "reason": "SSE-S3 is sufficient for access logs.",
1033 },
1034 {"id": "NIST.800.53.R5-S3BucketLoggingEnabled", "reason": logs_reason},
1035 {
1036 "id": "NIST.800.53.R5-S3BucketReplicationEnabled",
1037 "reason": "Access logs do not require replication.",
1038 },
1039 {
1040 "id": "NIST.800.53.R5-S3DefaultEncryptionKMS",
1041 "reason": "SSE-S3 is sufficient for access logs.",
1042 },
1043 {"id": "PCI.DSS.321-S3BucketLoggingEnabled", "reason": logs_reason},
1044 {
1045 "id": "PCI.DSS.321-S3BucketReplicationEnabled",
1046 "reason": "Access logs do not require replication.",
1047 },
1048 {
1049 "id": "PCI.DSS.321-S3DefaultEncryptionKMS",
1050 "reason": "SSE-S3 is sufficient for access logs.",
1051 },
1052 ],
1053 )
1055 CfnOutput(
1056 self,
1057 "ModelBucketName",
1058 value=self.model_bucket.bucket_name,
1059 description="S3 bucket for model weights",
1060 export_name=f"{project_name}-model-bucket-name",
1061 )
1063 CfnOutput(
1064 self,
1065 "ModelBucketArn",
1066 value=self.model_bucket.bucket_arn,
1067 description="S3 bucket ARN for model weights",
1068 export_name=f"{project_name}-model-bucket-arn",
1069 )
1071 ssm.StringParameter(
1072 self,
1073 "ModelBucketNameParam",
1074 parameter_name=f"/{project_name}/model-bucket-name",
1075 string_value=self.model_bucket.bucket_name,
1076 description="S3 bucket name for model weights",
1077 )
1079 def _create_backup_plan(self) -> None:
1080 """Create AWS Backup plan for DynamoDB tables.
1082 Creates a backup plan with:
1083 - Daily backups retained for 35 days
1084 - Weekly backups retained for 90 days
1085 - All DynamoDB tables added to the backup selection
1086 """
1087 # Create backup vault for storing backups
1088 self.backup_vault = backup.BackupVault(
1089 self,
1090 "DynamoDBBackupVault",
1091 removal_policy=RemovalPolicy.DESTROY,
1092 )
1094 # Create backup plan with daily and weekly rules
1095 self.backup_plan = backup.BackupPlan(
1096 self,
1097 "DynamoDBBackupPlan",
1098 backup_plan_rules=[
1099 # Daily backup - retained for 35 days
1100 backup.BackupPlanRule(
1101 rule_name="DailyBackup",
1102 backup_vault=self.backup_vault,
1103 schedule_expression=events.Schedule.cron(
1104 hour="3",
1105 minute="0",
1106 ),
1107 delete_after=Duration.days(35),
1108 enable_continuous_backup=True, # Enable PITR for DynamoDB
1109 ),
1110 # Weekly backup - retained for 90 days
1111 backup.BackupPlanRule(
1112 rule_name="WeeklyBackup",
1113 backup_vault=self.backup_vault,
1114 schedule_expression=events.Schedule.cron(
1115 hour="4",
1116 minute="0",
1117 week_day="SUN",
1118 ),
1119 delete_after=Duration.days(90),
1120 ),
1121 ],
1122 )
1124 # Add all DynamoDB tables to the backup selection
1125 self.backup_plan.add_selection(
1126 "DynamoDBTablesSelection",
1127 resources=[
1128 backup.BackupResource.from_dynamo_db_table(self.templates_table),
1129 backup.BackupResource.from_dynamo_db_table(self.webhooks_table),
1130 backup.BackupResource.from_dynamo_db_table(self.jobs_table),
1131 backup.BackupResource.from_dynamo_db_table(self.inference_endpoints_table),
1132 backup.BackupResource.from_dynamo_db_table(self.missions_table),
1133 ],
1134 )
1136 # Export backup plan ARN
1137 project_name = self.config.get_project_name()
1138 CfnOutput(
1139 self,
1140 "BackupPlanArn",
1141 value=self.backup_plan.backup_plan_arn,
1142 description="AWS Backup plan ARN for DynamoDB tables",
1143 export_name=f"{project_name}-backup-plan-arn",
1144 )
1146 CfnOutput(
1147 self,
1148 "BackupVaultArn",
1149 value=self.backup_vault.backup_vault_arn,
1150 description="AWS Backup vault ARN for DynamoDB backups",
1151 export_name=f"{project_name}-backup-vault-arn",
1152 )
1154 def _create_cluster_shared_kms_key(self) -> None:
1155 """Create the always-on customer-managed KMS key for ``Cluster_Shared_Bucket``.
1157 The key:
1158 - Enables automatic annual rotation.
1159 - Uses a 7-day pending window on destroy — the AWS minimum, matching the
1160 destroy-by-default iteration-loop posture of the analytics-environment
1161 feature while still providing a safety net against accidental deletion.
1162 - Uses ``RemovalPolicy.DESTROY`` so a ``cdk destroy gco-global`` cleans up
1163 the key without operator intervention (iteration-loop posture).
1164 - Grants encrypt/decrypt to the ``s3.amazonaws.com`` and
1165 ``logs.<region>.amazonaws.com`` service principals via the key policy
1166 so S3 server-side encryption and CloudWatch access-log delivery can use
1167 the key without role-side grants.
1169 The key is exposed as ``self.cluster_shared_kms_key`` for tests and for
1170 ``_create_cluster_shared_bucket`` to reference. Role-side usage grants
1171 (``kms:Decrypt`` / ``kms:GenerateDataKey``) are attached by downstream
1172 consumers: ``GCORegionalStack`` on the job-pod role (always-on)
1173 and ``GCOAnalyticsStack`` on the SageMaker execution role (conditional on
1174 the analytics toggle).
1175 """
1176 self.cluster_shared_kms_key = kms.Key(
1177 self,
1178 "ClusterSharedKmsKey",
1179 description=(
1180 "Customer-managed KMS key for the always-on Cluster_Shared_Bucket "
1181 "in GCOGlobalStack. Consumed by every regional EKS cluster and by "
1182 "GCOAnalyticsStack when analytics is enabled."
1183 ),
1184 enable_key_rotation=True,
1185 pending_window=Duration.days(7),
1186 removal_policy=RemovalPolicy.DESTROY,
1187 )
1189 # Key-policy grants for service principals that need to encrypt/decrypt
1190 # on behalf of the bucket (S3 server-side encryption) and the access-logs
1191 # bucket (CloudWatch Logs delivery). The actions match the standard
1192 # service-principal pattern used by cdk's default key policies.
1193 kms_actions = [
1194 "kms:Encrypt",
1195 "kms:Decrypt",
1196 "kms:ReEncrypt*",
1197 "kms:GenerateDataKey*",
1198 "kms:DescribeKey",
1199 ]
1201 self.cluster_shared_kms_key.add_to_resource_policy(
1202 iam.PolicyStatement(
1203 sid="AllowS3ServiceEncryptDecrypt",
1204 effect=iam.Effect.ALLOW,
1205 principals=[iam.ServicePrincipal("s3.amazonaws.com")],
1206 actions=kms_actions,
1207 resources=["*"],
1208 )
1209 )
1211 self.cluster_shared_kms_key.add_to_resource_policy(
1212 iam.PolicyStatement(
1213 sid="AllowCloudWatchLogsEncryptDecrypt",
1214 effect=iam.Effect.ALLOW,
1215 principals=[iam.ServicePrincipal("logs.amazonaws.com", region=self.region)],
1216 actions=kms_actions,
1217 resources=["*"],
1218 )
1219 )
1221 def _create_cluster_shared_bucket(self) -> None:
1222 """Create the always-on ``Cluster_Shared_Bucket`` and its access-logs bucket.
1224 Two buckets are created:
1226 1. ``cluster_shared_access_logs_bucket`` — dedicated S3 access-logs bucket
1227 used as ``server_access_logs_bucket`` for the primary bucket. Separate
1228 from ``model_bucket_access_logs`` so cluster-shared-bucket access logs
1229 are not commingled with model-bucket logs.
1230 2. ``cluster_shared_bucket`` — the primary bucket named
1231 ``<project_name>-cluster-shared-<account>-<global-region>`` (the
1232 prefix from ``cluster_shared_bucket_name_prefix(project_name)`` is
1233 the stable ARN prefix used by IAM policies and nag assertions).
1234 KMS-encrypted with
1235 ``cluster_shared_kms_key``, block-public-access on, SSL enforced,
1236 versioned, destroy-on-teardown.
1238 An explicit ``Deny`` statement for ``aws:SecureTransport=false`` is added
1239 to the bucket policy independent of ``enforce_ssl=True`` so the deny is
1240 verifiable in the synthesized template (belt-and-suspenders).
1242 Grants on ``Cluster_Shared_Bucket`` are intentionally not added here —
1243 they live on downstream role policies (``GCORegionalStack`` on the
1244 job-pod role, ``GCOAnalyticsStack`` on the SageMaker execution role)
1245 rather than in this bucket's policy. The bucket policy contains zero
1246 ``Principal: "*"`` Allow statements.
1247 """
1248 # Retention for the access-logs bucket honors the same `s3_access_logs`
1249 # context field as the model-bucket access-logs bucket (default 90 days).
1250 s3_access_logs_ctx = self.node.try_get_context("s3_access_logs") or {}
1251 access_logs_retention_days = int(s3_access_logs_ctx.get("retention_days", 90))
1253 # Dedicated access-logs bucket for Cluster_Shared_Bucket. Encrypted with
1254 # the cluster-shared KMS key (the key policy grants the logs service
1255 # principal encrypt/decrypt). Kept separate from model_bucket_access_logs
1256 # so operators can reason about each bucket's logs independently. Matches
1257 # the LifecycleRule used on `model_bucket_access_logs` so retention is
1258 # consistent across the two log sinks.
1259 self.cluster_shared_access_logs_bucket = s3.Bucket(
1260 self,
1261 "ClusterSharedAccessLogsBucket",
1262 encryption=s3.BucketEncryption.KMS,
1263 encryption_key=self.cluster_shared_kms_key,
1264 block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
1265 enforce_ssl=True,
1266 versioned=True,
1267 removal_policy=RemovalPolicy.DESTROY,
1268 auto_delete_objects=True,
1269 lifecycle_rules=[
1270 s3.LifecycleRule(
1271 id="ExpireAccessLogs",
1272 enabled=True,
1273 expiration=Duration.days(access_logs_retention_days),
1274 )
1275 ],
1276 )
1278 # Primary Cluster_Shared_Bucket. Name is derived from ``project_name``
1279 # so the bucket and the IAM allow-list assertion
1280 # (arn:aws:s3:::<project_name>-cluster-shared-*) stay in lockstep and
1281 # two deployments in the same account+region do not collide.
1282 # `bucket_key_enabled=True` mirrors the model_bucket pattern to reduce
1283 # per-object KMS request costs.
1284 project_name = self.config.get_project_name()
1285 self.cluster_shared_bucket = s3.Bucket(
1286 self,
1287 "ClusterSharedBucket",
1288 bucket_name=f"{cluster_shared_bucket_name_prefix(project_name)}-{self.account}-{self.region}",
1289 encryption=s3.BucketEncryption.KMS,
1290 encryption_key=self.cluster_shared_kms_key,
1291 bucket_key_enabled=True,
1292 block_public_access=s3.BlockPublicAccess.BLOCK_ALL,
1293 enforce_ssl=True,
1294 versioned=True,
1295 removal_policy=RemovalPolicy.DESTROY,
1296 auto_delete_objects=True,
1297 server_access_logs_bucket=self.cluster_shared_access_logs_bucket,
1298 server_access_logs_prefix="cluster-shared/",
1299 )
1301 # Explicit Deny for insecure transport. `enforce_ssl=True` already adds
1302 # an equivalent statement, but duplicating it here makes the deny
1303 # verifiable in the synthesized template under a known SID and satisfies
1304 # a belt-and-suspenders posture.
1305 self.cluster_shared_bucket.add_to_resource_policy(
1306 iam.PolicyStatement(
1307 sid="DenyInsecureTransport",
1308 effect=iam.Effect.DENY,
1309 principals=[iam.AnyPrincipal()],
1310 actions=["s3:*"],
1311 resources=[
1312 self.cluster_shared_bucket.bucket_arn,
1313 f"{self.cluster_shared_bucket.bucket_arn}/*",
1314 ],
1315 conditions={"Bool": {"aws:SecureTransport": "false"}},
1316 )
1317 )
1319 # CDK-nag suppressions — scoped per-resource at the construct site to
1320 # mirror the ``_create_model_bucket`` pattern (keeps the suppression
1321 # co-located with the construct it applies to, so the reason survives
1322 # refactors). Every suppression carries an explicit reason
1323 # string; no blanket ``Resource::*`` bypasses.
1324 from gco.stacks.nag_suppressions import acknowledge_nag_findings
1326 shared_replication_reason = (
1327 "Cluster_Shared_Bucket is a regional scratch sink; cluster jobs "
1328 "publish to it from a single region, and there is no durability "
1329 "requirement that warrants cross-region replication. Access logs "
1330 "do not require replication for the same reason."
1331 )
1333 acknowledge_nag_findings(
1334 self.cluster_shared_bucket,
1335 [
1336 {
1337 "id": "HIPAA.Security-S3BucketReplicationEnabled",
1338 "reason": shared_replication_reason,
1339 },
1340 {
1341 "id": "NIST.800.53.R5-S3BucketReplicationEnabled",
1342 "reason": shared_replication_reason,
1343 },
1344 {
1345 "id": "PCI.DSS.321-S3BucketReplicationEnabled",
1346 "reason": shared_replication_reason,
1347 },
1348 ],
1349 )
1351 access_logs_is_self_target_reason = (
1352 "This is the server access logs destination bucket for Cluster_Shared_Bucket."
1353 )
1354 acknowledge_nag_findings(
1355 self.cluster_shared_access_logs_bucket,
1356 [
1357 {
1358 "id": "AwsSolutions-S1",
1359 "reason": access_logs_is_self_target_reason,
1360 },
1361 {
1362 "id": "HIPAA.Security-S3BucketLoggingEnabled",
1363 "reason": access_logs_is_self_target_reason,
1364 },
1365 {
1366 "id": "NIST.800.53.R5-S3BucketLoggingEnabled",
1367 "reason": access_logs_is_self_target_reason,
1368 },
1369 {
1370 "id": "PCI.DSS.321-S3BucketLoggingEnabled",
1371 "reason": access_logs_is_self_target_reason,
1372 },
1373 {
1374 "id": "HIPAA.Security-S3BucketReplicationEnabled",
1375 "reason": shared_replication_reason,
1376 },
1377 {
1378 "id": "NIST.800.53.R5-S3BucketReplicationEnabled",
1379 "reason": shared_replication_reason,
1380 },
1381 {
1382 "id": "PCI.DSS.321-S3BucketReplicationEnabled",
1383 "reason": shared_replication_reason,
1384 },
1385 ],
1386 )
1388 def _publish_cluster_shared_bucket_ssm_params(self) -> None:
1389 """Publish the three ``/gco/cluster-shared-bucket/*`` SSM parameters.
1391 Writes:
1393 - ``/gco/cluster-shared-bucket/name`` — bucket name
1394 - ``/gco/cluster-shared-bucket/arn`` — bucket ARN
1395 - ``/gco/cluster-shared-bucket/region`` — bucket home region (global region)
1397 These parameters are the cross-region contract consumed by
1398 ``GCORegionalStack._resolve_cluster_shared_bucket_from_ssm`` (always) and by
1399 ``GCOAnalyticsStack._grant_sagemaker_role_on_cluster_shared_bucket``
1400 (conditional on the analytics toggle). The prefix from
1401 ``cluster_shared_ssm_parameter_prefix(project_name)`` is the single
1402 source of truth so the namespace can be renamed in exactly one place.
1404 Also emits four ``CfnOutput`` values for discoverability: the three SSM
1405 values plus the KMS key ARN. Export names follow the existing
1406 ``{project_name}-cluster-shared-{suffix}`` pattern used by the rest of
1407 this stack's outputs so operators can cross-reference them from peer
1408 stacks via ``Fn.import_value`` if needed (the primary cross-region
1409 contract remains SSM).
1410 """
1411 project_name = self.config.get_project_name()
1413 ssm.StringParameter(
1414 self,
1415 "ClusterSharedBucketNameParam",
1416 parameter_name=f"{cluster_shared_ssm_parameter_prefix(project_name)}/name",
1417 string_value=self.cluster_shared_bucket.bucket_name,
1418 description="Name of the always-on Cluster_Shared_Bucket (owned by GCOGlobalStack).",
1419 )
1421 ssm.StringParameter(
1422 self,
1423 "ClusterSharedBucketArnParam",
1424 parameter_name=f"{cluster_shared_ssm_parameter_prefix(project_name)}/arn",
1425 string_value=self.cluster_shared_bucket.bucket_arn,
1426 description="ARN of the always-on Cluster_Shared_Bucket (owned by GCOGlobalStack).",
1427 )
1429 ssm.StringParameter(
1430 self,
1431 "ClusterSharedBucketRegionParam",
1432 parameter_name=f"{cluster_shared_ssm_parameter_prefix(project_name)}/region",
1433 string_value=self.region,
1434 description="Home region of the always-on Cluster_Shared_Bucket (the global region).",
1435 )
1437 CfnOutput(
1438 self,
1439 "ClusterSharedBucketName",
1440 value=self.cluster_shared_bucket.bucket_name,
1441 description="Name of the always-on Cluster_Shared_Bucket.",
1442 export_name=f"{project_name}-cluster-shared-bucket-name",
1443 )
1445 CfnOutput(
1446 self,
1447 "ClusterSharedBucketArn",
1448 value=self.cluster_shared_bucket.bucket_arn,
1449 description="ARN of the always-on Cluster_Shared_Bucket.",
1450 export_name=f"{project_name}-cluster-shared-bucket-arn",
1451 )
1453 CfnOutput(
1454 self,
1455 "ClusterSharedBucketRegion",
1456 value=self.region,
1457 description="Home region of the always-on Cluster_Shared_Bucket.",
1458 export_name=f"{project_name}-cluster-shared-bucket-region",
1459 )
1461 CfnOutput(
1462 self,
1463 "ClusterSharedKmsKeyArn",
1464 value=self.cluster_shared_kms_key.key_arn,
1465 description="ARN of the always-on KMS key encrypting Cluster_Shared_Bucket.",
1466 export_name=f"{project_name}-cluster-shared-kms-key-arn",
1467 )
1469 def _resolve_replication_destinations(self, destinations: str | list[str]) -> list[str]:
1470 """Resolve the configured replication destinations into a region list.
1472 When ``destinations`` is the literal ``"all_deployed_regions"``, the
1473 list comes from ``self.config.get_regions()`` (the same source the
1474 rest of the stack uses for cross-region wiring). When it is an
1475 explicit list, it is returned as-is. The source region (the global
1476 stack's deploy region) is excluded — ECR replication is point-to-point
1477 and a self-referential destination is rejected by the API.
1478 """
1479 if isinstance(destinations, str): 1479 ↛ 1482line 1479 didn't jump to line 1482 because the condition on line 1479 was always true
1480 candidate_regions = list(self.config.get_regions())
1481 else:
1482 candidate_regions = list(destinations)
1483 return [region for region in candidate_regions if region != self.region]
1485 def _create_image_replication_rule(self) -> None:
1486 """Provision the ECR replication rule for ``gco/*`` repositories.
1488 When ``images.replication.enabled`` is True and at least one
1489 non-source destination resolves, creates one
1490 ``aws_ecr.CfnReplicationConfiguration`` rule with a single
1491 ``PREFIX_MATCH`` filter on ``gco/`` and one destination per resolved
1492 region. When replication is disabled or the destination list is
1493 empty (e.g. single-region deploy), no replication resource is
1494 provisioned and the method becomes a no-op.
1495 """
1496 if not self.images_config["replication"]["enabled"]: 1496 ↛ 1497line 1496 didn't jump to line 1497 because the condition on line 1496 was never true
1497 return
1499 destinations = self._resolve_replication_destinations(
1500 self.images_config["replication"]["destinations"]
1501 )
1502 if not destinations: 1502 ↛ 1503line 1502 didn't jump to line 1503 because the condition on line 1502 was never true
1503 return
1505 ecr.CfnReplicationConfiguration(
1506 self,
1507 "GcoImageReplicationConfig",
1508 replication_configuration=ecr.CfnReplicationConfiguration.ReplicationConfigurationProperty(
1509 rules=[
1510 ecr.CfnReplicationConfiguration.ReplicationRuleProperty(
1511 destinations=[
1512 ecr.CfnReplicationConfiguration.ReplicationDestinationProperty(
1513 region=region,
1514 registry_id=self.account,
1515 )
1516 for region in destinations
1517 ],
1518 repository_filters=[
1519 ecr.CfnReplicationConfiguration.RepositoryFilterProperty(
1520 # Replicate this deployment's own ECR namespace
1521 # (``<project_name>/*``) — ``gco/`` for the stock
1522 # project — so two deployments don't cross-replicate (#139).
1523 filter=f"{self.config.get_project_name()}/",
1524 filter_type="PREFIX_MATCH",
1525 )
1526 ],
1527 )
1528 ]
1529 ),
1530 )
1532 def _create_image_lookup_lambda(self) -> None:
1533 """Create the lookup-or-create custom resource Lambda for image repos.
1535 The Lambda implements the adopt-or-create pattern for ECR repos
1536 under the project's ``gco/*`` prefix. It is invoked at the time
1537 ``cli images init`` registers a new repo with the global stack via
1538 a ``CustomResource``; the function itself is provisioned here so
1539 the ARN is stable across deploys.
1541 The Lambda's IAM role grants read/write access to ECR repository
1542 APIs scoped to the project's prefix, plus the standard basic
1543 execution policy for CloudWatch Logs.
1544 """
1545 project_name = self.config.get_project_name()
1547 # IAM role for the Lambda — minimal ECR + CloudWatch Logs permissions.
1548 # ECR repository APIs scope by repository name, not ARN, so the
1549 # ``gco/*`` prefix scope is enforced via the ARN pattern in the
1550 # policy resource list.
1551 repo_arn = f"arn:{self.partition}:ecr:*:{self.account}:repository/{project_name}/*"
1553 self.image_lookup_lambda = lambda_.Function(
1554 self,
1555 "ImageLookupFunction",
1556 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
1557 handler="handler.lambda_handler",
1558 code=lambda_.Code.from_asset("lambda/image-lookup"),
1559 timeout=Duration.minutes(5),
1560 description=(
1561 "Lookup-or-create custom resource handler for ECR "
1562 "repositories under the project's gco/* prefix."
1563 ),
1564 )
1566 assert self.image_lookup_lambda.role is not None
1567 self.image_lookup_lambda.role.add_to_principal_policy(
1568 iam.PolicyStatement(
1569 effect=iam.Effect.ALLOW,
1570 actions=[
1571 "ecr:DescribeRepositories",
1572 "ecr:CreateRepository",
1573 "ecr:DeleteRepository",
1574 "ecr:PutLifecyclePolicy",
1575 "ecr:GetLifecyclePolicy",
1576 "ecr:TagResource",
1577 "ecr:ListTagsForResource",
1578 "ecr:BatchDeleteImage",
1579 "ecr:DescribeImages",
1580 "ecr:ListImages",
1581 ],
1582 resources=[repo_arn],
1583 )
1584 )
1586 CfnOutput(
1587 self,
1588 "ImageLookupFunctionArn",
1589 value=self.image_lookup_lambda.function_arn,
1590 description=(
1591 "Lambda ARN for the lookup-or-create custom resource that "
1592 "manages ECR repositories under the gco/* prefix."
1593 ),
1594 export_name=f"{project_name}-image-lookup-function-arn",
1595 )
1597 # The ECR repository policy uses a partition-aware
1598 # ``arn:<partition>:ecr:*:<account>:repository/gco/*`` resource
1599 # which cdk-nag flags as ``AwsSolutions-IAM5`` because of the trailing
1600 # ``*``. The wildcard here is the documented IAM way to express
1601 # "every repository in this project's prefix", which is exactly the
1602 # blast radius we want for a Lambda whose contract is to manage
1603 # ECR repos under that prefix. Suppression is scoped to the specific
1604 # ARN pattern (and to all ECR Describe/Read action wildcards in
1605 # the policy below) rather than a blanket ``Resource::*`` bypass.
1606 #
1607 # cdk-nag reports this finding's account as the ``<AWS::AccountId>``
1608 # placeholder for an environment-agnostic synth, but as the concrete
1609 # account id for an environment-specific one (the ARN above is
1610 # hand-built from ``self.account``, which becomes a literal once the
1611 # stack has a resolved account). We author the ``appliesTo`` with the
1612 # placeholder; ``acknowledge_nag_findings`` additionally registers the
1613 # concrete-account rendering when the account is resolved, so the
1614 # acknowledgment matches in both cases.
1615 from gco.stacks.nag_suppressions import acknowledge_nag_findings
1617 acknowledge_nag_findings(
1618 self.image_lookup_lambda.role,
1619 [
1620 {
1621 "id": "AwsSolutions-IAM5",
1622 "reason": (
1623 "The ImageLookupFunction's contract is to look up "
1624 "or create any ECR repository under the project's "
1625 "``gco/*`` prefix. The ARN pattern "
1626 "``arn:<partition>:ecr:*:<account>:repository/gco/*`` is "
1627 "the documented IAM way to express that scope: it "
1628 "covers exactly the repositories the function is "
1629 "allowed to touch and nothing else."
1630 ),
1631 "appliesTo": [
1632 f"Resource::arn:<AWS::Partition>:ecr:*:<AWS::AccountId>:"
1633 f"repository/{project_name}/*",
1634 ],
1635 },
1636 ],
1637 )