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

1""" 

2Global shared-resources stack with optional commercial-partition routing. 

3 

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. 

9 

10Regional ALB registration is performed separately by each regional stack only 

11when the accelerator topology exists. 

12""" 

13 

14from typing import Any 

15 

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 

34 

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) 

41 

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 

51 

52 

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" 

65 

66_IMAGES_VALID_REMOVAL_POLICIES = ("retain", "destroy") 

67 

68 

69def _parse_images_config(cdk_context: dict[str, Any] | None) -> dict[str, Any]: 

70 """Parse the ``images`` block from cdk.json with defaults applied. 

71 

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]``. 

77 

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. 

81 

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 {} 

88 

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 ) 

94 

95 empty_on_delete = bool(raw.get("empty_on_delete", _IMAGES_DEFAULT_EMPTY_ON_DELETE)) 

96 

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 ) 

104 

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 ) 

130 

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 } 

143 

144 

145class GCOGlobalStack(Stack): 

146 """Global shared resources with optional AWS Global Accelerator. 

147 

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. 

151 

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 """ 

160 

161 def __init__( 

162 self, scope: Construct, construct_id: str, config: ConfigLoader, **kwargs: Any 

163 ) -> None: 

164 super().__init__(scope, construct_id, **kwargs) 

165 

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 ) 

173 

174 ga_config = self.config.get_global_accelerator_config() 

175 

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 ) 

183 

184 # Create DynamoDB tables for templates and webhooks 

185 self._create_dynamodb_tables() 

186 

187 # Create S3 bucket for model weights 

188 self._create_model_bucket() 

189 

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() 

196 

197 # Create AWS Backup plan for DynamoDB tables 

198 self._create_backup_plan() 

199 

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() 

209 

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() 

215 

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() 

245 

246 # Apply cdk-nag suppressions 

247 self._apply_nag_suppressions() 

248 

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. 

252 

253 Global Accelerator supports two client-affinity modes: 

254 

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. 

260 

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) 

272 

273 def _create_capacity_poller(self) -> None: 

274 """Create the optional Historical Capacity Surface add-on. 

275 

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 

285 

286 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

287 

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"]) 

299 

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 ) 

322 

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 ) 

356 

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 ) 

380 

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 ) 

403 

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 ) 

425 

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 ) 

499 

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() 

505 

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 ) 

513 

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 ) 

521 

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 ) 

529 

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 

533 

534 apply_all_suppressions( 

535 self, stack_type="global", project_name=self.config.get_project_name() 

536 ) 

537 

538 def _create_endpoint_group(self, region: str) -> None: 

539 """ 

540 Create an endpoint group for a specific region. 

541 

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. 

546 

547 Also stores the endpoint group ARN in SSM Parameter Store for 

548 cross-region access by regional stacks. 

549 

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() 

560 

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 ) 

574 

575 self.endpoint_groups[region] = endpoint_group 

576 

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 ) 

585 

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 ) 

595 

596 def add_regional_endpoint(self, region: str, alb_arn: str) -> None: 

597 """Add a regional ALB endpoint to the Global Accelerator. 

598 

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. 

602 

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 

608 

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 

612 

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) 

618 

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) 

624 

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}") 

630 

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() 

634 

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 ) 

651 

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 ) 

668 

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 ) 

678 

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 ) 

697 

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 ) 

712 

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 ) 

730 

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 ) 

744 

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 ) 

758 

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 ) 

767 

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 ) 

775 

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 ) 

783 

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 ) 

791 

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 ) 

799 

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 ) 

807 

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 ) 

825 

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 ) 

833 

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 ) 

841 

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 ) 

860 

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 ) 

874 

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 ) 

882 

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 ) 

890 

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 ) 

899 

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 ) 

907 

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 ) 

915 

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 ) 

923 

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 ) 

931 

932 def _create_model_bucket(self) -> None: 

933 """Create S3 bucket for model weights. 

934 

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. 

938 

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() 

943 

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 ) 

952 

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)) 

958 

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 ) 

976 

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 ) 

992 

993 # CDK-nag suppressions — only replication (not needed for model weights) 

994 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

995 

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 ) 

1001 

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 ) 

1019 

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 ) 

1054 

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 ) 

1062 

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 ) 

1070 

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 ) 

1078 

1079 def _create_backup_plan(self) -> None: 

1080 """Create AWS Backup plan for DynamoDB tables. 

1081 

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 ) 

1093 

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 ) 

1123 

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 ) 

1135 

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 ) 

1145 

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 ) 

1153 

1154 def _create_cluster_shared_kms_key(self) -> None: 

1155 """Create the always-on customer-managed KMS key for ``Cluster_Shared_Bucket``. 

1156 

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. 

1168 

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 ) 

1188 

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 ] 

1200 

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 ) 

1210 

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 ) 

1220 

1221 def _create_cluster_shared_bucket(self) -> None: 

1222 """Create the always-on ``Cluster_Shared_Bucket`` and its access-logs bucket. 

1223 

1224 Two buckets are created: 

1225 

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. 

1237 

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). 

1241 

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)) 

1252 

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 ) 

1277 

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 ) 

1300 

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 ) 

1318 

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 

1325 

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 ) 

1332 

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 ) 

1350 

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 ) 

1387 

1388 def _publish_cluster_shared_bucket_ssm_params(self) -> None: 

1389 """Publish the three ``/gco/cluster-shared-bucket/*`` SSM parameters. 

1390 

1391 Writes: 

1392 

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) 

1396 

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. 

1403 

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() 

1412 

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 ) 

1420 

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 ) 

1428 

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 ) 

1436 

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 ) 

1444 

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 ) 

1452 

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 ) 

1460 

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 ) 

1468 

1469 def _resolve_replication_destinations(self, destinations: str | list[str]) -> list[str]: 

1470 """Resolve the configured replication destinations into a region list. 

1471 

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] 

1484 

1485 def _create_image_replication_rule(self) -> None: 

1486 """Provision the ECR replication rule for ``gco/*`` repositories. 

1487 

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 

1498 

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 

1504 

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 ) 

1531 

1532 def _create_image_lookup_lambda(self) -> None: 

1533 """Create the lookup-or-create custom resource Lambda for image repos. 

1534 

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. 

1540 

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() 

1546 

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}/*" 

1552 

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 ) 

1565 

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 ) 

1585 

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 ) 

1596 

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 

1616 

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 )