Coverage for gco / stacks / regional_stack.py: 100.00%

1052 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1""" 

2Regional stack for GCO (Global Capacity Orchestrator on AWS) - EKS cluster and ALB per region. 

3 

4This is the largest stack in the project (~3200 lines) and creates all regional 

5resources for a single AWS region. One instance is deployed per region defined 

6in cdk.json. 

7 

8Resources Created: 

9 VPC & Networking: 

10 - VPC spanning every AZ in the region, public subnets (NAT), private subnets (EKS and ALB) 

11 - 2 NAT Gateways for high availability 

12 - VPC endpoints for ECR, S3, STS, Secrets Manager, SSM, CloudWatch 

13 - VPC Flow Logs (CloudWatch Logs, 30-day retention) 

14 

15 EKS Cluster (Auto Mode): 

16 - Managed control plane with full logging (API, Audit, Authenticator, Controller Manager, Scheduler) 

17 - Built-in NodePools: system, general-purpose 

18 - Custom NodePools: gpu-x86-pool, gpu-arm-pool, gpu-inference-pool, 

19 gpu-efa-pool, mooncake-efa-pool, neuron-pool, cpu-general-pool 

20 - IRSA roles for service accounts (Secrets Manager, SQS, DynamoDB, CloudWatch, S3, EFS) 

21 

22 Load Balancing: 

23 - Internal ALB created from Gateway API resources by the self-managed 

24 AWS Load Balancer Controller 

25 - Always-deployed regional API bridge reaches the ALB through a VPC Lambda; 

26 direct caller access is optional in ``aws`` and required elsewhere 

27 - Global Accelerator endpoint registration in commercial ``aws`` only 

28 

29 Storage: 

30 - EFS with dynamic provisioning (CSI driver, access points, encryption at rest + in transit) 

31 - FSx for Lustre (optional, toggled via cdk.json) 

32 - Valkey Serverless cache (optional) 

33 - Aurora Serverless v2 with pgvector (optional) 

34 

35 Lambda Functions: 

36 - kubectl-applier: applies K8s manifests during deployment 

37 - helm-installer: installs Helm charts (KEDA, Volcano, KubeRay, etc.) 

38 - ga-registration: registers the ALB with Global Accelerator in ``aws`` 

39 - regional-api-proxy: separate-stack VPC proxy used by the always-on 

40 aggregation bridge and by optional direct callers in ``aws`` or the 

41 required regional workload ingress in other partitions 

42 

43 Container Images: 

44 - Docker image assets (CDK bootstrap asset repository, content-hash 

45 tags) for health-monitor, manifest-processor, inference-proxy, 

46 inference-monitor, queue-processor, cost-monitor 

47 

48 SQS: 

49 - Regional job queue + dead letter queue (for gco jobs submit-sqs) 

50 

51Key Design Decisions: 

52 - EKS Auto Mode handles node provisioning — no managed node groups or Karpenter provisioners 

53 - NodePools use WhenEmpty consolidation for inference to avoid disrupting long-running pods 

54 - IRSA (IAM Roles for Service Accounts) for least-privilege pod-level AWS access 

55 - All optional features (FSx, Valkey, Aurora) are toggled via cdk.json context variables 

56 - Template variables in K8s manifests ({{PLACEHOLDER}}) are replaced at deploy time 

57 

58Dependencies: 

59 - GCOGlobalStack (partition-wide state and, in ``aws``, Global Accelerator endpoint groups) 

60 - GCOApiGatewayGlobalStack (for auth secret ARN) 

61 

62Modification Guide: 

63 - To add a new NodePool: add a YAML manifest in lambda/kubectl-applier-simple/manifests/ (40-49 range) 

64 - To add a new service: add ECR image build here, Dockerfile in dockerfiles/, manifest in manifests/ 

65 - To add a new optional feature: add a cdk.json context toggle, guard with if/else in this file 

66 - To change EKS version: update KUBERNETES_VERSION in constants.py 

67""" 

68 

69from __future__ import annotations 

70 

71import ipaddress 

72import os 

73import re 

74from collections.abc import Mapping 

75from dataclasses import dataclass 

76from datetime import UTC, datetime 

77from pathlib import Path 

78from typing import Any, cast 

79 

80import aws_cdk.aws_eks_v2 as eks 

81import yaml 

82from aws_cdk import ( 

83 Acknowledgment, 

84 Annotations, 

85 CfnJson, 

86 CfnOutput, 

87 CfnTag, 

88 CustomResource, 

89 Duration, 

90 Fn, 

91 RemovalPolicy, 

92 Stack, 

93 Validations, 

94) 

95from aws_cdk import aws_ec2 as ec2 

96from aws_cdk import aws_ecr_assets as ecr_assets 

97from aws_cdk import aws_efs as efs 

98from aws_cdk import aws_eks as eks_l1 # L1 constructs (CfnPodIdentityAssociation) 

99from aws_cdk import aws_events as events 

100from aws_cdk import aws_events_targets as events_targets 

101from aws_cdk import aws_fsx as fsx 

102from aws_cdk import aws_iam as iam 

103from aws_cdk import aws_kms as kms 

104from aws_cdk import aws_lambda as lambda_ 

105from aws_cdk import aws_logs as logs 

106from aws_cdk import aws_s3 as s3 

107from aws_cdk import aws_secretsmanager as secretsmanager 

108from aws_cdk import aws_sns as sns 

109from aws_cdk import aws_sqs as sqs 

110from aws_cdk import aws_ssm as ssm 

111from aws_cdk import aws_stepfunctions as sfn 

112from aws_cdk import aws_stepfunctions_tasks as sfn_tasks 

113from aws_cdk import custom_resources as cr 

114from constructs import Construct 

115 

116from gco.config.config_loader import ConfigLoader 

117from gco.inference_proxy_config import ( 

118 compute_inference_proxy_tls_replacements as _compute_inference_proxy_tls_replacements, 

119) 

120from gco.manifest_security_policy import validate_manifest_security_policy 

121from gco.stacks.aws_load_balancer_controller_policy import ( 

122 aws_load_balancer_controller_policy_document, 

123) 

124from gco.stacks.constants import ( 

125 AURORA_POSTGRES_VERSION, 

126 DEFAULT_MANIFEST_RESOURCE_CAPS, 

127 DEFAULT_RESOURCE_QUOTA, 

128 EKS_ADDON_CLOUDWATCH_OBSERVABILITY, 

129 EKS_ADDON_EFS_CSI_DRIVER, 

130 EKS_ADDON_FSX_CSI_DRIVER, 

131 EKS_ADDON_METRICS_SERVER, 

132 EKS_ADDON_POD_IDENTITY_AGENT, 

133 EKS_UNSUPPORTED_AZ_IDS, 

134 LAMBDA_PYTHON_RUNTIME, 

135 MOONCAKE_MASTER_DEFAULT_IMAGE, 

136 api_gateway_auth_secret_name, 

137 backend_tls_certificate_arn_parameter_name, 

138 cluster_shared_ssm_parameter_prefix, 

139 cost_report_ssm_parameter_prefix, 

140 parse_k8s_quantity, 

141 regional_shared_ssm_parameter_prefix, 

142) 

143 

144# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

145# Generated at (UTC): 2026-09-12T06:04:03Z 

146# Generated from Git commit: e96e2c39c3626a5088651f43873dfade6a346850 

147# Flowchart(s) generated from this file: 

148# * ``GCORegionalStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/regional_stack.GCORegionalStack___init__.html`` 

149# (PNG: ``diagrams/code_diagrams/gco/stacks/regional_stack.GCORegionalStack___init__.png``) 

150# * ``GCORegionalStack._get_volcano_image_mirror_config`` -> ``diagrams/code_diagrams/gco/stacks/regional_stack.GCORegionalStack__get_volcano_image_mirror_config.html`` 

151# (PNG: ``diagrams/code_diagrams/gco/stacks/regional_stack.GCORegionalStack__get_volcano_image_mirror_config.png``) 

152# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

153# <pyflowchart-code-diagram> END 

154 

155 

156_LIVE_VALIDATION_PROVIDER_LOG_CONTEXT = "gco_live_validation_retain_provider_log_groups" 

157 

158 

159@dataclass(frozen=True) 

160class SharedBucketIdentity: 

161 """Identity of the always-on ``Cluster_Shared_Bucket`` owned by ``GCOGlobalStack``. 

162 

163 Every regional stack resolves this identity from the three SSM parameters 

164 ``/gco/cluster-shared-bucket/{name,arn,region}`` published by 

165 ``GCOGlobalStack`` in the global region. The three values are used to 

166 grant IAM permissions on the bucket to the regional job-pod role and to 

167 populate the ``gco-cluster-shared-bucket`` ConfigMap applied to every 

168 regional EKS cluster. Frozen so it can be safely shared across helper 

169 methods without accidental mutation. 

170 """ 

171 

172 name: str 

173 arn: str 

174 region: str 

175 

176 

177def _compute_kubectl_cluster_shared_replacements( 

178 shared: SharedBucketIdentity, 

179) -> dict[str, str]: 

180 """Build the ``{{CLUSTER_SHARED_BUCKET*}}`` kubectl-applier replacements. 

181 

182 Pure helper kept at module scope so property and presence tests can 

183 inspect the output without synthesizing a full regional stack. The 

184 three keys are always populated — there is no feature toggle — because 

185 the ``gco-cluster-shared-bucket`` ConfigMap is applied unconditionally 

186 on every regional cluster. 

187 """ 

188 return { 

189 "{{CLUSTER_SHARED_BUCKET}}": shared.name, 

190 "{{CLUSTER_SHARED_BUCKET_ARN}}": shared.arn, 

191 "{{CLUSTER_SHARED_BUCKET_REGION}}": shared.region, 

192 } 

193 

194 

195def _compute_kubectl_regional_shared_replacements( 

196 name: str, 

197 arn: str, 

198 region: str, 

199) -> dict[str, str]: 

200 """Build the ``{{REGIONAL_SHARED_BUCKET*}}`` kubectl-applier replacements. 

201 

202 Pure helper kept at module scope so property and presence tests can 

203 inspect the output without synthesizing a full regional stack, mirroring 

204 :func:`_compute_kubectl_cluster_shared_replacements`. 

205 

206 The three keys are always populated — there is no feature toggle — 

207 because ``_create_regional_shared_bucket`` provisions the bucket 

208 unconditionally, so the ``gco-regional-shared-bucket`` ConfigMap is 

209 applied on every regional cluster and is never gated out of the 

210 applier by an unresolved placeholder. 

211 

212 Unlike the cluster-shared helper this takes the three values directly 

213 rather than a :class:`SharedBucketIdentity`: the regional bucket is a 

214 local construct in this stack, so its name/ARN are CDK tokens resolved 

215 at deploy time instead of values read back from cross-region SSM. 

216 """ 

217 return { 

218 "{{REGIONAL_SHARED_BUCKET}}": name, 

219 "{{REGIONAL_SHARED_BUCKET_ARN}}": arn, 

220 "{{REGIONAL_SHARED_BUCKET_REGION}}": region, 

221 } 

222 

223 

224#: StorageClass name for in-cluster observability PVCs (Prometheus, Grafana, 

225#: Alertmanager). The value overrides reference this name, and the gated gp3 

226#: StorageClass manifest (25-storage-observability-gp3.yaml) declares it. A 

227#: synth test asserts the two stay in lockstep. The manifest keeps this name 

228#: static (a placeholder in ``metadata.name`` would fail k8s schema 

229#: validation), so the toggle gate lives in an annotation value instead. 

230_OBSERVABILITY_STORAGE_CLASS = "gco-observability-gp3" 

231 

232 

233#: In-cluster names clients use to reach the MLflow tracking server. MLflow 

234#: 3.x's host-validation middleware matches the raw Host header (port 

235#: included — that is why both spellings are listed), and setting 

236#: ``allowed-hosts`` REPLACES its built-in localhost/private-IP allowance 

237#: rather than extending it, so the value override must carry the complete 

238#: list (see ``_mlflow_allowed_hosts``). 

239_MLFLOW_SERVICE_HOSTS = ("mlflow.monitoring", "mlflow.monitoring:5000") 

240 

241#: Loopback spellings a browser sends through the access tunnel. 

242#: 

243#: ``gco monitoring open --service mlflow`` is the ONLY human path to this 

244#: server (ClusterIP, no Ingress), and it port-forwards to localhost — so the 

245#: browser sends ``Host: localhost:5000`` or ``Host: 127.0.0.1:5000``. Because 

246#: the flag replaces MLflow's built-in loopback allowance instead of extending 

247#: it, omitting these makes the documented UI path answer 403 "possible DNS 

248#: rebinding attack detected" while the server is perfectly healthy — the 

249#: in-cluster DNS spellings return 200 through the very same tunnel (caught 

250#: live 2026-08-15, taking the release screenshot). 

251#: 

252#: This restores upstream's own loopback posture rather than widening it: the 

253#: rebinding attack these checks exist for is an external DNS name resolving 

254#: to an internal address, which loopback literals cannot express. Reaching 

255#: the port at all still requires the authenticated SSM tunnel, and arbitrary 

256#: DNS names stay rejected. 

257_MLFLOW_TUNNEL_HOSTS = ("localhost", "localhost:5000", "127.0.0.1", "127.0.0.1:5000") 

258 

259 

260def _mlflow_allowed_hosts(vpc_endpoint_cidrs: list[str]) -> str: 

261 """Compose the MLflow ``allowed-hosts`` list from the deployment's CIDRs. 

262 

263 The service-DNS and loopback spellings are static; the IP tail derives 

264 from ``vpc_endpoint_cidrs`` (single source of truth — the same context key 

265 the NetworkPolicy egress rules render) so widening the VPC range never 

266 needs a matching charts.yaml edit. Every group is load-bearing: 

267 Prometheus scrapes the pod IP directly, so dropping the pod-IP allowance 

268 403s every ServiceMonitor scrape (caught live 2026-08-14); the tunnel 

269 forwards to loopback, so dropping those 403s the only human UI path 

270 (caught live 2026-08-15). 

271 

272 MLflow's allow-list is glob-based, so only octet-aligned prefixes convert 

273 exactly; other masks WIDEN to the containing octet boundary (capped at 

274 /24 granularity so the trailing ``.*`` still matches ``host:port`` 

275 Host headers). Widening is the safe direction — this is Host-header 

276 hygiene layered over NetworkPolicies and a private ALB, and 

277 under-matching is what breaks scrapes. 

278 """ 

279 patterns: list[str] = [] 

280 for cidr in vpc_endpoint_cidrs: 

281 network = ipaddress.ip_network(cidr, strict=False) 

282 if network.version != 4: 

283 raise ValueError( 

284 f"vpc_endpoint_cidrs entry {cidr!r} is not IPv4; the MLflow " 

285 "allowed-hosts derivation only understands IPv4 globs" 

286 ) 

287 octets = str(network.network_address).split(".") 

288 kept = min(max(network.prefixlen // 8, 1), 3) 

289 patterns.append(".".join(octets[:kept]) + ".*") 

290 return ",".join(dict.fromkeys([*_MLFLOW_SERVICE_HOSTS, *_MLFLOW_TUNNEL_HOSTS, *patterns])) 

291 

292 

293_SERVICE_IMAGE_BUILD_INPUTS = ( 

294 "dockerfiles/health-monitor-dockerfile", 

295 "dockerfiles/manifest-processor-dockerfile", 

296 "dockerfiles/inference-proxy-dockerfile", 

297 "dockerfiles/inference-monitor-dockerfile", 

298 "dockerfiles/queue-processor-dockerfile", 

299 "dockerfiles/cost-monitor-dockerfile", 

300) 

301_SERVICE_IMAGE_COMMON_EXCLUDES = ( 

302 "cli/**", 

303 "gco/stacks/**", 

304 "dockerfiles/README.md", 

305) 

306 

307 

308def _service_image_asset_excludes(*included_paths: str) -> list[str]: 

309 """Exclude inputs that cannot affect one production service image.""" 

310 included = set(included_paths) 

311 return list(_SERVICE_IMAGE_COMMON_EXCLUDES) + [ 

312 path for path in _SERVICE_IMAGE_BUILD_INPUTS if path not in included 

313 ] 

314 

315 

316#: cdk.json ``vpc_endpoints`` service keys → CDK endpoint services. The key 

317#: sets are pinned to ``ConfigLoader``'s validation lists by 

318#: ``tests/test_regional_stack.py`` so a name the loader accepts always maps. 

319_GATEWAY_ENDPOINT_SERVICES: dict[str, ec2.GatewayVpcEndpointAwsService] = { 

320 "s3": ec2.GatewayVpcEndpointAwsService.S3, 

321 "dynamodb": ec2.GatewayVpcEndpointAwsService.DYNAMODB, 

322} 

323_INTERFACE_ENDPOINT_SERVICES: dict[str, ec2.InterfaceVpcEndpointAwsService] = { 

324 "sts": ec2.InterfaceVpcEndpointAwsService.STS, 

325 "ecr.api": ec2.InterfaceVpcEndpointAwsService.ECR, 

326 "ecr.dkr": ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER, 

327 "logs": ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, 

328 "monitoring": ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_MONITORING, 

329 "sqs": ec2.InterfaceVpcEndpointAwsService.SQS, 

330 "ssm": ec2.InterfaceVpcEndpointAwsService.SSM, 

331 "secretsmanager": ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, 

332 "kms": ec2.InterfaceVpcEndpointAwsService.KMS, 

333 "eks": ec2.InterfaceVpcEndpointAwsService.EKS, 

334 "elasticfilesystem": ec2.InterfaceVpcEndpointAwsService.ELASTIC_FILESYSTEM, 

335 "bedrock-runtime": ec2.InterfaceVpcEndpointAwsService.BEDROCK_RUNTIME, 

336} 

337 

338 

339#: CDK context key that force-enables optional Helm charts for one deploy 

340#: without editing cdk.json (comma-separated cdk.json helm-block key names, 

341#: e.g. ``--context helm_enabled_overrides=yunikorn,slurm``). The live release 

342#: validation harness uses this to exercise off-by-default schedulers against 

343#: an otherwise pristine checkout; it is equally useful for trying one 

344#: scheduler ahead of a config change. Overrides can only ENABLE — a chart 

345#: disabled by an operator stays disabled unless named here. 

346_HELM_OVERRIDE_CONTEXT_KEY = "helm_enabled_overrides" 

347 

348#: Live-validation-only context that prevents AWS-managed EFS automatic backups 

349#: from outliving a disposable stack for the service's fixed retention window. 

350#: Normal deployments omit this key and preserve automatic backups. 

351_LIVE_VALIDATION_DISABLE_EFS_BACKUPS_CONTEXT = "gco_live_validation_disable_efs_automatic_backups" 

352 

353 

354def _explicit_context_bool(raw: object, *, key: str) -> bool: 

355 """Parse an optional CDK context boolean without truthy-string ambiguity.""" 

356 if raw is None: 

357 return False 

358 if isinstance(raw, bool): 

359 return raw 

360 if isinstance(raw, str): 

361 normalized = raw.strip().casefold() 

362 if normalized == "true": 

363 return True 

364 if normalized == "false": 

365 return False 

366 raise ValueError(f"{key} must be true or false") 

367 

368 

369#: Every cdk.json helm-block key _get_enabled_charts understands. Kept in 

370#: lockstep with its chart_map so an override typo fails the synth loudly 

371#: instead of silently deploying without the requested chart. 

372_HELM_CHART_CONFIG_KEYS = frozenset( 

373 { 

374 "aws_load_balancer_controller", 

375 "keda", 

376 "aws_efa_device_plugin", 

377 "aws_neuron_device_plugin", 

378 "volcano", 

379 "kuberay", 

380 "cert_manager", 

381 "slurm", 

382 "yunikorn", 

383 "kubeflow_trainer", 

384 "kueue", 

385 } 

386) 

387 

388#: Charts that are mandatory platform components; the cdk.json toggle is 

389#: ignored for these (see _get_enabled_charts for the rationale). 

390_MANDATORY_CHART_KEYS = frozenset({"aws_load_balancer_controller", "keda"}) 

391 

392 

393#: (container ceiling, namespace ceiling) pairs the resource-quota invariant 

394#: compares; every dimension a container can request must fit the namespace. 

395_RESOURCE_QUOTA_INVARIANTS = ( 

396 ("container_max_cpu", "max_cpu"), 

397 ("container_max_memory", "max_memory"), 

398 ("container_max_gpu", "max_gpu"), 

399) 

400 

401 

402def _validated_resource_quota(raw: object) -> dict[str, str]: 

403 """Merge the ``resource_quota`` context over defaults and validate it. 

404 

405 The values are substituted verbatim into the gco-jobs ResourceQuota and 

406 LimitRange manifests, where a typo or an incoherent pair (a per-container 

407 ceiling that exceeds the namespace ceiling) previously deployed silently 

408 and only surfaced as pods being forbidden at admission — with the reason 

409 visible in namespace events alone. Fail the synth instead. 

410 

411 Raises: 

412 ValueError: If the context is not a mapping, carries an unknown key, 

413 a value that does not parse as a Kubernetes quantity, or a 

414 per-container ceiling exceeding its namespace ceiling. 

415 """ 

416 if not isinstance(raw, dict): 

417 raise ValueError( 

418 f"cdk.json context 'resource_quota' must be an object, got {type(raw).__name__}" 

419 ) 

420 unknown = sorted(set(raw) - set(DEFAULT_RESOURCE_QUOTA)) 

421 if unknown: 

422 allowed = ", ".join(sorted(DEFAULT_RESOURCE_QUOTA)) 

423 raise ValueError( 

424 f"cdk.json context 'resource_quota' has unknown key(s) {unknown}; allowed: {allowed}" 

425 ) 

426 merged = {key: str(raw.get(key, default)) for key, default in DEFAULT_RESOURCE_QUOTA.items()} 

427 parsed: dict[str, float] = {} 

428 for key, value in merged.items(): 

429 try: 

430 parsed[key] = parse_k8s_quantity(value) 

431 except ValueError as exc: 

432 raise ValueError( 

433 f"resource_quota.{key}={value!r} is not a valid Kubernetes quantity" 

434 ) from exc 

435 if parsed[key] < 0: 

436 raise ValueError(f"resource_quota.{key}={value!r} must not be negative") 

437 for container_key, namespace_key in _RESOURCE_QUOTA_INVARIANTS: 

438 if parsed[container_key] > parsed[namespace_key]: 

439 raise ValueError( 

440 f"resource_quota.{container_key}={merged[container_key]!r} exceeds " 

441 f"resource_quota.{namespace_key}={merged[namespace_key]!r}: a container " 

442 "that passes the LimitRange could never be admitted by the namespace " 

443 "ResourceQuota" 

444 ) 

445 return merged 

446 

447 

448#: (per-manifest cap, container ceiling, namespace ceiling) triples for the 

449#: cross-layer invariant: container_max_* <= *_per_manifest <= max_*. 

450_MANIFEST_CAP_INVARIANTS = ( 

451 ("max_cpu_per_manifest", "container_max_cpu", "max_cpu"), 

452 ("max_memory_per_manifest", "container_max_memory", "max_memory"), 

453 ("max_gpu_per_manifest", "container_max_gpu", "max_gpu"), 

454) 

455 

456 

457def _validated_manifest_caps(raw: object, resource_quota: dict[str, str]) -> dict[str, str]: 

458 """Merge ``job_validation_policy.resource_quotas`` over defaults; validate. 

459 

460 Three layers govern job resources and must tell one story: the 

461 manifest/queue processors cap what a single submitted manifest may total 

462 (these values), the LimitRange caps each container, and the namespace 

463 ResourceQuota caps the aggregate. Enforce 

464 ``container_max_* <= *_per_manifest <= max_*`` at synth so the front door 

465 never rejects a manifest whose pods the namespace would admit and never 

466 accepts one that can never run — previously the defaults disagreed 

467 (per-manifest 4 GPUs vs the platform's own 16-GPU EFA training example). 

468 

469 Raises: 

470 ValueError: On unknown keys, unparseable quantities, or a violated 

471 layering invariant. 

472 """ 

473 if not isinstance(raw, dict): 

474 raise ValueError( 

475 "cdk.json context 'job_validation_policy.resource_quotas' must be an " 

476 f"object, got {type(raw).__name__}" 

477 ) 

478 unknown = sorted(set(raw) - set(DEFAULT_MANIFEST_RESOURCE_CAPS)) 

479 if unknown: 

480 allowed = ", ".join(sorted(DEFAULT_MANIFEST_RESOURCE_CAPS)) 

481 raise ValueError( 

482 "cdk.json context 'job_validation_policy.resource_quotas' has unknown " 

483 f"key(s) {unknown}; allowed: {allowed}" 

484 ) 

485 merged = { 

486 key: str(raw.get(key, default)) for key, default in DEFAULT_MANIFEST_RESOURCE_CAPS.items() 

487 } 

488 parsed: dict[str, float] = {} 

489 for key, value in merged.items(): 

490 try: 

491 parsed[key] = parse_k8s_quantity(value) 

492 except ValueError as exc: 

493 raise ValueError( 

494 f"job_validation_policy.resource_quotas.{key}={value!r} is not a " 

495 "valid Kubernetes quantity" 

496 ) from exc 

497 for manifest_key, container_key, namespace_key in _MANIFEST_CAP_INVARIANTS: 

498 container_value = parse_k8s_quantity(resource_quota[container_key]) 

499 namespace_value = parse_k8s_quantity(resource_quota[namespace_key]) 

500 if parsed[manifest_key] < container_value: 

501 raise ValueError( 

502 f"job_validation_policy.resource_quotas.{manifest_key}=" 

503 f"{merged[manifest_key]!r} is below resource_quota.{container_key}=" 

504 f"{resource_quota[container_key]!r}: the front door would reject a " 

505 "manifest whose single container the LimitRange admits" 

506 ) 

507 if parsed[manifest_key] > namespace_value: 

508 raise ValueError( 

509 f"job_validation_policy.resource_quotas.{manifest_key}=" 

510 f"{merged[manifest_key]!r} exceeds resource_quota.{namespace_key}=" 

511 f"{resource_quota[namespace_key]!r}: the front door would accept a " 

512 "manifest the namespace ResourceQuota can never admit" 

513 ) 

514 return merged 

515 

516 

517def _parse_helm_enabled_overrides(raw: object) -> frozenset[str]: 

518 """Parse and validate the ``helm_enabled_overrides`` context value. 

519 

520 Accepts a comma-separated string (the only shape the CDK CLI can pass 

521 with ``--context``) or a list of strings (cdk.json-style), returning the 

522 validated set of helm-block keys to force-enable. Unknown names raise at 

523 synth time with the valid list. 

524 """ 

525 if raw is None: 

526 return frozenset() 

527 if isinstance(raw, str): 

528 names = [part.strip() for part in raw.split(",") if part.strip()] 

529 elif isinstance(raw, list) and all(isinstance(part, str) for part in raw): 

530 names = [part.strip() for part in raw if part.strip()] 

531 else: 

532 raise ValueError( 

533 f"{_HELM_OVERRIDE_CONTEXT_KEY} must be a comma-separated string or string list" 

534 ) 

535 unknown = sorted(set(names) - _HELM_CHART_CONFIG_KEYS) 

536 if unknown: 

537 valid = ", ".join(sorted(_HELM_CHART_CONFIG_KEYS)) 

538 raise ValueError( 

539 f"Unknown {_HELM_OVERRIDE_CONTEXT_KEY} name(s): {', '.join(unknown)}. Valid: {valid}" 

540 ) 

541 return frozenset(names) 

542 

543 

544def _helm_chart_enabled( 

545 helm_config: Mapping[str, Any], 

546 overrides: frozenset[str], 

547 config_key: str, 

548) -> bool: 

549 """Resolve one helm-block key's effective enablement. 

550 

551 Single source of truth shared by _get_enabled_charts and the 

552 kubectl-applier gate replacements so the installed chart set and the 

553 gated manifests can never disagree: mandatory charts are always on, a 

554 context override forces on, and otherwise the cdk.json toggle decides 

555 (missing key defaults to enabled, matching the historical behavior). 

556 """ 

557 if config_key in _MANDATORY_CHART_KEYS or config_key in overrides: 

558 return True 

559 chart_config = helm_config.get(config_key, {}) 

560 return bool(chart_config.get("enabled", True)) if isinstance(chart_config, dict) else True 

561 

562 

563def _compute_kubectl_scheduler_replacements( 

564 *, kueue_enabled: bool, slurm_enabled: bool, kubeflow_trainer_enabled: bool = False 

565) -> dict[str, str]: 

566 """Build the kubectl-applier replacements that gate scheduler manifests. 

567 

568 When Kueue is enabled the ``{{KUEUE_ENABLED}}`` gate resolves so the 

569 default queue topology (post-helm-kueue-default-queues.yaml) applies; 

570 when Slurm is enabled the ``{{SLURM_ENABLED}}`` gate resolves so the 

571 Slinky NetworkPolicies (post-helm-slurm-network.yaml) apply; when the 

572 Kubeflow Trainer is enabled the ``{{KUBEFLOW_TRAINER_ENABLED}}`` gate 

573 resolves so the built-in ClusterTrainingRuntime blueprints 

574 (post-helm-kubeflow-trainer-runtimes.yaml) apply. A disabled 

575 scheduler leaves its placeholder unreplaced, the applier skips the file, 

576 and _FEATURE_RESOURCE_INVENTORY prunes previously applied objects — the 

577 same optional-feature gating observability, FSx, and Valkey use. 

578 """ 

579 replacements: dict[str, str] = {} 

580 if kueue_enabled: 

581 replacements["{{KUEUE_ENABLED}}"] = "true" 

582 if slurm_enabled: 

583 replacements["{{SLURM_ENABLED}}"] = "true" 

584 if kubeflow_trainer_enabled: 

585 replacements["{{KUBEFLOW_TRAINER_ENABLED}}"] = "true" 

586 return replacements 

587 

588 

589def _compute_kubectl_observability_replacements( 

590 enabled: bool, *, grafana_admin_password_rotation_schedule: str = "" 

591) -> dict[str, str]: 

592 """Build the kubectl-applier replacements that gate the observability manifests. 

593 

594 Pure helper kept at module scope so presence/absence can be asserted 

595 without synthesizing a full regional stack. When observability is enabled 

596 the ``{{CLUSTER_OBSERVABILITY_ENABLED}}`` gate resolves to ``"true"`` so the 

597 gp3 StorageClass, ServiceMonitors, dashboards, and credential-rotation 

598 CronJob render and apply, and ``{{GRAFANA_ADMIN_PASSWORD_ROTATION_SCHEDULE}}`` 

599 resolves to the configured cron. When disabled the dict is empty, so those 

600 manifests keep an unreplaced ``{{...}}`` token and the applier skips them — 

601 the same optional-feature gating FSx and Valkey already rely on. 

602 """ 

603 if not enabled: 

604 return {} 

605 return { 

606 "{{CLUSTER_OBSERVABILITY_ENABLED}}": "true", 

607 "{{GRAFANA_ADMIN_PASSWORD_ROTATION_SCHEDULE}}": grafana_admin_password_rotation_schedule, 

608 } 

609 

610 

611def _augment_trusted_registries_with_project_ecr( 

612 base: list[str], 

613 *, 

614 account: str, 

615 regions: list[str], 

616 global_region: str, 

617 url_suffix: str, 

618) -> list[str]: 

619 """Return the configured trusted registries plus the project's own ECR. 

620 

621 The new ``gco images build`` flow pushes images to a per-account ECR 

622 registry under ``<account>.dkr.ecr.<region>.<url-suffix>/gco/<name>``. 

623 Without this augmentation the queue/manifest validators would treat 

624 those URIs as untrusted and reject every job that uses one — which 

625 defeats the whole point of the image registry feature. 

626 

627 Returns the unique union of the operator-configured ``base`` list 

628 plus the per-region project ECR hostnames (one per deployed region, 

629 plus the global region where ``gco-global`` provisions the source 

630 repo). Order is stable so the rendered ConfigMap doesn't churn 

631 between deploys. 

632 """ 

633 augmented: list[str] = list(base) 

634 seen = set(augmented) 

635 targets = list(dict.fromkeys([global_region, *regions])) 

636 if account: 

637 for region in targets: 

638 host = f"{account}.dkr.ecr.{region}.{url_suffix}" 

639 if host not in seen: 

640 augmented.append(host) 

641 seen.add(host) 

642 return augmented 

643 

644 

645def _deployment_timestamp() -> str: 

646 """Return the synth-time token that deliberately retriggers convergence.""" 

647 return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") 

648 

649 

650def _load_helm_chart_order() -> list[str]: 

651 """Return helm chart names in their canonical install order. 

652 

653 Reads ``lambda/helm-installer/charts.yaml`` (the source of truth, in file 

654 order) so the Step Functions state machine has exactly one task per chart, 

655 in the same order every deploy — kueue stays last because its mutating 

656 webhook intercepts every Job/Deployment. Missing or malformed chart data 

657 aborts synthesis rather than silently omitting every Helm install/uninstall. 

658 """ 

659 charts_path = Path(__file__).resolve().parents[2] / "lambda" / "helm-installer" / "charts.yaml" 

660 try: 

661 with open(charts_path, encoding="utf-8") as f: 

662 data = yaml.safe_load(f) 

663 except (OSError, yaml.YAMLError) as exc: 

664 raise RuntimeError(f"Unable to load Helm chart order from {charts_path}: {exc}") from exc 

665 if not isinstance(data, dict): 

666 raise RuntimeError(f"Helm chart config {charts_path} must be an object") 

667 charts = data.get("charts") 

668 if not isinstance(charts, dict) or not charts: 

669 raise RuntimeError( 

670 f"Helm chart config {charts_path} must contain a non-empty charts object" 

671 ) 

672 if any(not isinstance(name, str) or not name for name in charts): 

673 raise RuntimeError(f"Helm chart config {charts_path} contains an invalid chart name") 

674 return list(charts) 

675 

676 

677class GCORegionalStack(Stack): 

678 """ 

679 Regional resources stack for a single AWS region. 

680 

681 Creates EKS cluster, load balancers, and supporting infrastructure 

682 for running GCO services in a specific region. 

683 

684 Attributes: 

685 vpc: VPC with public/private subnets 

686 cluster: EKS Auto Mode cluster 

687 """ 

688 

689 @staticmethod 

690 def _create_irsa_role( 

691 scope: GCORegionalStack, 

692 id: str, 

693 oidc_provider_arn: str, 

694 oidc_issuer_url: str, 

695 service_account_names: list[str], 

696 namespaces: list[str], 

697 *, 

698 include_pod_identity: bool = True, 

699 ) -> iam.Role: 

700 """Create an OIDC IRSA role, optionally trusted by EKS Pod Identity. 

701 

702 IRSA is the primary credential mechanism — it works reliably on EKS Auto 

703 Mode by projecting a service-account token that the AWS SDK exchanges for 

704 temporary credentials via the OIDC provider. General platform roles retain 

705 Pod Identity as a secondary path; controller roles can disable it to keep 

706 their trust policy bound to one exact Kubernetes service account. 

707 

708 Uses CfnJson to defer OIDC condition key resolution to deploy time, 

709 because the issuer URL is a CloudFormation token that can't be used 

710 as a Python dict key at synth time. 

711 """ 

712 # Strip https:// from issuer URL for the OIDC condition 

713 issuer = Fn.select(1, Fn.split("//", oidc_issuer_url)) 

714 

715 # Build OIDC conditions using CfnJson to defer token resolution 

716 # The issuer URL is a CFN token — can't be used as a dict key at synth time 

717 aud_key = Fn.join("", [issuer, ":aud"]) 

718 sub_key = Fn.join("", [issuer, ":sub"]) 

719 

720 conditions_json = CfnJson( 

721 scope, 

722 f"{id}OidcConditions", 

723 value={ 

724 aud_key: "sts.amazonaws.com", 

725 sub_key: [ 

726 f"system:serviceaccount:{ns}:{sa}" 

727 for ns in namespaces 

728 for sa in service_account_names 

729 ], 

730 }, 

731 ) 

732 

733 role = iam.Role( 

734 scope, 

735 id, 

736 assumed_by=iam.FederatedPrincipal( 

737 federated=oidc_provider_arn, 

738 conditions={ 

739 "StringEquals": conditions_json, 

740 }, 

741 assume_role_action="sts:AssumeRoleWithWebIdentity", 

742 ), 

743 ) 

744 

745 if include_pod_identity: 

746 # Secondary credential path for platform workloads. Dedicated 

747 # controllers such as LBC deliberately remain OIDC-only. 

748 assert role.assume_role_policy is not None 

749 role.assume_role_policy.add_statements( 

750 iam.PolicyStatement( 

751 effect=iam.Effect.ALLOW, 

752 principals=[iam.ServicePrincipal("pods.eks.amazonaws.com")], 

753 actions=["sts:AssumeRole", "sts:TagSession"], 

754 ) 

755 ) 

756 return role 

757 

758 def __init__( 

759 self, 

760 scope: Construct, 

761 construct_id: str, 

762 config: ConfigLoader, 

763 region: str, 

764 auth_secret_arn: str, 

765 **kwargs: Any, 

766 ) -> None: 

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

768 

769 self.config = config 

770 self.deployment_region = region 

771 self.auth_secret_arn = auth_secret_arn 

772 self.alb_arn: str | None = None 

773 self.disable_efs_automatic_backups = _explicit_context_bool( 

774 self.node.try_get_context(_LIVE_VALIDATION_DISABLE_EFS_BACKUPS_CONTEXT), 

775 key=_LIVE_VALIDATION_DISABLE_EFS_BACKUPS_CONTEXT, 

776 ) 

777 retain_provider_logs = self.node.try_get_context(_LIVE_VALIDATION_PROVIDER_LOG_CONTEXT) 

778 self.provider_log_group_removal_policy = ( 

779 RemovalPolicy.RETAIN 

780 if retain_provider_logs is True 

781 or ( 

782 isinstance(retain_provider_logs, str) 

783 and retain_provider_logs.strip().casefold() == "true" 

784 ) 

785 else RemovalPolicy.DESTROY 

786 ) 

787 supports_global_accelerator = getattr(config, "supports_global_accelerator", None) 

788 self.global_accelerator_enabled = ( 

789 bool(supports_global_accelerator()) if callable(supports_global_accelerator) else True 

790 ) 

791 

792 # Get cluster configuration for this region 

793 cluster_config = self.config.get_cluster_config(region) 

794 self.cluster_config = cluster_config 

795 

796 # Create VPC for the EKS cluster. 

797 # 

798 # ``max_azs=99`` is the CDK idiom for "span every Availability Zone the 

799 # region offers" — CDK caps the value at the number of AZs actually 

800 # returned for this account+region, so each AZ gets one public and one 

801 # private subnet. This only enumerates the *real* AZ list when the stack 

802 # is environment-specific (account + region both resolved); app.py sets 

803 # the account from CDK_DEFAULT_ACCOUNT for exactly this reason. In an 

804 # environment-agnostic synth (no account, e.g. some CI paths) CDK falls 

805 # back to a fixed placeholder AZ list rather than the full set. 

806 self.vpc = ec2.Vpc( 

807 self, 

808 "GCOVpc", 

809 # vpc_name intentionally omitted - let CDK generate unique name 

810 max_azs=99, # use every AZ in the region (each AZ gets 1 public + 1 private subnet) 

811 nat_gateways=2, # For high availability 

812 subnet_configuration=[ 

813 ec2.SubnetConfiguration( 

814 name="PublicSubnet", subnet_type=ec2.SubnetType.PUBLIC, cidr_mask=24 

815 ), 

816 ec2.SubnetConfiguration( 

817 name="PrivateSubnet", 

818 subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS, 

819 cidr_mask=24, 

820 ), 

821 ], 

822 ) 

823 

824 # Enable VPC Flow Logs for network traffic analysis and security monitoring 

825 self._create_vpc_flow_logs() 

826 

827 # Keep AWS API traffic inside the VPC where the operator asked for it 

828 self._create_vpc_endpoints() 

829 

830 # Create SQS queue for job ingestion 

831 self._create_sqs_queue() 

832 

833 # Build the platform service images as CDK Docker image assets 

834 self._create_container_images() 

835 

836 # Pre-create the execution role shared by every ``cr.AwsCustomResource`` 

837 # in this stack. See ``_create_aws_custom_resource_role`` for the full 

838 # rationale — in short, CDK's default behavior of auto-generating a 

839 # Lambda role per ``AwsCustomResource`` (and then merging all the 

840 # ``policy=`` statements onto it during deploy) triggers an IAM 

841 # propagation race on cold creates. We sidestep the race by creating 

842 # a single long-lived role up front and attaching policies to it as 

843 # each consumer is built; every ``AwsCustomResource`` then passes 

844 # ``role=self.aws_custom_resource_role`` instead of ``policy=``, so 

845 # the singleton Lambda runs against a role whose inline policy has 

846 # already replicated globally. 

847 self._create_aws_custom_resource_role() 

848 

849 # Resolve this region's fixed ACM certificate ARN from the global 

850 # backend-TLS registry before rendering the HTTPS-only Gateway. 

851 self.backend_tls_certificate_arn = self._resolve_backend_tls_certificate_arn() 

852 

853 # Create EKS cluster 

854 self._create_eks_cluster(cluster_config) 

855 

856 # Optional Volcano image mirror (cdk.json ``volcano_image_mirror``). 

857 # When enabled this resolves ``self.volcano_mirror_registry`` — the 

858 # gco/* ECR namespace that Volcano's ``basic.image_registry`` is 

859 # redirected to, so its docker.io-only images are pulled from the 

860 # project's own ECR (populated out-of-band by 

861 # ``gco images mirror``) instead of rate-limited Docker 

862 # Hub. Creates no CloudFormation resources; must run before 

863 # ``_apply_kubernetes_manifests`` builds the ``HelmInstallCharts`` custom 

864 # resource, which reads the override via ``_helm_chart_value_overrides()``. 

865 self._configure_volcano_image_mirror() 

866 

867 # Resolve the always-on Cluster_Shared_Bucket identity from SSM 

868 # (owned by GCOGlobalStack) and attach RW + KMS grants to the 

869 # job-pod role. Runs unconditionally — the ConfigMap and IAM 

870 # statements are always present on every regional cluster. Must 

871 # run after 

872 # _create_pod_identity_associations (which created service_account_role) 

873 # and before _apply_kubernetes_manifests (which consumes the 

874 # replacements in the KubectlApplyManifests CustomResource). 

875 self.cluster_shared_identity = self._resolve_cluster_shared_bucket_from_ssm() 

876 self._grant_cluster_shared_bucket_to_job_role(self.cluster_shared_identity) 

877 

878 # MLflow artifact storage: a dedicated OIDC-only IRSA role for the 

879 # tracking server's service account, scoped to the mlflow-artifacts/ 

880 # prefix of the same shared bucket. Created here (not with the other 

881 # IRSA roles) because it needs the resolved bucket identity above; 

882 # must precede _apply_kubernetes_manifests, whose value overrides 

883 # inject the role ARN into the chart's service-account annotation. 

884 if self._mlflow_active(): 

885 self._create_mlflow_artifact_role(self.cluster_shared_identity) 

886 

887 # Create the always-on general-purpose regional bucket (KMS key + 

888 # access-logs bucket + primary bucket). Provisioned unconditionally — 

889 # there is no cdk.json toggle and no feature flag gating its existence — 

890 # in addition to the central buckets owned by GCOGlobalStack. 

891 self._create_regional_shared_bucket() 

892 

893 # Create EFS for shared storage 

894 self._create_efs() 

895 

896 # Create FSx for Lustre (if enabled) for high-performance storage 

897 self._create_fsx_lustre() 

898 

899 # Create Valkey Serverless cache (if enabled) for K/V caching 

900 self._create_valkey_cache() 

901 

902 # Create Aurora Serverless v2 + pgvector (if enabled) for vector DB 

903 self._create_aurora_pgvector() 

904 

905 # Discover and publish the Gateway ALB in every partition. Global 

906 # Accelerator registration is an optional extension of this same exact 

907 # ownership path where the service is available. 

908 self._create_ga_registration_lambda() 

909 

910 # Provider framework Lambdas can emit their final delete-event log after 

911 # CloudFormation has otherwise finished the custom resource. Strict live 

912 # validation retains this explicit group through stack deletion so the 

913 # harness can remove the same checkpointed generation after every target 

914 # stack is absent. Ordinary deployments keep DESTROY semantics and do not 

915 # accumulate retained groups. 

916 self.helm_installer_provider_log_group = logs.LogGroup( 

917 self, 

918 "HelmInstallerProviderLogGroup", 

919 retention=logs.RetentionDays.ONE_WEEK, 

920 removal_policy=self.provider_log_group_removal_policy, 

921 ) 

922 

923 # Create Helm installer Lambda for KEDA and other Helm-based installations 

924 self._create_helm_installer_lambda() 

925 

926 # Apply Kubernetes manifests (after EFS so IDs are available) 

927 self._apply_kubernetes_manifests() 

928 

929 # Create CloudFormation drift detection (daily schedule + SNS alerts) 

930 self._create_drift_detection() 

931 

932 # Create dedicated IAM role for MCP server 

933 self._create_mcp_role() 

934 

935 # Export cluster information 

936 self._create_outputs() 

937 

938 # Apply cdk-nag suppressions for this stack 

939 self._apply_nag_suppressions() 

940 

941 def _create_vpc_flow_logs(self) -> None: 

942 """Create VPC Flow Logs for network traffic monitoring. 

943 

944 Flow logs capture information about IP traffic going to and from 

945 network interfaces in the VPC. This is required for security 

946 monitoring and compliance (HIPAA, SOC2, etc.). 

947 """ 

948 # Create CloudWatch Log Group for flow logs 

949 flow_log_group = logs.LogGroup( 

950 self, 

951 "VpcFlowLogGroup", 

952 # log_group_name intentionally omitted - let CDK generate unique name 

953 retention=logs.RetentionDays.ONE_MONTH, 

954 removal_policy=RemovalPolicy.DESTROY, 

955 ) 

956 

957 # Create IAM role for VPC Flow Logs 

958 flow_log_role = iam.Role( 

959 self, 

960 "VpcFlowLogRole", 

961 assumed_by=iam.ServicePrincipal("vpc-flow-logs.amazonaws.com"), 

962 ) 

963 

964 flow_log_role.add_to_policy( 

965 iam.PolicyStatement( 

966 actions=[ 

967 "logs:CreateLogStream", 

968 "logs:PutLogEvents", 

969 "logs:DescribeLogGroups", 

970 "logs:DescribeLogStreams", 

971 ], 

972 resources=[flow_log_group.log_group_arn, f"{flow_log_group.log_group_arn}:*"], 

973 ) 

974 ) 

975 

976 # Create VPC Flow Log 

977 ec2.FlowLog( 

978 self, 

979 "VpcFlowLog", 

980 resource_type=ec2.FlowLogResourceType.from_vpc(self.vpc), 

981 destination=ec2.FlowLogDestination.to_cloud_watch_logs(flow_log_group, flow_log_role), 

982 traffic_type=ec2.FlowLogTrafficType.ALL, 

983 ) 

984 

985 def _create_vpc_endpoints(self) -> None: 

986 """Create the VPC endpoints selected by ``cdk.json`` ``vpc_endpoints``. 

987 

988 Gateway endpoints (S3, DynamoDB) are free route-table entries: with 

989 them, the platform's largest data path — model and dataset pulls, 

990 checkpoints, MLflow artifacts, cost reports — stops flowing through the 

991 NAT gateways' per-GB metering and never leaves the VPC. Interface 

992 endpoints are PrivateLink ENIs billed per AZ-hour, so they are opt-in; 

993 CDK gives each one a security group admitting HTTPS from the VPC CIDR 

994 and private DNS, so callers keep using the public service hostnames. 

995 

996 NetworkPolicy egress is unaffected either way: S3 traffic still 

997 resolves to public S3 addresses that the route table steers into the 

998 endpoint, which is why 03-network-policies.yaml allows HTTPS by port 

999 rather than by destination. 

1000 """ 

1001 selection = self.config.get_vpc_endpoints_config() 

1002 self.vpc_gateway_endpoints: dict[str, ec2.GatewayVpcEndpoint] = {} 

1003 self.vpc_interface_endpoints: dict[str, ec2.InterfaceVpcEndpoint] = {} 

1004 for service in selection["gateway"]: 

1005 self.vpc_gateway_endpoints[service] = self.vpc.add_gateway_endpoint( 

1006 f"VpcEndpoint-{service}", 

1007 service=_GATEWAY_ENDPOINT_SERVICES[service], 

1008 ) 

1009 for service in selection["interface"]: 

1010 self.vpc_interface_endpoints[service] = self.vpc.add_interface_endpoint( 

1011 f"VpcEndpoint-{service.replace('.', '-')}", 

1012 service=_INTERFACE_ENDPOINT_SERVICES[service], 

1013 subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS), 

1014 ) 

1015 

1016 def _apply_nag_suppressions(self) -> None: 

1017 """Apply cdk-nag suppressions for this stack.""" 

1018 from gco.stacks.nag_suppressions import apply_all_suppressions 

1019 

1020 apply_all_suppressions( 

1021 self, 

1022 stack_type="regional", 

1023 regions=self.config.get_regions(), 

1024 global_region=self.config.get_global_region(), 

1025 api_gateway_region=self.config.get_api_gateway_region(), 

1026 project_name=self.config.get_project_name(), 

1027 ) 

1028 

1029 def _create_sqs_queue(self) -> None: 

1030 """Create SQS queue for job ingestion. 

1031 

1032 Creates an SQS queue that serves as the default job ingestion point 

1033 for this region. Jobs submitted to this queue are processed by the 

1034 manifest processor and KEDA scales based on queue depth. 

1035 

1036 Also creates a dead-letter queue for failed messages. 

1037 Both queues use server-side encryption with AWS managed keys. 

1038 """ 

1039 project_name = self.config.get_project_name() 

1040 

1041 # Create dead-letter queue for failed messages 

1042 self.job_dlq = sqs.Queue( 

1043 self, 

1044 "JobDeadLetterQueue", 

1045 queue_name=f"{project_name}-jobs-dlq-{self.deployment_region}", 

1046 retention_period=Duration.days(14), 

1047 removal_policy=RemovalPolicy.DESTROY, 

1048 enforce_ssl=True, # Require SSL for all requests 

1049 encryption=sqs.QueueEncryption.SQS_MANAGED, # Server-side encryption 

1050 ) 

1051 

1052 # Create main job queue 

1053 self.job_queue = sqs.Queue( 

1054 self, 

1055 "JobQueue", 

1056 queue_name=f"{project_name}-jobs-{self.deployment_region}", 

1057 visibility_timeout=Duration.minutes(5), # Match Lambda timeout 

1058 retention_period=Duration.days(7), 

1059 dead_letter_queue=sqs.DeadLetterQueue( 

1060 max_receive_count=3, # Move to DLQ after 3 failed attempts 

1061 queue=self.job_dlq, 

1062 ), 

1063 removal_policy=RemovalPolicy.DESTROY, 

1064 enforce_ssl=True, # Require SSL for all requests 

1065 encryption=sqs.QueueEncryption.SQS_MANAGED, # Server-side encryption 

1066 ) 

1067 

1068 # Output queue information 

1069 CfnOutput( 

1070 self, 

1071 "JobQueueUrl", 

1072 value=self.job_queue.queue_url, 

1073 description=f"SQS Job Queue URL for {self.deployment_region}", 

1074 export_name=f"{project_name}-job-queue-url-{self.deployment_region}", 

1075 ) 

1076 

1077 CfnOutput( 

1078 self, 

1079 "JobQueueArn", 

1080 value=self.job_queue.queue_arn, 

1081 description=f"SQS Job Queue ARN for {self.deployment_region}", 

1082 export_name=f"{project_name}-job-queue-arn-{self.deployment_region}", 

1083 ) 

1084 

1085 CfnOutput( 

1086 self, 

1087 "JobDlqUrl", 

1088 value=self.job_dlq.queue_url, 

1089 description=f"SQS Dead Letter Queue URL for {self.deployment_region}", 

1090 export_name=f"{project_name}-job-dlq-url-{self.deployment_region}", 

1091 ) 

1092 

1093 def _create_aws_custom_resource_role(self) -> None: 

1094 """Pre-create the execution role shared by every ``AwsCustomResource``. 

1095 

1096 CDK's ``cr.AwsCustomResource`` defaults to auto-generating a per- 

1097 construct Lambda execution role from the ``policy=`` parameter. 

1098 Internally, CDK deduplicates those auto-generated roles onto a 

1099 single *singleton* provider Lambda (logical id prefix 

1100 ``AWS679f53fac002430cb0da5b7982bd22872``), and merges each custom 

1101 resource's policy statements onto that Lambda's role at stack 

1102 create time. On cold deploys, CloudFormation invokes the Lambda 

1103 within 2-3 seconds of attaching a new policy statement, which is 

1104 faster than IAM's global propagation window. The symptom is a 

1105 ``iam:PassRole NOT authorized`` failure on whichever addon role 

1106 update happens to run right after its ``iam:PassRole`` policy 

1107 statement was attached but before it had replicated. 

1108 

1109 The fix is to create the role up front, attach every policy 

1110 statement the stack will need during stack creation, and pass 

1111 ``role=self.aws_custom_resource_role`` to every 

1112 ``AwsCustomResource`` instead of ``policy=``. Because the role 

1113 already exists — and its inline policy has had minutes to 

1114 replicate by the time any ``AwsCustomResource`` actually fires — 

1115 the race disappears entirely. 

1116 

1117 This method creates the role with the statements we can compute 

1118 without a cluster reference (EKS ``UpdateAddon`` / ``DescribeAddon`` 

1119 scoped to this cluster, and SSM ``GetParameter`` for the endpoint 

1120 group ARN). ``iam:PassRole`` statements for individual addon 

1121 roles (EFS CSI, FSx CSI, CloudWatch Observability) are appended 

1122 by each ``_create_*_addon`` method after the corresponding IRSA 

1123 role has been created, so every PassRole ``resources=`` list 

1124 stays precise (no wildcards) and cdk-nag stays happy. 

1125 """ 

1126 project_name = self.config.get_project_name() 

1127 global_region = self.config.get_global_region() 

1128 

1129 self.aws_custom_resource_role = iam.Role( 

1130 self, 

1131 "AwsCustomResourceRole", 

1132 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 

1133 description=( 

1134 "Shared execution role for every cr.AwsCustomResource in this " 

1135 "stack. Pre-created to avoid the IAM policy propagation race " 

1136 "that occurs when CDK auto-generates per-CR roles and the " 

1137 "singleton provider Lambda fires before the freshly-attached " 

1138 "policy has replicated globally." 

1139 ), 

1140 managed_policies=[ 

1141 iam.ManagedPolicy.from_aws_managed_policy_name( 

1142 "service-role/AWSLambdaBasicExecutionRole" 

1143 ), 

1144 ], 

1145 ) 

1146 

1147 # EKS UpdateAddon / DescribeAddon — used by the three updateAddon 

1148 # custom resources (EFS CSI, FSx CSI, CloudWatch Observability). 

1149 # Scoped to this cluster's addons by ARN. 

1150 self.aws_custom_resource_role.add_to_policy( 

1151 iam.PolicyStatement( 

1152 effect=iam.Effect.ALLOW, 

1153 actions=["eks:UpdateAddon", "eks:DescribeAddon"], 

1154 resources=[ 

1155 f"arn:{self.partition}:eks:{self.deployment_region}:{self.account}" 

1156 f":addon/{self.cluster_config.cluster_name}/*" 

1157 ], 

1158 ) 

1159 ) 

1160 

1161 # SSM GetParameter — used by the GetEndpointGroupArn custom 

1162 # resource in _create_ga_registration_lambda to read the ARN of 

1163 # the Global Accelerator endpoint group published by the global 

1164 # stack during its deploy. 

1165 self.aws_custom_resource_role.add_to_policy( 

1166 iam.PolicyStatement( 

1167 effect=iam.Effect.ALLOW, 

1168 actions=["ssm:GetParameter"], 

1169 resources=[ 

1170 f"arn:{self.partition}:ssm:{global_region}:{self.account}:" 

1171 f"parameter/{project_name}/*" 

1172 ], 

1173 ) 

1174 ) 

1175 

1176 # cdk-nag suppressions: the two wildcard-bearing ARNs above are 

1177 # intentional and both scoped as tightly as AWS IAM permits. 

1178 # 

1179 # - The ``eks:UpdateAddon`` / ``eks:DescribeAddon`` statement uses 

1180 # ``addon/<cluster>/*`` as its resource because the same shared 

1181 # role is consumed by three different updateAddon custom 

1182 # resources (EFS CSI, FSx CSI, CloudWatch Observability). Each 

1183 # addon has its own ARN and we'd otherwise need three separate 

1184 # statements that each grant access to a known addon name. The 

1185 # wildcard is scoped to a single cluster in a single region in 

1186 # a single account — it cannot be used against any addon 

1187 # belonging to a different cluster or a different service. 

1188 # 

1189 # - The ``ssm:GetParameter`` statement uses 

1190 # ``parameter/<project>/*`` because the exact parameter name 

1191 # (``endpoint-group-<region>-arn``) is only known at Global 

1192 # Accelerator registration time and the endpoint path 

1193 # structure is ``<project>/<parameter>``. Scoping to the 

1194 # project prefix restricts access to parameters owned by this 

1195 # project only. 

1196 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

1197 

1198 acknowledge_nag_findings( 

1199 self.aws_custom_resource_role, 

1200 [ 

1201 { 

1202 "id": "AwsSolutions-IAM5", 

1203 "reason": ( 

1204 "Scoped to a single EKS cluster's addons " 

1205 "(addon/<cluster>/*) and this project's SSM " 

1206 "parameters (parameter/<project>/*). Both wildcards " 

1207 "are as tight as AWS IAM permits: addon names and " 

1208 "parameter names are not known at stack synthesis " 

1209 "time because the addons are created later in the " 

1210 "same stack and the GA endpoint group ARN is " 

1211 "published by a separate stack during deploy. The " 

1212 "shared role pattern itself is deliberate — see " 

1213 "_create_aws_custom_resource_role docstring for why " 

1214 "we pre-create instead of letting CDK auto-generate " 

1215 "per-CR roles." 

1216 ), 

1217 "appliesTo": [ 

1218 f"Resource::arn:<AWS::Partition>:eks:{self.deployment_region}" 

1219 f":<AWS::AccountId>:addon/{self.cluster_config.cluster_name}/*", 

1220 f"Resource::arn:<AWS::Partition>:ssm:{global_region}" 

1221 f":<AWS::AccountId>:parameter/{project_name}/*", 

1222 ], 

1223 }, 

1224 ], 

1225 ) 

1226 

1227 def _resolve_backend_tls_certificate_arn(self) -> str: 

1228 """Read this region's stable imported ACM ARN from global-region SSM. 

1229 

1230 The certificate manager publishes one fixed ARN per workload region. 

1231 The regional Ingress consumes the token directly, which creates a 

1232 CloudFormation dependency ensuring the certificate exists before the 

1233 HTTPS listener is reconciled. The shared custom-resource role is 

1234 already restricted to this project's SSM namespace. 

1235 """ 

1236 project_name = self.config.get_project_name() 

1237 parameter_name = backend_tls_certificate_arn_parameter_name( 

1238 project_name, self.deployment_region 

1239 ) 

1240 reader = cr.AwsCustomResource( 

1241 self, 

1242 "GetBackendTlsCertificateArn", 

1243 on_create=cr.AwsSdkCall( 

1244 service="SSM", 

1245 action="getParameter", 

1246 parameters={"Name": parameter_name}, 

1247 region=self.config.get_global_region(), 

1248 physical_resource_id=cr.PhysicalResourceId.of( 

1249 f"{project_name}-backend-tls-certificate-{self.deployment_region}" 

1250 ), 

1251 ), 

1252 on_update=cr.AwsSdkCall( 

1253 service="SSM", 

1254 action="getParameter", 

1255 parameters={"Name": parameter_name}, 

1256 region=self.config.get_global_region(), 

1257 ), 

1258 role=self.aws_custom_resource_role, 

1259 ) 

1260 reader.node.add_dependency(self.aws_custom_resource_role) 

1261 return str(reader.get_response_field("Parameter.Value")) 

1262 

1263 def _create_container_images(self) -> None: 

1264 """Build the platform service images as CDK Docker image assets. 

1265 

1266 Every image is a ``DockerImageAsset``: CDK builds it at synth, pushes 

1267 it to the bootstrap asset repository under a content-hash tag, and the 

1268 manifests receive that URI through ``{{*_IMAGE}}`` replacements. The 

1269 stack creates no ECR repositories of its own — three empty 

1270 per-service repositories used to be declared here and were never 

1271 pushed to or referenced; they only added resources to create, scan, 

1272 and delete on every deploy. 

1273 """ 

1274 

1275 # All Docker images target AMD64 (x86_64) to match EKS Auto Mode's 

1276 # default system nodepool. 

1277 

1278 # Build and push health monitor Docker image 

1279 self.health_monitor_image = ecr_assets.DockerImageAsset( 

1280 self, 

1281 "HealthMonitorImage", 

1282 directory=".", # Root directory 

1283 file="dockerfiles/health-monitor-dockerfile", 

1284 platform=ecr_assets.Platform.LINUX_AMD64, 

1285 exclude=_service_image_asset_excludes( 

1286 "dockerfiles/health-monitor-dockerfile", 

1287 ), 

1288 ) 

1289 

1290 # Build and push manifest processor Docker image 

1291 self.manifest_processor_image = ecr_assets.DockerImageAsset( 

1292 self, 

1293 "ManifestProcessorImage", 

1294 directory=".", 

1295 file="dockerfiles/manifest-processor-dockerfile", 

1296 platform=ecr_assets.Platform.LINUX_AMD64, 

1297 exclude=_service_image_asset_excludes( 

1298 "dockerfiles/manifest-processor-dockerfile", 

1299 ), 

1300 ) 

1301 

1302 # Build the inference-only data-plane proxy image. Keeping this 

1303 # separate from manifest-processor prevents model traffic from 

1304 # sharing its Kubernetes API/RBAC and queue-worker process surface. 

1305 self.inference_proxy_image = ecr_assets.DockerImageAsset( 

1306 self, 

1307 "InferenceProxyImage", 

1308 directory=".", 

1309 file="dockerfiles/inference-proxy-dockerfile", 

1310 platform=ecr_assets.Platform.LINUX_AMD64, 

1311 exclude=_service_image_asset_excludes( 

1312 "dockerfiles/inference-proxy-dockerfile", 

1313 ), 

1314 ) 

1315 

1316 # Output image URIs for reference 

1317 CfnOutput( 

1318 self, 

1319 "HealthMonitorImageUri", 

1320 value=self.health_monitor_image.image_uri, 

1321 description="Health Monitor Docker image URI", 

1322 ) 

1323 

1324 CfnOutput( 

1325 self, 

1326 "ManifestProcessorImageUri", 

1327 value=self.manifest_processor_image.image_uri, 

1328 description="Manifest Processor Docker image URI", 

1329 ) 

1330 

1331 CfnOutput( 

1332 self, 

1333 "InferenceProxyImageUri", 

1334 value=self.inference_proxy_image.image_uri, 

1335 description="Inference Proxy Docker image URI", 

1336 ) 

1337 

1338 # Build and push inference monitor Docker image 

1339 self.inference_monitor_image = ecr_assets.DockerImageAsset( 

1340 self, 

1341 "InferenceMonitorImage", 

1342 directory=".", 

1343 file="dockerfiles/inference-monitor-dockerfile", 

1344 platform=ecr_assets.Platform.LINUX_AMD64, 

1345 exclude=_service_image_asset_excludes( 

1346 "dockerfiles/inference-monitor-dockerfile", 

1347 ), 

1348 ) 

1349 

1350 CfnOutput( 

1351 self, 

1352 "InferenceMonitorImageUri", 

1353 value=self.inference_monitor_image.image_uri, 

1354 description="Inference Monitor Docker image URI", 

1355 ) 

1356 

1357 # Build and push queue processor Docker image (if enabled). 

1358 # The queue processor is a KEDA ScaledJob that consumes manifests from 

1359 # the regional SQS queue. It can be disabled in cdk.json if users want 

1360 # to implement their own consumer. When disabled, the post-helm-sqs-consumer.yaml 

1361 # manifest is skipped (unreplaced template variables cause it to be skipped). 

1362 queue_processor_config = self.node.try_get_context("queue_processor") or {} 

1363 self.queue_processor_enabled = queue_processor_config.get("enabled", True) 

1364 

1365 if self.queue_processor_enabled: 

1366 self.queue_processor_image = ecr_assets.DockerImageAsset( 

1367 self, 

1368 "QueueProcessorImage", 

1369 directory=".", 

1370 file="dockerfiles/queue-processor-dockerfile", 

1371 platform=ecr_assets.Platform.LINUX_AMD64, 

1372 exclude=_service_image_asset_excludes( 

1373 "dockerfiles/queue-processor-dockerfile", 

1374 ), 

1375 ) 

1376 

1377 CfnOutput( 

1378 self, 

1379 "QueueProcessorImageUri", 

1380 value=self.queue_processor_image.image_uri, 

1381 description="Queue Processor Docker image URI", 

1382 ) 

1383 

1384 # Build and push the cost-monitor image only when the cost monitoring 

1385 # pipeline deploys to this region — skipping the build keeps opted-out 

1386 # deployments' synth/deploy time unchanged (same gating rationale as 

1387 # the queue processor above). 

1388 if self._cost_monitoring_active(): 

1389 self.cost_monitor_image = ecr_assets.DockerImageAsset( 

1390 self, 

1391 "CostMonitorImage", 

1392 directory=".", 

1393 file="dockerfiles/cost-monitor-dockerfile", 

1394 platform=ecr_assets.Platform.LINUX_AMD64, 

1395 exclude=_service_image_asset_excludes( 

1396 "dockerfiles/cost-monitor-dockerfile", 

1397 ), 

1398 ) 

1399 

1400 CfnOutput( 

1401 self, 

1402 "CostMonitorImageUri", 

1403 value=self.cost_monitor_image.image_uri, 

1404 description="Cost Monitor Docker image URI", 

1405 ) 

1406 

1407 def _resolve_unsupported_az_names(self) -> list[str]: 

1408 """Resolve this region's EKS-unsupported AZ *IDs* to this account's AZ *names*. 

1409 

1410 EKS rejects cluster subnets in a small set of Availability Zones, 

1411 published by AZ ID (``EKS_UNSUPPORTED_AZ_IDS``). AZ *names* are 

1412 randomized per account, so the disallowed ``use1-az3`` may be 

1413 ``us-east-1e`` in one account and a different name in another — we must 

1414 map ID -> name for the deploy account. 

1415 

1416 Returns an empty list when the region has no restriction (the common 

1417 case) or when the deploy account is not resolved. A credentialed, 

1418 environment-specific synth fails closed if EC2 cannot resolve every 

1419 restricted AZ ID; selecting all private subnets in that case could hand 

1420 EKS a known-unsupported control-plane subnet. 

1421 """ 

1422 unsupported_ids = EKS_UNSUPPORTED_AZ_IDS.get(self.deployment_region, ()) 

1423 if not unsupported_ids: 

1424 return [] 

1425 # Only reach EC2 during a credentialed, environment-specific synth or 

1426 # deploy. The CDK CLI exports CDK_DEFAULT_ACCOUNT from the active 

1427 # identity; unit tests and agnostic synth don't, so we never call AWS 

1428 # (nor block synthesis on missing credentials) there. 

1429 if not os.environ.get("CDK_DEFAULT_ACCOUNT"): 

1430 return [] 

1431 try: 

1432 import boto3 

1433 from botocore.config import Config 

1434 

1435 ec2_client = boto3.client( 

1436 "ec2", 

1437 region_name=self.deployment_region, 

1438 config=Config(connect_timeout=5, read_timeout=5, retries={"max_attempts": 2}), 

1439 ) 

1440 response = ec2_client.describe_availability_zones( 

1441 Filters=[{"Name": "zone-id", "Values": list(unsupported_ids)}] 

1442 ) 

1443 except Exception as exc: 

1444 raise RuntimeError( 

1445 f"Unable to resolve EKS-unsupported Availability Zones in " 

1446 f"{self.deployment_region}: {exc}" 

1447 ) from exc 

1448 

1449 zones = response.get("AvailabilityZones") 

1450 if not isinstance(zones, list): 

1451 raise RuntimeError( 

1452 f"EC2 returned malformed Availability Zone data for {self.deployment_region}" 

1453 ) 

1454 names_by_id = { 

1455 zone_id: zone_name 

1456 for zone in zones 

1457 if isinstance(zone, dict) 

1458 and isinstance((zone_id := zone.get("ZoneId")), str) 

1459 and isinstance((zone_name := zone.get("ZoneName")), str) 

1460 and zone_id in unsupported_ids 

1461 and zone_name 

1462 } 

1463 missing_ids = [zone_id for zone_id in unsupported_ids if zone_id not in names_by_id] 

1464 if missing_ids: 

1465 raise RuntimeError( 

1466 f"EC2 did not resolve EKS-unsupported Availability Zone IDs in " 

1467 f"{self.deployment_region}: {', '.join(missing_ids)}" 

1468 ) 

1469 return [names_by_id[zone_id] for zone_id in unsupported_ids] 

1470 

1471 def _eks_control_plane_subnets(self) -> ec2.SubnetSelection: 

1472 """Private-subnet selection for the EKS control plane, excluding any AZ 

1473 EKS does not support for cluster subnets. 

1474 

1475 Records the outcome on ``self`` (``eks_unsupported_az_names`` and 

1476 ``eks_control_plane_subnets``) so tests and operators can introspect 

1477 exactly which subnets the cluster was given. 

1478 """ 

1479 unsupported = set(self._resolve_unsupported_az_names()) 

1480 usable = [ 

1481 subnet 

1482 for subnet in self.vpc.private_subnets 

1483 if subnet.availability_zone not in unsupported 

1484 ] 

1485 self.eks_unsupported_az_names = sorted(unsupported) 

1486 self.eks_control_plane_subnets = usable 

1487 if not unsupported: 

1488 # No restricted AZ in this region: keep the subnet-type selection so 

1489 # the synthesized template is identical to before for the common case. 

1490 return ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS) 

1491 return ec2.SubnetSelection(subnets=usable) 

1492 

1493 def _create_eks_cluster(self, cluster_config: Any) -> None: 

1494 """Create the EKS cluster with auto mode and GPU node groups""" 

1495 

1496 # Create cluster admin role 

1497 # role_name intentionally omitted - let CDK generate unique name 

1498 cluster_admin_role = iam.Role( 

1499 self, 

1500 "ClusterAdminRole", 

1501 assumed_by=iam.ServicePrincipal("eks.amazonaws.com"), 

1502 managed_policies=[ 

1503 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonEKSClusterPolicy") 

1504 ], 

1505 ) 

1506 

1507 # Create node group role 

1508 # role_name intentionally omitted - let CDK generate unique name 

1509 iam.Role( 

1510 self, 

1511 "NodeGroupRole", 

1512 assumed_by=iam.ServicePrincipal("ec2.amazonaws.com"), 

1513 managed_policies=[ 

1514 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonEKSWorkerNodePolicy"), 

1515 iam.ManagedPolicy.from_aws_managed_policy_name("AmazonEKS_CNI_Policy"), 

1516 iam.ManagedPolicy.from_aws_managed_policy_name( 

1517 "AmazonEC2ContainerRegistryReadOnly" 

1518 ), 

1519 ], 

1520 ) 

1521 

1522 # Create EKS Auto Mode cluster with built-in system and general-purpose nodepools 

1523 # Auto Mode automatically manages compute resources and comes with essential addons 

1524 # Get endpoint access configuration 

1525 eks_config = self.config.get_eks_cluster_config() 

1526 endpoint_access_mode = eks_config.get("endpoint_access", "PRIVATE") 

1527 

1528 # Map config string to EKS EndpointAccess enum. When the public 

1529 # endpoint is enabled, eks_cluster.public_access_cidrs restricts who 

1530 # can reach it; enabling it with no allowlist is a deliberate, 

1531 # loudly-announced 0.0.0.0/0 exposure rather than a silent default. 

1532 public_access_cidrs = [str(cidr) for cidr in (eks_config.get("public_access_cidrs") or [])] 

1533 if endpoint_access_mode == "PRIVATE": 

1534 endpoint_access = eks.EndpointAccess.PRIVATE 

1535 elif public_access_cidrs: 

1536 endpoint_access = eks.EndpointAccess.PUBLIC_AND_PRIVATE.only_from(*public_access_cidrs) 

1537 else: 

1538 Annotations.of(self).add_warning( 

1539 "eks_cluster.endpoint_access is PUBLIC_AND_PRIVATE with no " 

1540 "public_access_cidrs allowlist: the EKS API server accepts " 

1541 "connections from 0.0.0.0/0 (authentication still applies). " 

1542 "Set eks_cluster.public_access_cidrs to your egress CIDRs " 

1543 "(gco stacks eks endpoint set PUBLIC_AND_PRIVATE --cidr <cidr>) " 

1544 "or use PRIVATE with `gco cluster tunnel`." 

1545 ) 

1546 endpoint_access = eks.EndpointAccess.PUBLIC_AND_PRIVATE 

1547 

1548 # Create KMS key for EKS secrets encryption 

1549 self.eks_encryption_key = kms.Key( 

1550 self, 

1551 "EksSecretsEncryptionKey", 

1552 description="KMS key for EKS Kubernetes secrets encryption", 

1553 enable_key_rotation=True, 

1554 removal_policy=RemovalPolicy.RETAIN, 

1555 ) 

1556 

1557 # Get Kubernetes version - use custom version if not available in CDK enum 

1558 k8s_version_str = cluster_config.kubernetes_version 

1559 try: 

1560 k8s_version = getattr(eks.KubernetesVersion, f"V{k8s_version_str.replace('.', '_')}") 

1561 except AttributeError: 

1562 # Version not in CDK enum yet, use custom version 

1563 k8s_version = eks.KubernetesVersion.of(k8s_version_str) 

1564 

1565 self.cluster = eks.Cluster( 

1566 self, 

1567 "GCOEksCluster", 

1568 cluster_name=cluster_config.cluster_name, 

1569 version=k8s_version, # Use configured version for Auto Mode with DRA support 

1570 vpc=self.vpc, 

1571 compute=eks.ComputeConfig( 

1572 # Enable both built-in node pools - Auto Mode manages these automatically 

1573 node_pools=["system", "general-purpose"] 

1574 ), 

1575 # SECURITY: Endpoint access controlled via cdk.json eks_cluster.endpoint_access 

1576 # PRIVATE (default): EKS API accessible only from within VPC - most secure 

1577 # Job submission works via API Gateway → Lambda (in VPC) or SQS 

1578 # For kubectl access, use a bastion host, VPN, or AWS SSM Session Manager 

1579 # PUBLIC_AND_PRIVATE: EKS API accessible from internet and VPC 

1580 # Allows direct kubectl access but less secure 

1581 endpoint_access=endpoint_access, 

1582 role=cluster_admin_role, 

1583 # The VPC spans every AZ, but EKS refuses control-plane subnets in a 

1584 # few AZs (by stable AZ ID; see EKS_UNSUPPORTED_AZ_IDS). Select the 

1585 # private subnets in supported AZs only — worker/other subnets in the 

1586 # excluded AZs still exist in the VPC. 

1587 vpc_subnets=[self._eks_control_plane_subnets()], 

1588 # Enable all control plane logging for security and compliance 

1589 cluster_logging=[ 

1590 eks.ClusterLoggingTypes.API, 

1591 eks.ClusterLoggingTypes.AUDIT, 

1592 eks.ClusterLoggingTypes.AUTHENTICATOR, 

1593 eks.ClusterLoggingTypes.CONTROLLER_MANAGER, 

1594 eks.ClusterLoggingTypes.SCHEDULER, 

1595 ], 

1596 # SECURITY: Enable envelope encryption for Kubernetes secrets using KMS 

1597 secrets_encryption_key=self.eks_encryption_key, 

1598 ) 

1599 

1600 # The EKS cluster security group's auto-generated ingress rule allows 

1601 # 443 from the VPC CIDR, expressed as a CloudFormation token. cdk-nag's 

1602 # SG-ingress rules can't resolve the token and throw; scope the 

1603 # acknowledgment to the cluster construct so it can't mask an 

1604 # open-ingress finding elsewhere in the stack. 

1605 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings 

1606 

1607 acknowledge_security_group_cidr_findings( 

1608 self.cluster, 

1609 reason=( 

1610 "The EKS Auto Mode cluster security group allows HTTPS (443) " 

1611 "ingress from the VPC CIDR only, referenced via an " 

1612 "``Fn::GetAtt`` token that cdk-nag cannot resolve at synth " 

1613 "time. Ingress is restricted to intra-VPC traffic, the " 

1614 "tightest possible source for the Kubernetes API and webhook " 

1615 "endpoints." 

1616 ), 

1617 ) 

1618 

1619 # Auto Mode comes with essential addons pre-configured: 

1620 # - AWS Load Balancer Controller (for ALB/NLB integration) 

1621 # - CoreDNS, kube-proxy, VPC CNI (standard Kubernetes components) 

1622 

1623 # OIDC provider for IRSA — the primary credential injection mechanism. 

1624 # IRSA uses projected service-account tokens exchanged via the OIDC provider 

1625 # for temporary AWS credentials. This works reliably on EKS Auto Mode. 

1626 self.oidc_provider = eks.OidcProviderNative( 

1627 self, 

1628 "OidcProvider", 

1629 url=self.cluster.cluster_open_id_connect_issuer_url, 

1630 ) 

1631 

1632 # Pod Identity Agent add-on — registers the admission webhook that injects 

1633 # Pod Identity credentials. On Auto Mode the DaemonSet schedules 0 pods 

1634 # (the agent is built into the node), but the add-on registration is still 

1635 # needed for the control-plane webhook. Kept as a secondary credential path. 

1636 self._create_pod_identity_agent_addon() 

1637 

1638 # Add Metrics Server add-on for HPA and resource monitoring 

1639 self._create_metrics_server_addon() 

1640 

1641 # Add EFS CSI Driver add-on for shared storage 

1642 self._create_efs_csi_driver_addon() 

1643 

1644 # Add CloudWatch Observability add-on for Container Insights metrics 

1645 self._create_cloudwatch_observability_addon() 

1646 

1647 # NOTE: GPU compute is configured via Karpenter NodePools (not managed node groups) 

1648 # NodePool manifests are located in lambda/kubectl-applier-simple/manifests/: 

1649 # - 40-nodepool-gpu-x86.yaml: active x86_64 general GPU instances (g4dn, g5, g6/g6e/g6f/gr6/gr6f, g7/g7e; deprecated p3/p3dn excluded) 

1650 # - 41-nodepool-gpu-arm.yaml: ARM64 GPU instances (g5g) 

1651 # - 42-nodepool-inference.yaml: inference-optimized GPU instances 

1652 # - 43-nodepool-efa.yaml: EFA-enabled instances (p4d, p4de, p5/p5e/p5en, p6-b200/p6-b300/p6e-gb200) 

1653 # - 44-nodepool-neuron.yaml: Trainium/Inferentia instances 

1654 # These will be applied by the kubectl Lambda custom resource (created below) 

1655 

1656 # Developer access entries (eks_cluster.developer_access). The cluster 

1657 # authenticates through EKS access entries only, and until now no human 

1658 # principal could be granted one at deploy time — every access entry 

1659 # belonged to a platform Lambda. Absent or empty config synthesizes 

1660 # exactly today's entries. 

1661 self._create_developer_access_entries(eks_config) 

1662 

1663 # Create IRSA role for service account to access secrets 

1664 self._create_service_account_role() 

1665 

1666 # Create kubectl Lambda for applying Kubernetes manifests 

1667 self._create_kubectl_lambda() 

1668 

1669 def _create_developer_access_entries(self, eks_config: dict[str, Any]) -> None: 

1670 """Synthesize one EKS access entry per configured developer principal. 

1671 

1672 Each ``eks_cluster.developer_access`` element is 

1673 ``{principal_arn, scope, namespaces}``. The default is deliberately 

1674 the narrow grant — AmazonEKSEditPolicy scoped to the ``gco-jobs`` 

1675 namespace, enough to submit and inspect jobs — not cluster-admin. 

1676 ``scope: cluster`` opts into AmazonEKSClusterAdminPolicy explicitly. 

1677 Config errors fail synthesis: a typo'd access grant must never 

1678 silently synthesize as nothing. 

1679 """ 

1680 entries = eks_config.get("developer_access") or [] 

1681 if not isinstance(entries, list): 

1682 raise ValueError("eks_cluster.developer_access must be a list of entries") 

1683 for index, entry in enumerate(entries): 

1684 if not isinstance(entry, dict): 

1685 raise ValueError( 

1686 f"eks_cluster.developer_access[{index}] must be an object with " 

1687 "principal_arn, and optionally scope and namespaces" 

1688 ) 

1689 principal_arn = entry.get("principal_arn") 

1690 if not isinstance(principal_arn, str) or not principal_arn.startswith("arn:"): 

1691 raise ValueError( 

1692 f"eks_cluster.developer_access[{index}].principal_arn must be an " 

1693 f"IAM principal ARN, got {principal_arn!r}" 

1694 ) 

1695 scope = str(entry.get("scope", "namespace")).strip().lower() 

1696 if scope == "cluster": 

1697 access_policy = eks.AccessPolicy.from_access_policy_name( 

1698 "AmazonEKSClusterAdminPolicy", 

1699 access_scope_type=eks.AccessScopeType.CLUSTER, 

1700 ) 

1701 elif scope == "namespace": 

1702 namespaces = entry.get("namespaces") or ["gco-jobs"] 

1703 if not isinstance(namespaces, list) or not all( 

1704 isinstance(namespace, str) and namespace for namespace in namespaces 

1705 ): 

1706 raise ValueError( 

1707 f"eks_cluster.developer_access[{index}].namespaces must be a " 

1708 "list of namespace names" 

1709 ) 

1710 access_policy = eks.AccessPolicy.from_access_policy_name( 

1711 "AmazonEKSEditPolicy", 

1712 access_scope_type=eks.AccessScopeType.NAMESPACE, 

1713 namespaces=namespaces, 

1714 ) 

1715 else: 

1716 raise ValueError( 

1717 f"eks_cluster.developer_access[{index}].scope must be 'namespace' " 

1718 f"or 'cluster', got {entry.get('scope')!r}" 

1719 ) 

1720 eks.AccessEntry( 

1721 self, 

1722 f"DeveloperAccessEntry{index}", 

1723 cluster=self.cluster, # type: ignore[arg-type] 

1724 principal=principal_arn, 

1725 access_policies=[access_policy], 

1726 ) 

1727 

1728 # ── Shared toleration config for EKS add-ons ────────────────────────── 

1729 # All GCO nodepools apply taints (nvidia.com/gpu, aws.amazon.com/neuron, 

1730 # vpc.amazonaws.com/efa) that prevent DaemonSet pods from scheduling. 

1731 # Every add-on component that runs as a DaemonSet (storage drivers' node 

1732 # agents, metrics/log agents, node exporters) must tolerate these taints 

1733 # so infrastructure works on every node type. 

1734 # 

1735 # Deployment-shaped add-on components (metrics-server, the CSI 

1736 # *controllers*) must NOT carry these tolerations: a toleration makes 

1737 # EKS Auto Mode consider the tainted accelerator pools for them, and 

1738 # during a deploy's pod surge it will happily launch GPU instances for 

1739 # zero-GPU pods and then consolidation-flap them — live release 

1740 # validation run sched241-350ffc7d caught exactly that (two g4dn.xlarge 

1741 # NodeClaims requesting ``nvidia.com/gpu: "0"``, churned mid-install, 

1742 # failing GPU DaemonSet convergence checks). 

1743 _ADDON_NODE_TOLERATIONS = [ 

1744 {"key": "nvidia.com/gpu", "operator": "Exists", "effect": "NoSchedule"}, 

1745 {"key": "aws.amazon.com/neuron", "operator": "Exists", "effect": "NoSchedule"}, 

1746 {"key": "vpc.amazonaws.com/efa", "operator": "Exists", "effect": "NoSchedule"}, 

1747 ] 

1748 

1749 def _create_pod_identity_agent_addon(self) -> None: 

1750 """Create EKS Pod Identity Agent add-on. 

1751 

1752 On Auto Mode the DaemonSet schedules 0 pods (the agent is built into 

1753 the node runtime), but the add-on registration is still required for 

1754 the control-plane admission webhook that injects Pod Identity tokens. 

1755 """ 

1756 eks.Addon( 

1757 self, 

1758 "PodIdentityAgentAddon", 

1759 cluster=self.cluster, # type: ignore[arg-type] 

1760 addon_name="eks-pod-identity-agent", 

1761 addon_version=EKS_ADDON_POD_IDENTITY_AGENT, 

1762 preserve_on_delete=False, 

1763 configuration_values={ 

1764 "tolerations": self._ADDON_NODE_TOLERATIONS, 

1765 }, 

1766 ) 

1767 

1768 def _create_metrics_server_addon(self) -> None: 

1769 """Create Metrics Server add-on for resource metrics. 

1770 

1771 The Metrics Server collects resource metrics from kubelets and exposes 

1772 them via the Kubernetes API server. This is required for: 

1773 - Horizontal Pod Autoscaler (HPA) 

1774 - Vertical Pod Autoscaler (VPA) 

1775 - kubectl top commands 

1776 - Resource monitoring dashboards 

1777 

1778 Note: Metrics Server doesn't require an IRSA role as it only needs 

1779 in-cluster permissions which are handled by its service account. 

1780 """ 

1781 # Deployment-shaped: no accelerator tolerations on purpose (see 

1782 # _ADDON_NODE_TOLERATIONS) — metrics-server runs fine on the default 

1783 # CPU pool and a toleration invites Auto Mode to launch GPU nodes 

1784 # for it during deploy pod surges. 

1785 eks.Addon( 

1786 self, 

1787 "MetricsServerAddon", 

1788 cluster=self.cluster, # type: ignore[arg-type] 

1789 addon_name="metrics-server", 

1790 addon_version=EKS_ADDON_METRICS_SERVER, 

1791 preserve_on_delete=False, 

1792 ) 

1793 

1794 def _create_efs_csi_driver_addon(self) -> None: 

1795 """Create EFS CSI Driver add-on for shared storage support. 

1796 

1797 The EFS CSI driver enables Kubernetes pods to mount EFS file systems 

1798 as persistent volumes. This is required for the shared storage feature. 

1799 

1800 We create a Pod Identity role for the EFS CSI driver and update the add-on 

1801 to use it via a custom resource after the add-on is created. 

1802 """ 

1803 # Create IAM role for EFS CSI Driver using IRSA + Pod Identity 

1804 self.efs_csi_role = GCORegionalStack._create_irsa_role( 

1805 self, 

1806 "EfsCsiDriverRole", 

1807 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

1808 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

1809 service_account_names=["efs-csi-controller-sa"], 

1810 namespaces=["kube-system"], 

1811 ) 

1812 

1813 # Add EFS CSI driver permissions 

1814 self.efs_csi_role.add_managed_policy( 

1815 iam.ManagedPolicy.from_aws_managed_policy_name("service-role/AmazonEFSCSIDriverPolicy") 

1816 ) 

1817 

1818 # Create EFS CSI Driver add-on 

1819 efs_addon = eks.Addon( 

1820 self, 

1821 "EfsCsiDriverAddon", 

1822 cluster=self.cluster, # type: ignore[arg-type] 

1823 addon_name="aws-efs-csi-driver", 

1824 addon_version=EKS_ADDON_EFS_CSI_DRIVER, 

1825 preserve_on_delete=False, 

1826 configuration_values={ 

1827 # DaemonSet node agent must run on every node type; the 

1828 # Deployment-shaped controller deliberately carries no 

1829 # accelerator tolerations (see _ADDON_NODE_TOLERATIONS). 

1830 "node": { 

1831 "tolerations": self._ADDON_NODE_TOLERATIONS, 

1832 }, 

1833 }, 

1834 ) 

1835 

1836 # Append the PassRole statement for the EFS CSI role to the shared 

1837 # AwsCustomResource execution role. See the role's creation in 

1838 # _create_aws_custom_resource_role for the full rationale on why 

1839 # we pre-create + attach up-front instead of letting CDK 

1840 # auto-generate per-CR roles. 

1841 self.aws_custom_resource_role.add_to_policy( 

1842 iam.PolicyStatement( 

1843 effect=iam.Effect.ALLOW, 

1844 actions=["iam:PassRole"], 

1845 resources=[self.efs_csi_role.role_arn], 

1846 ) 

1847 ) 

1848 

1849 # Update the add-on to use the IRSA role via custom resource 

1850 # This is needed because the eks v2 alpha Addon doesn't support service_account_role directly 

1851 update_addon = cr.AwsCustomResource( 

1852 self, 

1853 "UpdateEfsCsiAddonRole", 

1854 on_create=cr.AwsSdkCall( 

1855 service="EKS", 

1856 action="updateAddon", 

1857 parameters={ 

1858 "clusterName": self.cluster.cluster_name, 

1859 "addonName": "aws-efs-csi-driver", 

1860 "serviceAccountRoleArn": self.efs_csi_role.role_arn, 

1861 }, 

1862 physical_resource_id=cr.PhysicalResourceId.of( 

1863 f"{self.cluster.cluster_name}-efs-csi-role-update" 

1864 ), 

1865 ), 

1866 on_update=cr.AwsSdkCall( 

1867 service="EKS", 

1868 action="updateAddon", 

1869 parameters={ 

1870 "clusterName": self.cluster.cluster_name, 

1871 "addonName": "aws-efs-csi-driver", 

1872 "serviceAccountRoleArn": self.efs_csi_role.role_arn, 

1873 }, 

1874 ), 

1875 role=self.aws_custom_resource_role, 

1876 ) 

1877 

1878 # Ensure the update happens after the add-on is created. We also 

1879 # depend on the shared execution role so CloudFormation has fully 

1880 # attached + replicated its inline policy before the Lambda fires. 

1881 update_addon.node.add_dependency(efs_addon) 

1882 update_addon.node.add_dependency(self.efs_csi_role) 

1883 update_addon.node.add_dependency(self.aws_custom_resource_role) 

1884 

1885 # Expose the update-addon resource so _apply_kubernetes_manifests can 

1886 # make the kubectl Lambda wait for the IRSA annotation patch to land 

1887 # before it tries to rollout-restart the efs-csi-controller. Without 

1888 # this ordering, the restart could fire before EKS has re-attached 

1889 # the role ARN, leaving the new pods just as credential-less as the 

1890 # old ones and causing every EFS CreateAccessPoint to fail with a 

1891 # 401 from IMDS. 

1892 self._efs_csi_addon_role_update = update_addon 

1893 

1894 def _create_cloudwatch_observability_addon(self) -> None: 

1895 """Create CloudWatch Observability add-on for Container Insights. 

1896 

1897 The CloudWatch Observability add-on enables Container Insights metrics 

1898 for the EKS cluster, providing visibility into: 

1899 - Cluster CPU and memory utilization 

1900 - Node-level metrics 

1901 - Pod and container metrics 

1902 - Application logs (optional) 

1903 

1904 These metrics are used by the monitoring dashboard to display 

1905 cluster health and resource utilization. 

1906 """ 

1907 

1908 # Create IAM role for CloudWatch agent using IRSA + Pod Identity 

1909 self.cloudwatch_role = GCORegionalStack._create_irsa_role( 

1910 self, 

1911 "CloudWatchObservabilityRole", 

1912 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

1913 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

1914 service_account_names=["cloudwatch-agent"], 

1915 namespaces=["amazon-cloudwatch"], 

1916 ) 

1917 

1918 # Add CloudWatch agent permissions 

1919 self.cloudwatch_role.add_managed_policy( 

1920 iam.ManagedPolicy.from_aws_managed_policy_name("CloudWatchAgentServerPolicy") 

1921 ) 

1922 self.cloudwatch_role.add_managed_policy( 

1923 iam.ManagedPolicy.from_aws_managed_policy_name("AWSXrayWriteOnlyAccess") 

1924 ) 

1925 

1926 # Create CloudWatch Observability add-on 

1927 cw_addon = eks.Addon( 

1928 self, 

1929 "CloudWatchObservabilityAddon", 

1930 cluster=self.cluster, # type: ignore[arg-type] 

1931 addon_name="amazon-cloudwatch-observability", 

1932 addon_version=EKS_ADDON_CLOUDWATCH_OBSERVABILITY, 

1933 preserve_on_delete=False, 

1934 configuration_values={ 

1935 "tolerations": self._ADDON_NODE_TOLERATIONS, 

1936 # Enable Container Insights with application log collection 

1937 # Logs are sent to /aws/containerinsights/{cluster}/application 

1938 "containerLogs": { 

1939 "enabled": True, 

1940 }, 

1941 }, 

1942 ) 

1943 

1944 # Append the PassRole statement for the CloudWatch Observability 

1945 # role to the shared AwsCustomResource execution role. See 

1946 # _create_aws_custom_resource_role for the full rationale. 

1947 self.aws_custom_resource_role.add_to_policy( 

1948 iam.PolicyStatement( 

1949 effect=iam.Effect.ALLOW, 

1950 actions=["iam:PassRole"], 

1951 resources=[self.cloudwatch_role.role_arn], 

1952 ) 

1953 ) 

1954 

1955 # Update the add-on to use the IRSA role via custom resource 

1956 update_cw_addon = cr.AwsCustomResource( 

1957 self, 

1958 "UpdateCloudWatchAddonRole", 

1959 on_create=cr.AwsSdkCall( 

1960 service="EKS", 

1961 action="updateAddon", 

1962 parameters={ 

1963 "clusterName": self.cluster.cluster_name, 

1964 "addonName": "amazon-cloudwatch-observability", 

1965 "serviceAccountRoleArn": self.cloudwatch_role.role_arn, 

1966 }, 

1967 physical_resource_id=cr.PhysicalResourceId.of( 

1968 f"{self.cluster.cluster_name}-cw-obs-role-update" 

1969 ), 

1970 ), 

1971 on_update=cr.AwsSdkCall( 

1972 service="EKS", 

1973 action="updateAddon", 

1974 parameters={ 

1975 "clusterName": self.cluster.cluster_name, 

1976 "addonName": "amazon-cloudwatch-observability", 

1977 "serviceAccountRoleArn": self.cloudwatch_role.role_arn, 

1978 }, 

1979 ), 

1980 role=self.aws_custom_resource_role, 

1981 ) 

1982 

1983 # Ensure the update happens after the add-on is created. Depend on 

1984 # the shared execution role so CFN has fully attached + replicated 

1985 # its inline policy before the Lambda fires. No CR→CR dependency 

1986 # chain needed anymore — the race it was serializing against is 

1987 # eliminated by pre-creating the role. 

1988 update_cw_addon.node.add_dependency(cw_addon) 

1989 update_cw_addon.node.add_dependency(self.cloudwatch_role) 

1990 update_cw_addon.node.add_dependency(self.aws_custom_resource_role) 

1991 

1992 # Expose the update-addon resource so _apply_kubernetes_manifests can 

1993 # make the kubectl Lambda wait for the IRSA annotation patch to land 

1994 # before it rollout-restarts the cloudwatch-agent DaemonSet. See the 

1995 # EFS CSI equivalent for the full rationale — same race, same fix. 

1996 self._cloudwatch_addon_role_update = update_cw_addon 

1997 

1998 def _create_service_account_role(self) -> None: 

1999 """Create IAM role for Kubernetes service account using EKS Pod Identity. 

2000 

2001 Pod Identity is the recommended mechanism for EKS Auto Mode. It's simpler 

2002 and more reliable than IRSA — no OIDC provider, no webhook injection, no 

2003 projected tokens. EKS manages the credential injection automatically. 

2004 

2005 The general workload role is deliberately separate from the manifest 

2006 processor role. Job and inference workload service accounts must never 

2007 receive queue-table mutation privileges; only the platform API/worker 

2008 identity can claim, fence, or transition centralized queue records. 

2009 """ 

2010 self.service_account_role = GCORegionalStack._create_irsa_role( 

2011 self, 

2012 "ServiceAccountRole", 

2013 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

2014 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

2015 service_account_names=[ 

2016 "gco-service-account", 

2017 "gco-inference-monitor-sa", 

2018 ], 

2019 namespaces=["gco-system", "gco-jobs", "gco-inference"], 

2020 ) 

2021 

2022 self.manifest_processor_role = GCORegionalStack._create_irsa_role( 

2023 self, 

2024 "ManifestProcessorRole", 

2025 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

2026 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

2027 service_account_names=["gco-manifest-processor-sa"], 

2028 namespaces=["gco-system"], 

2029 ) 

2030 

2031 self.inference_proxy_role = GCORegionalStack._create_irsa_role( 

2032 self, 

2033 "InferenceProxyRole", 

2034 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

2035 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

2036 service_account_names=["gco-inference-proxy-sa"], 

2037 namespaces=["gco-system"], 

2038 ) 

2039 

2040 self.health_monitor_role = GCORegionalStack._create_irsa_role( 

2041 self, 

2042 "HealthMonitorRole", 

2043 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

2044 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

2045 service_account_names=["gco-health-monitor-sa"], 

2046 namespaces=["gco-system"], 

2047 ) 

2048 

2049 if self._cost_monitoring_active(): 

2050 self.cost_monitor_role = GCORegionalStack._create_irsa_role( 

2051 self, 

2052 "CostMonitorRole", 

2053 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

2054 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

2055 service_account_names=["gco-cost-monitor-sa"], 

2056 namespaces=["gco-system"], 

2057 ) 

2058 self._grant_cost_report_bucket_discovery_to_cost_monitor() 

2059 

2060 self._create_aws_load_balancer_controller_role() 

2061 

2062 # Grant permission to read the auth secret. 

2063 # 

2064 # The resource is built as a *deterministic* ARN from the known secret 

2065 # name, the API Gateway region (where the secret lives), and this 

2066 # stack's account — rather than from ``self.auth_secret_arn``, which is 

2067 # ``api_gateway_stack.secret.secret_arn`` (a cross-stack reference 

2068 # token). The token was the source of issue #125: it renders 

2069 # differently depending on topology — 

2070 # * cross-region -> a literal ARN, and 

2071 # * same-region -> a native cross-stack export 

2072 # (``gco-api-gateway:ExportsOutputRefGCOAuthSecret<hash>``) 

2073 # so the trailing-``*`` IAM resource only matched the stack-level 

2074 # AwsSolutions-IAM5 suppression in the cross-region (default) topology. 

2075 # Collapsing every stack into one region left the export-token form 

2076 # unsuppressed and failed ``cdk synth``. Building the ARN ourselves 

2077 # makes the ``Resource`` render identically in both topologies so a 

2078 # single deterministic suppression (see ``add_iam_suppressions``) 

2079 # always matches. 

2080 # 

2081 # The trailing ``*`` matches the random 6-character suffix Secrets 

2082 # Manager appends to secret ARNs (Secrets Manager accepts either the 

2083 # full ARN with suffix or the partial ARN without it). 

2084 auth_secret_resource = ( 

2085 f"arn:{self.partition}:secretsmanager:{self.config.get_api_gateway_region()}" 

2086 f":{self.account}:secret:{api_gateway_auth_secret_name(self.config.get_project_name())}*" 

2087 ) 

2088 self.manifest_processor_role.add_to_policy( 

2089 iam.PolicyStatement( 

2090 effect=iam.Effect.ALLOW, 

2091 actions=[ 

2092 "secretsmanager:GetSecretValue", 

2093 "secretsmanager:DescribeSecret", 

2094 ], 

2095 resources=[auth_secret_resource], 

2096 ) 

2097 ) 

2098 self.inference_proxy_role.add_to_policy( 

2099 iam.PolicyStatement( 

2100 effect=iam.Effect.ALLOW, 

2101 actions=[ 

2102 "secretsmanager:GetSecretValue", 

2103 "secretsmanager:DescribeSecret", 

2104 ], 

2105 resources=[auth_secret_resource], 

2106 ) 

2107 ) 

2108 self.health_monitor_role.add_to_policy( 

2109 iam.PolicyStatement( 

2110 effect=iam.Effect.ALLOW, 

2111 actions=[ 

2112 "secretsmanager:GetSecretValue", 

2113 "secretsmanager:DescribeSecret", 

2114 ], 

2115 resources=[auth_secret_resource], 

2116 ) 

2117 ) 

2118 

2119 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

2120 

2121 # The SQS queue processor runs as gco-manifest-processor-sa. Keep 

2122 # queue consumption on that dedicated platform identity; KEDA has its 

2123 # own read-only queue role and general workload identities must not be 

2124 # able to receive or delete submitted jobs. 

2125 self.manifest_processor_role.add_to_policy( 

2126 iam.PolicyStatement( 

2127 effect=iam.Effect.ALLOW, 

2128 actions=[ 

2129 "sqs:ReceiveMessage", 

2130 "sqs:DeleteMessage", 

2131 ], 

2132 resources=[self.job_queue.queue_arn], 

2133 ) 

2134 ) 

2135 

2136 # Manifest API/worker metrics are emitted only by the dedicated 

2137 # platform identity, never by user workload service accounts. 

2138 self.manifest_processor_role.add_to_policy( 

2139 iam.PolicyStatement( 

2140 effect=iam.Effect.ALLOW, 

2141 actions=["cloudwatch:PutMetricData"], 

2142 resources=["*"], 

2143 conditions={"StringEquals": {"cloudwatch:namespace": "GCO/ManifestProcessor"}}, 

2144 ) 

2145 ) 

2146 

2147 # The central queue worker's spot price gate reads current spot 

2148 # pricing before dispatching price-capped jobs. 

2149 # ec2:DescribeSpotPriceHistory is a read-only Describe* action that 

2150 # does not support resource-level scoping (Resource must be *). 

2151 self.manifest_processor_role.add_to_policy( 

2152 iam.PolicyStatement( 

2153 effect=iam.Effect.ALLOW, 

2154 actions=["ec2:DescribeSpotPriceHistory"], 

2155 resources=["*"], 

2156 ) 

2157 ) 

2158 

2159 # Add DynamoDB permissions for templates, webhooks, and job queue 

2160 # Tables are created in the global stack and accessed from all regions 

2161 project_name = self.config.get_project_name() 

2162 global_region = self.config.get_global_region() 

2163 

2164 # Health-monitor runtime grants are isolated from the shared workload 

2165 # role. It can read/repair one endpoint-registry parameter, read webhook 

2166 # subscriptions, publish only its metric namespace, and read the auth 

2167 # secret granted above. 

2168 self.health_monitor_role.add_to_policy( 

2169 iam.PolicyStatement( 

2170 effect=iam.Effect.ALLOW, 

2171 actions=["ssm:GetParameter", "ssm:PutParameter"], 

2172 resources=[ 

2173 f"arn:{self.partition}:ssm:{global_region}:{self.account}:" 

2174 f"parameter/{project_name}/alb-hostname-{self.deployment_region}" 

2175 ], 

2176 ) 

2177 ) 

2178 self.health_monitor_role.add_to_policy( 

2179 iam.PolicyStatement( 

2180 effect=iam.Effect.ALLOW, 

2181 actions=["dynamodb:Query", "dynamodb:Scan"], 

2182 resources=[ 

2183 f"arn:{self.partition}:dynamodb:{global_region}:{self.account}:" 

2184 f"table/{project_name}-webhooks", 

2185 f"arn:{self.partition}:dynamodb:{global_region}:{self.account}:" 

2186 f"table/{project_name}-webhooks/index/namespace-index", 

2187 ], 

2188 ) 

2189 ) 

2190 self.health_monitor_role.add_to_policy( 

2191 iam.PolicyStatement( 

2192 effect=iam.Effect.ALLOW, 

2193 actions=["cloudwatch:PutMetricData"], 

2194 resources=["*"], 

2195 conditions={"StringEquals": {"cloudwatch:namespace": "GCO/HealthMonitor"}}, 

2196 ) 

2197 ) 

2198 acknowledge_nag_findings( 

2199 self.health_monitor_role, 

2200 [ 

2201 { 

2202 "id": "AwsSolutions-IAM5", 

2203 "reason": ( 

2204 "HealthMonitorRole has two unavoidable wildcard shapes: the " 

2205 "Secrets Manager random ARN suffix and cloudwatch:PutMetricData's " 

2206 "required Resource:*. PutMetricData is constrained to the exact " 

2207 "GCO/HealthMonitor namespace; all SSM and DynamoDB resources are exact." 

2208 ), 

2209 "appliesTo": ["Resource::*"], 

2210 } 

2211 ], 

2212 ) 

2213 

2214 # The manifest processor is the only identity that can mutate the 

2215 # centralized queue. Workload identities receive no access to the jobs 

2216 # table, preventing submitted pods from forging queue state. 

2217 manifest_table_prefix = ( 

2218 f"arn:{self.partition}:dynamodb:{global_region}:{self.account}:table/{project_name}" 

2219 ) 

2220 self.manifest_processor_role.add_to_policy( 

2221 iam.PolicyStatement( 

2222 effect=iam.Effect.ALLOW, 

2223 actions=[ 

2224 "dynamodb:GetItem", 

2225 "dynamodb:PutItem", 

2226 "dynamodb:UpdateItem", 

2227 "dynamodb:DeleteItem", 

2228 "dynamodb:Query", 

2229 "dynamodb:Scan", 

2230 ], 

2231 resources=[ 

2232 f"{manifest_table_prefix}-job-templates", 

2233 f"{manifest_table_prefix}-job-templates/index/*", 

2234 f"{manifest_table_prefix}-webhooks", 

2235 f"{manifest_table_prefix}-webhooks/index/*", 

2236 ], 

2237 ) 

2238 ) 

2239 self.manifest_processor_role.add_to_policy( 

2240 iam.PolicyStatement( 

2241 effect=iam.Effect.ALLOW, 

2242 actions=[ 

2243 "dynamodb:GetItem", 

2244 "dynamodb:PutItem", 

2245 "dynamodb:UpdateItem", 

2246 "dynamodb:Query", 

2247 "dynamodb:Scan", 

2248 ], 

2249 resources=[ 

2250 f"{manifest_table_prefix}-jobs", 

2251 f"{manifest_table_prefix}-jobs/index/*", 

2252 ], 

2253 ) 

2254 ) 

2255 

2256 # The inference proxy needs only point reads of endpoint state. It has 

2257 # no write, scan, index, S3, Kubernetes, or queue permissions. 

2258 self.inference_proxy_role.add_to_policy( 

2259 iam.PolicyStatement( 

2260 effect=iam.Effect.ALLOW, 

2261 actions=["dynamodb:GetItem"], 

2262 resources=[f"{manifest_table_prefix}-inference-endpoints"], 

2263 ) 

2264 ) 

2265 acknowledge_nag_findings( 

2266 self.inference_proxy_role, 

2267 [ 

2268 { 

2269 "id": "AwsSolutions-IAM5", 

2270 "reason": ( 

2271 "InferenceProxyRole uses one wildcard only for the random " 

2272 "Secrets Manager ARN suffix. DynamoDB access is an exact-table " 

2273 "GetItem grant, and the role has no Kubernetes, queue, or write access." 

2274 ), 

2275 "appliesTo": ["Resource::*"], 

2276 } 

2277 ], 

2278 ) 

2279 

2280 # The inference monitor still owns desired-state reconciliation, but 

2281 # this shared role intentionally has no jobs-table ARN. 

2282 self.service_account_role.add_to_policy( 

2283 iam.PolicyStatement( 

2284 effect=iam.Effect.ALLOW, 

2285 actions=[ 

2286 "dynamodb:GetItem", 

2287 "dynamodb:PutItem", 

2288 "dynamodb:UpdateItem", 

2289 "dynamodb:DeleteItem", 

2290 "dynamodb:Query", 

2291 "dynamodb:Scan", 

2292 ], 

2293 resources=[ 

2294 f"{manifest_table_prefix}-inference-endpoints", 

2295 f"{manifest_table_prefix}-inference-endpoints/index/*", 

2296 ], 

2297 ) 

2298 ) 

2299 acknowledge_nag_findings( 

2300 self.manifest_processor_role, 

2301 [ 

2302 { 

2303 "id": "AwsSolutions-IAM5", 

2304 "reason": ( 

2305 "ManifestProcessorRole has only four required wildcard shapes: " 

2306 "DynamoDB secondary indexes, the Secrets Manager generated ARN " 

2307 "suffix, cloudwatch:PutMetricData Resource:*, and the read-only " 

2308 "ec2:DescribeSpotPriceHistory Resource:* (Describe* actions do " 

2309 "not support resource-level scoping; the central queue worker's " 

2310 "spot price gate needs current pricing). DynamoDB table names " 

2311 "and the CloudWatch namespace are otherwise exact." 

2312 ), 

2313 "appliesTo": ["Resource::*"], 

2314 } 

2315 ], 

2316 ) 

2317 

2318 # Workload pods must run as gco-service-account to write artifacts 

2319 # (it is the only identity with RW on the regional shared bucket plus 

2320 # KMS encrypt), and without this statement that same role could not 

2321 # publish training metrics — CloudWatch denied PutMetricData with a 

2322 # warning most trainers swallow. Grant exactly one namespace, 

2323 # configurable via cdk.json::workload_metrics.cloudwatch_namespace so 

2324 # a deployment can point its own consumers at it; the default is a 

2325 # GCO-owned workload namespace. PutMetricData does not support 

2326 # resource-level scoping (Resource must be *), so the namespace 

2327 # condition carries the whole restriction — the same shape as every 

2328 # platform role's metrics grant. 

2329 workload_metrics_config = self.node.try_get_context("workload_metrics") or {} 

2330 workload_metrics_namespace = str( 

2331 workload_metrics_config.get("cloudwatch_namespace") or "GCO/Workloads" 

2332 ) 

2333 self.service_account_role.add_to_policy( 

2334 iam.PolicyStatement( 

2335 effect=iam.Effect.ALLOW, 

2336 actions=["cloudwatch:PutMetricData"], 

2337 resources=["*"], 

2338 conditions={"StringEquals": {"cloudwatch:namespace": workload_metrics_namespace}}, 

2339 ) 

2340 ) 

2341 acknowledge_nag_findings( 

2342 self.service_account_role, 

2343 [ 

2344 { 

2345 "id": "AwsSolutions-IAM5", 

2346 "reason": ( 

2347 "cloudwatch:PutMetricData does not support resource-level " 

2348 "scoping and requires Resource:*. The statement is " 

2349 "constrained by a StringEquals condition to exactly one " 

2350 "configured workload metric namespace, matching the " 

2351 "namespace-conditioned metrics grants on the platform roles." 

2352 ), 

2353 "appliesTo": ["Resource::*"], 

2354 } 

2355 ], 

2356 ) 

2357 

2358 # Vector-store read path for workloads (opt-in feature). Deliberately 

2359 # LOCAL-region ARNs: the store is a DynamoDB global table with a 

2360 # replica in this cluster's region, so pods query locally instead of 

2361 # crossing regions to the primary. Every resource is exact — the 

2362 # table and index names are deterministic from config — and the 

2363 # grant is read-only: writes belong to the ingest Lambda in the 

2364 # global stack, so a compromised workload cannot poison the corpus. 

2365 # The embedding-model grant lets pods embed their own query text 

2366 # with the exact model the corpus was ingested with. 

2367 if self.config.get_vector_store_enabled(): 

2368 vector_store_table_prefix = ( 

2369 f"arn:{self.partition}:dynamodb:{self.region}:{self.account}:" 

2370 f"table/{project_name}-vector-store" 

2371 ) 

2372 self.service_account_role.add_to_policy( 

2373 iam.PolicyStatement( 

2374 effect=iam.Effect.ALLOW, 

2375 actions=[ 

2376 "dynamodb:SearchVectors", 

2377 "dynamodb:GetItem", 

2378 "dynamodb:Query", 

2379 ], 

2380 resources=[ 

2381 vector_store_table_prefix, 

2382 f"{vector_store_table_prefix}/index/corpus-embedding-index", 

2383 ], 

2384 ) 

2385 ) 

2386 self.service_account_role.add_to_policy( 

2387 iam.PolicyStatement( 

2388 effect=iam.Effect.ALLOW, 

2389 actions=["bedrock:InvokeModel"], 

2390 resources=[ 

2391 f"arn:{self.partition}:bedrock:{self.region}::foundation-model/" 

2392 f"{self.config.get_vector_store_config()['embedding_model_id']}" 

2393 ], 

2394 ) 

2395 ) 

2396 

2397 # Add S3 permissions for model weights bucket (used by inference init containers) 

2398 self.service_account_role.add_to_policy( 

2399 iam.PolicyStatement( 

2400 effect=iam.Effect.ALLOW, 

2401 actions=[ 

2402 "s3:GetObject", 

2403 "s3:ListBucket", 

2404 ], 

2405 resources=[ 

2406 f"arn:{self.partition}:s3:::{project_name}-*", 

2407 f"arn:{self.partition}:s3:::{project_name}-*/*", 

2408 ], 

2409 ) 

2410 ) 

2411 

2412 # KMS decrypt for model weights bucket (S3-scoped) 

2413 self.service_account_role.add_to_policy( 

2414 iam.PolicyStatement( 

2415 effect=iam.Effect.ALLOW, 

2416 actions=["kms:Decrypt", "kms:GenerateDataKey"], 

2417 resources=[f"arn:{self.partition}:kms:*:{self.account}:key/*"], 

2418 conditions={ 

2419 "StringLike": { 

2420 "kms:ViaService": f"s3.*.{self.url_suffix}", 

2421 } 

2422 }, 

2423 ) 

2424 ) 

2425 

2426 # Create KEDA operator IAM role for SQS access 

2427 self._create_keda_operator_role() 

2428 

2429 # Create Pod Identity Associations for all service accounts 

2430 self._create_pod_identity_associations() 

2431 

2432 def _create_aws_load_balancer_controller_role(self) -> None: 

2433 """Create the controller's exact OIDC-only IRSA role and v3.4.2 policy.""" 

2434 self.aws_load_balancer_controller_role = GCORegionalStack._create_irsa_role( 

2435 self, 

2436 "AwsLoadBalancerControllerRole", 

2437 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

2438 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

2439 service_account_names=["aws-load-balancer-controller"], 

2440 namespaces=["kube-system"], 

2441 include_pod_identity=False, 

2442 ) 

2443 self.aws_load_balancer_controller_policy = iam.Policy( 

2444 self, 

2445 "AwsLoadBalancerControllerPolicy", 

2446 document=iam.PolicyDocument.from_json( 

2447 aws_load_balancer_controller_policy_document(self.partition) 

2448 ), 

2449 ) 

2450 self.aws_load_balancer_controller_role.attach_inline_policy( 

2451 self.aws_load_balancer_controller_policy 

2452 ) 

2453 

2454 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

2455 

2456 acknowledge_nag_findings( 

2457 self.aws_load_balancer_controller_policy, 

2458 [ 

2459 { 

2460 "id": "AwsSolutions-IAM5", 

2461 "reason": ( 

2462 "This is the exact upstream AWS Load Balancer Controller v3.4.2 " 

2463 "IAM policy. Its Resource::* entries cover AWS APIs that cannot " 

2464 "be resource-scoped, while its wildcard ARN segments are limited " 

2465 "to the controller's supported EC2 and ELB resource types and " 

2466 "constrained by upstream cluster ownership tag conditions. The " 

2467 "role trust is restricted to kube-system/aws-load-balancer-controller." 

2468 ), 

2469 "appliesTo": [ 

2470 "Resource::*", 

2471 "Resource::arn:<AWS::Partition>:ec2:*:*:security-group/*", 

2472 ( 

2473 "Resource::arn:<AWS::Partition>:elasticloadbalancing:*:*:" 

2474 "loadbalancer/app/*/*" 

2475 ), 

2476 ( 

2477 "Resource::arn:<AWS::Partition>:elasticloadbalancing:*:*:" 

2478 "loadbalancer/net/*/*" 

2479 ), 

2480 ("Resource::arn:<AWS::Partition>:elasticloadbalancing:*:*:targetgroup/*/*"), 

2481 ( 

2482 "Resource::arn:<AWS::Partition>:elasticloadbalancing:*:*:" 

2483 "listener-rule/app/*/*/*" 

2484 ), 

2485 ( 

2486 "Resource::arn:<AWS::Partition>:elasticloadbalancing:*:*:" 

2487 "listener-rule/net/*/*/*" 

2488 ), 

2489 ( 

2490 "Resource::arn:<AWS::Partition>:elasticloadbalancing:*:*:" 

2491 "listener/app/*/*/*" 

2492 ), 

2493 ( 

2494 "Resource::arn:<AWS::Partition>:elasticloadbalancing:*:*:" 

2495 "listener/net/*/*/*" 

2496 ), 

2497 ], 

2498 } 

2499 ], 

2500 ) 

2501 

2502 def _create_keda_operator_role(self) -> None: 

2503 """Create IAM role for KEDA operator service account using EKS Pod Identity. 

2504 

2505 This role allows the KEDA operator to access SQS queues for scaling 

2506 based on queue depth. The role is assumed by the keda-operator service 

2507 account in the keda namespace. 

2508 """ 

2509 # Create IAM role with IRSA (OIDC) trust + Pod Identity trust 

2510 self.keda_operator_role = GCORegionalStack._create_irsa_role( 

2511 self, 

2512 "KedaOperatorRole", 

2513 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

2514 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

2515 service_account_names=["keda-operator"], 

2516 namespaces=["keda"], 

2517 ) 

2518 

2519 # Add SQS permissions for KEDA to read queue metrics 

2520 self.keda_operator_role.add_to_policy( 

2521 iam.PolicyStatement( 

2522 effect=iam.Effect.ALLOW, 

2523 actions=[ 

2524 "sqs:GetQueueAttributes", 

2525 "sqs:GetQueueUrl", 

2526 ], 

2527 resources=[ 

2528 self.job_queue.queue_arn, 

2529 self.job_dlq.queue_arn, 

2530 ], 

2531 ) 

2532 ) 

2533 

2534 # CloudWatch read permissions for GPU-based autoscaling. The KEDA 

2535 # aws-cloudwatch scaler reads ContainerInsights GPU utilization metrics 

2536 # to scale inference roles that request GPUs — GPU is not a native HPA 

2537 # resource metric, so this is the only path that can drive GPU scaling. 

2538 # The CloudWatch read APIs do not support resource-level IAM scoping, so 

2539 # they are granted account-wide (read-only). 

2540 self.keda_operator_role.add_to_policy( 

2541 iam.PolicyStatement( 

2542 effect=iam.Effect.ALLOW, 

2543 actions=[ 

2544 "cloudwatch:GetMetricData", 

2545 "cloudwatch:GetMetricStatistics", 

2546 "cloudwatch:ListMetrics", 

2547 ], 

2548 resources=["*"], 

2549 ) 

2550 ) 

2551 

2552 # cdk-nag suppression: the CloudWatch metric-read APIs do not support 

2553 # resource-level IAM scoping — Resource: * is the only valid form. 

2554 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

2555 

2556 acknowledge_nag_findings( 

2557 self.keda_operator_role, 

2558 [ 

2559 { 

2560 "id": "AwsSolutions-IAM5", 

2561 "reason": ( 

2562 "The KEDA operator reads CloudWatch metrics " 

2563 "(GetMetricData, GetMetricStatistics, ListMetrics) to " 

2564 "drive GPU-based autoscaling. These APIs do not support " 

2565 "resource-level IAM scoping — Resource: * is the only " 

2566 "valid form. The grant is read-only." 

2567 ), 

2568 "appliesTo": ["Resource::*"], 

2569 }, 

2570 ], 

2571 ) 

2572 

2573 def _create_pod_identity_associations(self) -> None: 

2574 """Create EKS Pod Identity Associations for all service accounts. 

2575 

2576 Pod Identity is the recommended mechanism for EKS Auto Mode. Each 

2577 association links an IAM role to a Kubernetes service account in a 

2578 specific namespace. EKS manages credential injection automatically. 

2579 

2580 Stores associations in self._pod_identity_associations so the 

2581 kubectl-applier custom resource can declare an explicit dependency, 

2582 ensuring credentials are available before workloads start. 

2583 """ 

2584 self._pod_identity_associations: list[Any] = [] 

2585 

2586 # Health monitor — isolated write access for ALB hostname self-healing. 

2587 health_assoc = eks_l1.CfnPodIdentityAssociation( 

2588 self, 

2589 "PodIdentity-health-monitor", 

2590 cluster_name=self.cluster.cluster_name, 

2591 namespace="gco-system", 

2592 service_account="gco-health-monitor-sa", 

2593 role_arn=self.health_monitor_role.role_arn, 

2594 ) 

2595 self._pod_identity_associations.append(health_assoc) 

2596 

2597 # Manifest API and central queue worker — dedicated queue mutation role. 

2598 manifest_assoc = eks_l1.CfnPodIdentityAssociation( 

2599 self, 

2600 "PodIdentity-manifest-processor", 

2601 cluster_name=self.cluster.cluster_name, 

2602 namespace="gco-system", 

2603 service_account="gco-manifest-processor-sa", 

2604 role_arn=self.manifest_processor_role.role_arn, 

2605 ) 

2606 self._pod_identity_associations.append(manifest_assoc) 

2607 

2608 # Inference data plane — exact secret + endpoint-table read role, with 

2609 # no Kubernetes RBAC binding. 

2610 inference_proxy_assoc = eks_l1.CfnPodIdentityAssociation( 

2611 self, 

2612 "PodIdentity-inference-proxy", 

2613 cluster_name=self.cluster.cluster_name, 

2614 namespace="gco-system", 

2615 service_account="gco-inference-proxy-sa", 

2616 role_arn=self.inference_proxy_role.role_arn, 

2617 ) 

2618 self._pod_identity_associations.append(inference_proxy_assoc) 

2619 

2620 # Inference monitor — reconciles endpoints with the shared platform 

2621 # role (its IRSA subject is already in that role's trust policy). 

2622 # Every other platform Deployment gets both credential paths; this one 

2623 # had only the IRSA annotation. 

2624 inference_monitor_assoc = eks_l1.CfnPodIdentityAssociation( 

2625 self, 

2626 "PodIdentity-inference-monitor", 

2627 cluster_name=self.cluster.cluster_name, 

2628 namespace="gco-system", 

2629 service_account="gco-inference-monitor-sa", 

2630 role_arn=self.service_account_role.role_arn, 

2631 ) 

2632 self._pod_identity_associations.append(inference_monitor_assoc) 

2633 

2634 # Cost monitor — only when the pipeline deploys here (the role and 

2635 # 34-cost-monitor.yaml are gated the same way). 

2636 if self._cost_monitoring_active(): 

2637 cost_monitor_assoc = eks_l1.CfnPodIdentityAssociation( 

2638 self, 

2639 "PodIdentity-cost-monitor", 

2640 cluster_name=self.cluster.cluster_name, 

2641 namespace="gco-system", 

2642 service_account="gco-cost-monitor-sa", 

2643 role_arn=self.cost_monitor_role.role_arn, 

2644 ) 

2645 self._pod_identity_associations.append(cost_monitor_assoc) 

2646 

2647 # Shared GCO service account for user job and inference workloads — 

2648 # the two namespaces 01-serviceaccounts.yaml actually declares it in. 

2649 # (A gco-system association used to be created as well; no such 

2650 # ServiceAccount exists there, so it never bound anything.) 

2651 for namespace in ["gco-jobs", "gco-inference"]: 

2652 assoc = eks_l1.CfnPodIdentityAssociation( 

2653 self, 

2654 f"PodIdentity-gco-sa-{namespace}", 

2655 cluster_name=self.cluster.cluster_name, 

2656 namespace=namespace, 

2657 service_account="gco-service-account", 

2658 role_arn=self.service_account_role.role_arn, 

2659 ) 

2660 self._pod_identity_associations.append(assoc) 

2661 

2662 # KEDA operator — needs SQS access for queue-based scaling 

2663 keda_assoc = eks_l1.CfnPodIdentityAssociation( 

2664 self, 

2665 "PodIdentity-keda-operator", 

2666 cluster_name=self.cluster.cluster_name, 

2667 namespace="keda", 

2668 service_account="keda-operator", 

2669 role_arn=self.keda_operator_role.role_arn, 

2670 ) 

2671 self._pod_identity_associations.append(keda_assoc) 

2672 

2673 # EFS CSI driver — needs EFS access for shared storage 

2674 efs_assoc = eks_l1.CfnPodIdentityAssociation( 

2675 self, 

2676 "PodIdentity-efs-csi", 

2677 cluster_name=self.cluster.cluster_name, 

2678 namespace="kube-system", 

2679 service_account="efs-csi-controller-sa", 

2680 role_arn=self.efs_csi_role.role_arn, 

2681 ) 

2682 self._pod_identity_associations.append(efs_assoc) 

2683 

2684 # CloudWatch agent — needs CloudWatch access for observability 

2685 cw_assoc = eks_l1.CfnPodIdentityAssociation( 

2686 self, 

2687 "PodIdentity-cloudwatch", 

2688 cluster_name=self.cluster.cluster_name, 

2689 namespace="amazon-cloudwatch", 

2690 service_account="cloudwatch-agent", 

2691 role_arn=self.cloudwatch_role.role_arn, 

2692 ) 

2693 self._pod_identity_associations.append(cw_assoc) 

2694 

2695 # FSx CSI driver — only when FSx is enabled (created later in _create_fsx_lustre) 

2696 # The FSx Pod Identity association is added in _create_fsx_lustre instead 

2697 

2698 def _resolve_cluster_shared_bucket_from_ssm(self) -> SharedBucketIdentity: 

2699 """Resolve the ``Cluster_Shared_Bucket`` identity from cross-region SSM. 

2700 

2701 ``GCOGlobalStack`` publishes three ``ssm.StringParameter``s in the 

2702 global region at ``/gco/cluster-shared-bucket/{name,arn,region}``. 

2703 This method reads them back from the regional stack via 

2704 ``cr.AwsCustomResource`` with ``service="SSM"``, 

2705 ``action="getParameter"``, and ``region=<global-region>`` — matching 

2706 the cross-region read pattern already used in 

2707 ``_create_ga_registration_lambda`` for the Global Accelerator 

2708 endpoint group ARN. 

2709 

2710 Runs unconditionally in ``__init__`` — no feature toggle, no 

2711 conditional guard. The returned :class:`SharedBucketIdentity` feeds 

2712 ``_grant_cluster_shared_bucket_to_job_role`` (IAM) and the 

2713 ``image_replacements`` dict (ConfigMap) downstream. 

2714 

2715 Returns: 

2716 :class:`SharedBucketIdentity` with ``name``, ``arn``, and 

2717 ``region`` populated as CDK tokens that resolve at deploy time. 

2718 """ 

2719 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

2720 

2721 global_region = self.config.get_global_region() 

2722 cluster_shared_prefix = cluster_shared_ssm_parameter_prefix(self.config.get_project_name()) 

2723 resolved: dict[str, str] = {} 

2724 

2725 for suffix in ("name", "arn", "region"): 

2726 parameter_name = f"{cluster_shared_prefix}/{suffix}" 

2727 read_cr = cr.AwsCustomResource( 

2728 self, 

2729 f"ReadClusterSharedBucket{suffix.capitalize()}", 

2730 on_create=cr.AwsSdkCall( 

2731 service="SSM", 

2732 action="getParameter", 

2733 parameters={"Name": parameter_name}, 

2734 region=global_region, 

2735 physical_resource_id=cr.PhysicalResourceId.of(f"cluster-shared-{suffix}"), 

2736 ), 

2737 on_update=cr.AwsSdkCall( 

2738 service="SSM", 

2739 action="getParameter", 

2740 parameters={"Name": parameter_name}, 

2741 region=global_region, 

2742 physical_resource_id=cr.PhysicalResourceId.of(f"cluster-shared-{suffix}"), 

2743 ), 

2744 # Cross-region SSM GetParameter doesn't support resource-level 

2745 # scoping cleanly — the principal evaluating the call lives in 

2746 # this stack's region but the parameter lives in the global 

2747 # region. ANY_RESOURCE is the AWS-documented escape hatch; the 

2748 # resulting AwsSolutions-IAM5 nag finding is suppressed in a 

2749 # scoped add_resource_suppressions call below. 

2750 policy=cr.AwsCustomResourcePolicy.from_sdk_calls( 

2751 resources=cr.AwsCustomResourcePolicy.ANY_RESOURCE 

2752 ), 

2753 ) 

2754 

2755 # Scoped suppression: the CR policy is Resource::* because the 

2756 # SSM parameter lives in the global region (cross-region calls 

2757 # don't support resource-level scoping cleanly). The action is 

2758 # fixed to ssm:GetParameter and the parameter Name is fixed to 

2759 # a literal string, so the wildcard can only ever read one 

2760 # parameter. 

2761 acknowledge_nag_findings( 

2762 read_cr, 

2763 [ 

2764 { 

2765 "id": "AwsSolutions-IAM5", 

2766 "reason": ( 

2767 "Cross-region ssm:GetParameter for " 

2768 f"{parameter_name} in the global region. The " 

2769 "AwsCustomResource SDK-call policy is scoped to " 

2770 "a single fixed action (ssm:GetParameter) with " 

2771 "a fixed parameter Name — the Resource: * is " 

2772 "the CDK-documented escape hatch because the " 

2773 "parameter ARN is not known to the calling " 

2774 "principal's region. Effective blast radius: " 

2775 "one parameter." 

2776 ), 

2777 "appliesTo": ["Resource::*"], 

2778 }, 

2779 ], 

2780 ) 

2781 

2782 resolved[suffix] = read_cr.get_response_field("Parameter.Value") 

2783 

2784 return SharedBucketIdentity( 

2785 name=resolved["name"], 

2786 arn=resolved["arn"], 

2787 region=resolved["region"], 

2788 ) 

2789 

2790 def _grant_cluster_shared_bucket_to_job_role(self, shared: SharedBucketIdentity) -> None: 

2791 """Attach RW + KMS permissions on ``Cluster_Shared_Bucket`` to the job-pod role. 

2792 

2793 Two ``iam.PolicyStatement``s are added to ``self.service_account_role`` 

2794 (the EKS Pod Identity role used by every pod in ``gco-jobs``, 

2795 ``gco-system``, and ``gco-inference``): 

2796 

2797 1. S3 object + bucket-level actions (``GetObject``, ``PutObject``, 

2798 ``DeleteObject``, ``ListBucket``, ``GetBucketLocation``) scoped 

2799 to ``<shared.arn>`` and ``<shared.arn>/*`` — the literal ARN 

2800 resolved from SSM (the bucket's name is CloudFormation-generated, 

2801 so no prefix pattern is involved). 

2802 2. KMS ``Decrypt`` / ``GenerateDataKey`` scoped by the 

2803 ``kms:ViaService=s3.<shared.region>.<AWS::URLSuffix>`` condition — 

2804 ``resources=["*"]`` because the KMS key ARN is not known to this 

2805 stack (it lives in the global region and is referenced indirectly 

2806 through the S3 service). The condition is what actually restricts 

2807 the grant to the cluster-shared bucket's key. 

2808 

2809 Runs unconditionally — the grant is 

2810 present on every regional cluster whether or not analytics is 

2811 enabled. 

2812 """ 

2813 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

2814 

2815 self.service_account_role.add_to_policy( 

2816 iam.PolicyStatement( 

2817 effect=iam.Effect.ALLOW, 

2818 actions=[ 

2819 "s3:GetObject", 

2820 "s3:PutObject", 

2821 "s3:DeleteObject", 

2822 "s3:ListBucket", 

2823 "s3:GetBucketLocation", 

2824 ], 

2825 resources=[shared.arn, f"{shared.arn}/*"], 

2826 ) 

2827 ) 

2828 

2829 self.service_account_role.add_to_policy( 

2830 iam.PolicyStatement( 

2831 effect=iam.Effect.ALLOW, 

2832 actions=["kms:Decrypt", "kms:GenerateDataKey"], 

2833 resources=["*"], 

2834 conditions={ 

2835 "StringEquals": { 

2836 "kms:ViaService": f"s3.{shared.region}.{self.url_suffix}", 

2837 } 

2838 }, 

2839 ) 

2840 ) 

2841 

2842 # The grants contain two necessary wildcard shapes. The S3 bucket ARN 

2843 # uses ``/*`` for object keys within the single resolved shared bucket. 

2844 # KMS uses ``Resource::*`` because the global key ARN is not exported to 

2845 # this stack; ``kms:ViaService`` confines its use to S3 in the bucket's 

2846 # region, while the S3 statements separately scope accessible objects. 

2847 acknowledge_nag_findings( 

2848 self.service_account_role, 

2849 [ 

2850 { 

2851 "id": "AwsSolutions-IAM5", 

2852 "reason": ( 

2853 "The Cluster_Shared_Bucket grants require two wildcard shapes: " 

2854 "an <arn>/* object-key suffix on the single shared bucket resolved " 

2855 "from SSM, and KMS Resource::* because the global key ARN is not " 

2856 "exported. KMS use is constrained by kms:ViaService to S3 in the " 

2857 "bucket's region, and S3 access is separately limited to the " 

2858 "allowed bucket ARNs." 

2859 ), 

2860 "appliesTo": [ 

2861 "Resource::*", 

2862 "Resource::<ReadClusterSharedBucketArn4B0BD291.Parameter.Value>/*", 

2863 ], 

2864 }, 

2865 ], 

2866 ) 

2867 

2868 def _create_mlflow_artifact_role(self, shared: SharedBucketIdentity) -> None: 

2869 """Create the OIDC-only IRSA role MLflow uses for S3 artifact storage. 

2870 

2871 The official mlflow chart creates a ``mlflow`` ServiceAccount in the 

2872 ``monitoring`` namespace (``fullnameOverride`` keeps the bare name); 

2873 the value overrides annotate it with this role's ARN so the tracking 

2874 server exchanges its webhook-injected projected token for 

2875 credentials, which is what feeds the server-side S3 artifact proxy 

2876 (``mlflow.artifactsDestination``). The chart's default 

2877 ``automountServiceAccountToken: false`` does not affect IRSA — the 

2878 EKS pod identity webhook mounts its own token volume. Controller- 

2879 style posture: OIDC-only (``include_pod_identity=False``), trust 

2880 bound to exactly one namespace/service-account pair. 

2881 

2882 Grants are deliberately narrower than the job-pod role's bucket-wide 

2883 grant: object access only under the ``mlflow-artifacts/`` prefix of 

2884 the cluster-shared bucket, ``ListBucket`` condition-scoped to the 

2885 same prefix, and KMS confined by ``kms:ViaService`` exactly like 

2886 ``_grant_cluster_shared_bucket_to_job_role``. 

2887 """ 

2888 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

2889 

2890 self.mlflow_role = GCORegionalStack._create_irsa_role( 

2891 self, 

2892 "MlflowArtifactRole", 

2893 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

2894 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

2895 service_account_names=["mlflow"], 

2896 namespaces=["monitoring"], 

2897 include_pod_identity=False, 

2898 ) 

2899 

2900 self.mlflow_role.add_to_policy( 

2901 iam.PolicyStatement( 

2902 effect=iam.Effect.ALLOW, 

2903 actions=["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], 

2904 resources=[f"{shared.arn}/mlflow-artifacts/*"], 

2905 ) 

2906 ) 

2907 self.mlflow_role.add_to_policy( 

2908 iam.PolicyStatement( 

2909 effect=iam.Effect.ALLOW, 

2910 actions=["s3:ListBucket"], 

2911 resources=[shared.arn], 

2912 conditions={ 

2913 "StringLike": { 

2914 "s3:prefix": "mlflow-artifacts/*", 

2915 } 

2916 }, 

2917 ) 

2918 ) 

2919 # GetBucketLocation cannot share the ListBucket statement: requests 

2920 # for it never carry the s3:prefix key, so the condition above would 

2921 # implicitly deny it. 

2922 self.mlflow_role.add_to_policy( 

2923 iam.PolicyStatement( 

2924 effect=iam.Effect.ALLOW, 

2925 actions=["s3:GetBucketLocation"], 

2926 resources=[shared.arn], 

2927 ) 

2928 ) 

2929 self.mlflow_role.add_to_policy( 

2930 iam.PolicyStatement( 

2931 effect=iam.Effect.ALLOW, 

2932 actions=["kms:Decrypt", "kms:GenerateDataKey"], 

2933 resources=["*"], 

2934 conditions={ 

2935 "StringEquals": { 

2936 "kms:ViaService": f"s3.{shared.region}.{self.url_suffix}", 

2937 } 

2938 }, 

2939 ) 

2940 ) 

2941 

2942 acknowledge_nag_findings( 

2943 self.mlflow_role, 

2944 [ 

2945 { 

2946 "id": "AwsSolutions-IAM5", 

2947 "reason": ( 

2948 "The MLflow artifact grants require two wildcard shapes: a " 

2949 "mlflow-artifacts/* object-key suffix within the single shared " 

2950 "bucket resolved from SSM, and KMS Resource::* because the " 

2951 "global key ARN is not exported to this stack. KMS use is " 

2952 "constrained by kms:ViaService to S3 in the bucket's region, " 

2953 "and S3 access is separately limited to the artifact prefix." 

2954 ), 

2955 "appliesTo": [ 

2956 "Resource::*", 

2957 "Resource::<ReadClusterSharedBucketArn4B0BD291.Parameter.Value>/mlflow-artifacts/*", 

2958 ], 

2959 }, 

2960 ], 

2961 ) 

2962 

2963 def _create_regional_shared_bucket(self) -> None: 

2964 """Create the always-on general-purpose regional bucket for this region. 

2965 

2966 Provisioned unconditionally — there is no ``cdk.json`` toggle and no 

2967 feature flag that can suppress it — in addition to the central buckets 

2968 owned by ``GCOGlobalStack`` (the model bucket and the cluster-shared 

2969 bucket). The bucket is general purpose: any in-region workload may use 

2970 it, and the per-region cold KV tier auto-targets it when cold-tier 

2971 storage is requested. Its existence is independent of any endpoint's 

2972 cold-tier choice. 

2973 

2974 Three constructs are created, mirroring the cluster-shared bucket 

2975 pattern in ``GCOGlobalStack``: 

2976 

2977 1. ``regional_shared_kms_key`` — a customer-managed KMS key with annual 

2978 rotation and a 7-day pending window on destroy. The key policy grants 

2979 the ``s3.amazonaws.com`` and ``logs.<region>.amazonaws.com`` service 

2980 principals encrypt/decrypt so S3 server-side encryption and access-log 

2981 delivery work without role-side grants. 

2982 2. ``regional_shared_access_logs_bucket`` — the dedicated S3 access-logs 

2983 destination for the primary bucket. 

2984 3. ``regional_shared_bucket`` — the primary bucket. Its physical name 

2985 is CloudFormation-generated (``<stack>-regionalsharedbucket…``): 

2986 S3 bucket names are a global namespace and a deleted name is not 

2987 reliably reusable, so a fixed project/account/region name would 

2988 make every destroy-and-redeploy (and the ``retain`` policy below) 

2989 a collision hazard. Consumers never reconstruct it — they read the 

2990 SSM parameters published under 

2991 ``regional_shared_ssm_parameter_prefix(project_name)`` or the 

2992 ``gco-regional-shared-bucket`` ConfigMap. KMS-encrypted with 

2993 ``regional_shared_kms_key``, block-public-access on, SSL enforced, 

2994 versioned, destroy-on-teardown. 

2995 

2996 An explicit ``Deny`` for ``aws:SecureTransport=false`` is added to the 

2997 bucket policy independent of ``enforce_ssl=True`` so the deny is 

2998 verifiable in the synthesized template under a known SID. 

2999 """ 

3000 # Teardown behavior is configurable (cdk.json::regional_shared_bucket. 

3001 # removal_policy) because this bucket holds artifacts jobs just 

3002 # produced: 'destroy' (the default, preserving historical teardown 

3003 # semantics for existing deployments and validation cycles) deletes 

3004 # bucket, logs, and key with the region; 'retain' lets all three 

3005 # outlive a regional destroy so checkpoints survive. Invalid values 

3006 # fail synthesis rather than silently choosing a side. Keep this in 

3007 # sync with the tolerant CLI-side read in cli/storage.py 

3008 # (_regional_shared_removal_policy) that `gco storage s3-inventory` 

3009 # reports through. 

3010 retention_context = self.node.try_get_context("regional_shared_bucket") or {} 

3011 configured_policy = str(retention_context.get("removal_policy", "destroy")).strip().lower() 

3012 if configured_policy not in ("destroy", "retain"): 

3013 raise ValueError( 

3014 "regional_shared_bucket.removal_policy must be 'destroy' or 'retain', " 

3015 f"got {retention_context.get('removal_policy')!r}" 

3016 ) 

3017 retain_regional_shared = configured_policy == "retain" 

3018 regional_shared_removal_policy = ( 

3019 RemovalPolicy.RETAIN if retain_regional_shared else RemovalPolicy.DESTROY 

3020 ) 

3021 

3022 # KMS key for the regional bucket. Matches the cluster-shared key 

3023 # posture: annual rotation, 7-day pending window, destroy-on-teardown 

3024 # by default. Under 'retain' the key survives with the bucket — 

3025 # a retained bucket whose key was scheduled for deletion would be 

3026 # undecryptable, so the two always share a fate. 

3027 self.regional_shared_kms_key = kms.Key( 

3028 self, 

3029 "RegionalSharedKmsKey", 

3030 description=( 

3031 "Customer-managed KMS key for the always-on general-purpose " 

3032 "regional bucket in this region's GCORegionalStack." 

3033 ), 

3034 enable_key_rotation=True, 

3035 pending_window=Duration.days(7), 

3036 removal_policy=regional_shared_removal_policy, 

3037 ) 

3038 

3039 # Key-policy grants for the service principals that encrypt/decrypt on 

3040 # behalf of the bucket (S3 server-side encryption) and the access-logs 

3041 # bucket (CloudWatch/S3 log delivery). 

3042 kms_actions = [ 

3043 "kms:Encrypt", 

3044 "kms:Decrypt", 

3045 "kms:ReEncrypt*", 

3046 "kms:GenerateDataKey*", 

3047 "kms:DescribeKey", 

3048 ] 

3049 

3050 self.regional_shared_kms_key.add_to_resource_policy( 

3051 iam.PolicyStatement( 

3052 sid="AllowS3ServiceEncryptDecrypt", 

3053 effect=iam.Effect.ALLOW, 

3054 principals=[iam.ServicePrincipal("s3.amazonaws.com")], 

3055 actions=kms_actions, 

3056 resources=["*"], 

3057 ) 

3058 ) 

3059 

3060 self.regional_shared_kms_key.add_to_resource_policy( 

3061 iam.PolicyStatement( 

3062 sid="AllowCloudWatchLogsEncryptDecrypt", 

3063 effect=iam.Effect.ALLOW, 

3064 principals=[iam.ServicePrincipal(f"logs.{self.deployment_region}.amazonaws.com")], 

3065 actions=kms_actions, 

3066 resources=["*"], 

3067 ) 

3068 ) 

3069 

3070 # Retention for the access-logs bucket honors the same `s3_access_logs` 

3071 # context field used by the central buckets (default 90 days). 

3072 s3_access_logs_ctx = self.node.try_get_context("s3_access_logs") or {} 

3073 access_logs_retention_days = int(s3_access_logs_ctx.get("retention_days", 90)) 

3074 

3075 # Dedicated access-logs bucket for the regional bucket, encrypted with 

3076 # the regional KMS key (its key policy grants the logs service principal 

3077 # encrypt/decrypt). 

3078 self.regional_shared_access_logs_bucket = s3.Bucket( 

3079 self, 

3080 "RegionalSharedAccessLogsBucket", 

3081 encryption=s3.BucketEncryption.KMS, 

3082 encryption_key=self.regional_shared_kms_key, 

3083 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

3084 enforce_ssl=True, 

3085 versioned=True, 

3086 removal_policy=regional_shared_removal_policy, 

3087 auto_delete_objects=not retain_regional_shared, 

3088 lifecycle_rules=[ 

3089 s3.LifecycleRule( 

3090 id="ExpireAccessLogs", 

3091 enabled=True, 

3092 expiration=Duration.days(access_logs_retention_days), 

3093 ) 

3094 ], 

3095 ) 

3096 

3097 # Primary general-purpose regional bucket. No ``bucket_name``: the 

3098 # physical name is CloudFormation-generated so a destroy-and-redeploy 

3099 # (or a retained bucket from an earlier deployment) can never collide 

3100 # in S3's global namespace; the IAM grants below reference the 

3101 # construct's ARN token and consumers resolve the name from SSM. 

3102 # `bucket_key_enabled=True` mirrors the central-bucket pattern to 

3103 # reduce per-object KMS request costs. 

3104 project_name = self.config.get_project_name() 

3105 regional_shared_prefix = regional_shared_ssm_parameter_prefix(project_name) 

3106 self.regional_shared_bucket = s3.Bucket( 

3107 self, 

3108 "RegionalSharedBucket", 

3109 encryption=s3.BucketEncryption.KMS, 

3110 encryption_key=self.regional_shared_kms_key, 

3111 bucket_key_enabled=True, 

3112 block_public_access=s3.BlockPublicAccess.BLOCK_ALL, 

3113 enforce_ssl=True, 

3114 versioned=True, 

3115 removal_policy=regional_shared_removal_policy, 

3116 auto_delete_objects=not retain_regional_shared, 

3117 server_access_logs_bucket=self.regional_shared_access_logs_bucket, 

3118 server_access_logs_prefix="regional-shared/", 

3119 ) 

3120 

3121 # Explicit Deny for insecure transport. `enforce_ssl=True` already adds 

3122 # an equivalent statement, but duplicating it here makes the deny 

3123 # verifiable in the synthesized template under a known SID. 

3124 self.regional_shared_bucket.add_to_resource_policy( 

3125 iam.PolicyStatement( 

3126 sid="DenyInsecureTransport", 

3127 effect=iam.Effect.DENY, 

3128 principals=[iam.AnyPrincipal()], 

3129 actions=["s3:*"], 

3130 resources=[ 

3131 self.regional_shared_bucket.bucket_arn, 

3132 f"{self.regional_shared_bucket.bucket_arn}/*", 

3133 ], 

3134 conditions={"Bool": {"aws:SecureTransport": "false"}}, 

3135 ) 

3136 ) 

3137 

3138 # Publish the bucket's identity as three SSM parameters in this 

3139 # region's own parameter store, mirroring how the model bucket and 

3140 # cluster-shared bucket publish theirs. In-region workloads and the 

3141 # regional upload surface resolve the always-on regional bucket by 

3142 # reading these back rather than reconstructing the name. Because the 

3143 # bucket is unconditional, these parameters are always present once the 

3144 # region's stack is deployed. The prefix from 

3145 # ``regional_shared_ssm_parameter_prefix(project_name)`` is the single 

3146 # source of truth for the namespace. 

3147 ssm.StringParameter( 

3148 self, 

3149 "RegionalSharedBucketNameParam", 

3150 parameter_name=f"{regional_shared_prefix}/name", 

3151 string_value=self.regional_shared_bucket.bucket_name, 

3152 description="Name of the always-on general-purpose regional bucket for this region.", 

3153 ) 

3154 

3155 ssm.StringParameter( 

3156 self, 

3157 "RegionalSharedBucketArnParam", 

3158 parameter_name=f"{regional_shared_prefix}/arn", 

3159 string_value=self.regional_shared_bucket.bucket_arn, 

3160 description="ARN of the always-on general-purpose regional bucket for this region.", 

3161 ) 

3162 

3163 ssm.StringParameter( 

3164 self, 

3165 "RegionalSharedBucketRegionParam", 

3166 parameter_name=f"{regional_shared_prefix}/region", 

3167 string_value=self.deployment_region, 

3168 description="Home region of the always-on general-purpose regional bucket.", 

3169 ) 

3170 

3171 # CDK-nag suppressions scoped per-resource at the construct site, 

3172 # mirroring the central bucket pattern. Every suppression carries an 

3173 # explicit reason; no blanket bypasses. 

3174 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

3175 

3176 regional_replication_reason = ( 

3177 "The general-purpose regional bucket is a region-local store; " 

3178 "in-region workloads publish to their own region's bucket and there " 

3179 "is no durability requirement that warrants cross-region " 

3180 "replication. Access logs do not require replication for the same " 

3181 "reason." 

3182 ) 

3183 

3184 acknowledge_nag_findings( 

3185 self.regional_shared_bucket, 

3186 [ 

3187 { 

3188 "id": "HIPAA.Security-S3BucketReplicationEnabled", 

3189 "reason": regional_replication_reason, 

3190 }, 

3191 { 

3192 "id": "NIST.800.53.R5-S3BucketReplicationEnabled", 

3193 "reason": regional_replication_reason, 

3194 }, 

3195 { 

3196 "id": "PCI.DSS.321-S3BucketReplicationEnabled", 

3197 "reason": regional_replication_reason, 

3198 }, 

3199 ], 

3200 ) 

3201 

3202 access_logs_is_self_target_reason = ( 

3203 "This is the server access logs destination bucket for the " 

3204 "general-purpose regional bucket." 

3205 ) 

3206 acknowledge_nag_findings( 

3207 self.regional_shared_access_logs_bucket, 

3208 [ 

3209 { 

3210 "id": "AwsSolutions-S1", 

3211 "reason": access_logs_is_self_target_reason, 

3212 }, 

3213 { 

3214 "id": "HIPAA.Security-S3BucketLoggingEnabled", 

3215 "reason": access_logs_is_self_target_reason, 

3216 }, 

3217 { 

3218 "id": "NIST.800.53.R5-S3BucketLoggingEnabled", 

3219 "reason": access_logs_is_self_target_reason, 

3220 }, 

3221 { 

3222 "id": "PCI.DSS.321-S3BucketLoggingEnabled", 

3223 "reason": access_logs_is_self_target_reason, 

3224 }, 

3225 { 

3226 "id": "HIPAA.Security-S3BucketReplicationEnabled", 

3227 "reason": regional_replication_reason, 

3228 }, 

3229 { 

3230 "id": "NIST.800.53.R5-S3BucketReplicationEnabled", 

3231 "reason": regional_replication_reason, 

3232 }, 

3233 { 

3234 "id": "PCI.DSS.321-S3BucketReplicationEnabled", 

3235 "reason": regional_replication_reason, 

3236 }, 

3237 ], 

3238 ) 

3239 

3240 # Grant the in-region pod role read/write on this bucket and use of its 

3241 # KMS key — and nothing else. The grant lives next to the bucket it 

3242 # scopes to, so the role's regional-bucket access stays exactly as wide 

3243 # as this one bucket and its key. 

3244 self._grant_regional_shared_bucket_to_service_account() 

3245 

3246 def _grant_regional_shared_bucket_to_service_account(self) -> None: 

3247 """Attach RW + KMS permissions on the regional bucket to the pod role. 

3248 

3249 Two ``iam.PolicyStatement``s are added to ``self.service_account_role`` 

3250 (the EKS Pod Identity role used by every pod in ``gco-jobs``, 

3251 ``gco-system``, and ``gco-inference``): 

3252 

3253 1. S3 object + bucket-level actions (``GetObject``, ``PutObject``, 

3254 ``DeleteObject``, ``ListBucket``, ``GetBucketLocation``) scoped to 

3255 the literal ``regional_shared_bucket`` ARN and its ``<arn>/*`` 

3256 object-key space — and to no other bucket. 

3257 2. KMS ``Decrypt`` / ``Encrypt`` / ``GenerateDataKey`` / 

3258 ``DescribeKey`` scoped to the literal ``regional_shared_kms_key`` 

3259 ARN — and to no other key. 

3260 

3261 Because both resources are local constructs in this stack, each ARN is 

3262 a concrete reference rather than a wildcard, so the role gains access to 

3263 precisely this bucket and this key. The grant runs unconditionally as 

3264 part of provisioning the always-on regional bucket. 

3265 """ 

3266 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

3267 

3268 self.service_account_role.add_to_policy( 

3269 iam.PolicyStatement( 

3270 effect=iam.Effect.ALLOW, 

3271 actions=[ 

3272 "s3:GetObject", 

3273 "s3:PutObject", 

3274 "s3:DeleteObject", 

3275 "s3:ListBucket", 

3276 "s3:GetBucketLocation", 

3277 ], 

3278 resources=[ 

3279 self.regional_shared_bucket.bucket_arn, 

3280 f"{self.regional_shared_bucket.bucket_arn}/*", 

3281 ], 

3282 ) 

3283 ) 

3284 

3285 self.service_account_role.add_to_policy( 

3286 iam.PolicyStatement( 

3287 effect=iam.Effect.ALLOW, 

3288 actions=[ 

3289 "kms:Decrypt", 

3290 "kms:Encrypt", 

3291 "kms:GenerateDataKey", 

3292 "kms:DescribeKey", 

3293 ], 

3294 resources=[self.regional_shared_kms_key.key_arn], 

3295 ) 

3296 ) 

3297 

3298 # The S3 bucket-ARN resource uses a ``<arn>/*`` object-key wildcard 

3299 # which cdk-nag flags as a wildcard resource. The ARN itself is the 

3300 # literal regional bucket ARN created in this stack — the ``/*`` covers 

3301 # all object keys inside that single bucket, which is the intended 

3302 # semantic for the RW grant. The KMS statement carries no wildcard. 

3303 acknowledge_nag_findings( 

3304 self.service_account_role, 

3305 [ 

3306 { 

3307 "id": "AwsSolutions-IAM5", 

3308 "reason": ( 

3309 "The regional bucket RW grant uses an <arn>/* " 

3310 "object-key wildcard on the literal ARN of the " 

3311 "regional-shared bucket created in this stack " 

3312 "(CloudFormation-generated name). The wildcard covers object " 

3313 "keys within a single bucket — this is the standard " 

3314 "shape for a bucket-scoped RW grant and is what the " 

3315 "allow-list assertion is written against." 

3316 ), 

3317 "appliesTo": [ 

3318 "Resource::<RegionalSharedBucket3FF19783.Arn>/*", 

3319 ], 

3320 }, 

3321 ], 

3322 ) 

3323 

3324 def _cost_report_bucket_parameter_name(self) -> str: 

3325 """SSM parameter (in the monitoring region) publishing the cost bucket name.""" 

3326 return f"{cost_report_ssm_parameter_prefix(self.config.get_project_name())}/name" 

3327 

3328 def _grant_cost_report_bucket_discovery_to_cost_monitor(self) -> None: 

3329 """Let the cost-monitor role resolve the cost report bucket it writes to. 

3330 

3331 The bucket lives in ``GCOMonitoringStack`` in the monitoring region, 

3332 which deploys *after* every regional stack, and it carries a 

3333 CloudFormation-generated physical name — S3 bucket names are a global 

3334 namespace and a deleted name is not reliably reusable, so nothing 

3335 reconstructs it from project/account/region any more. That inverts 

3336 the old grant direction: 

3337 

3338 1. The monitoring stack publishes the bucket's identity at 

3339 ``<cost_report_ssm_parameter_prefix>/{name,arn,region}`` and grants 

3340 every regional cost-monitor role S3 object/bucket actions through 

3341 the bucket policy and KMS use through the key policy — principal 

3342 based, so this stack never needs the bucket or key ARN. 

3343 2. This stack grants the role exactly one permission: 

3344 ``ssm:GetParameter`` on the ``/name`` parameter, by literal ARN. The 

3345 service reads it at runtime (``COST_REPORT_BUCKET_PARAMETER`` / 

3346 ``COST_REPORT_BUCKET_PARAMETER_REGION`` in ``34-cost-monitor.yaml``). 

3347 

3348 On a fresh ``deploy-all`` the parameter appears only once monitoring is 

3349 deployed; the service re-resolves on every scheduled pass, so the 

3350 pipeline self-heals without ordering hacks. No wildcard remains on the 

3351 role, so no cdk-nag acknowledgement is needed here. 

3352 """ 

3353 monitoring_region = self.config.get_monitoring_region() 

3354 parameter_name = self._cost_report_bucket_parameter_name() 

3355 self.cost_monitor_role.add_to_policy( 

3356 iam.PolicyStatement( 

3357 effect=iam.Effect.ALLOW, 

3358 actions=["ssm:GetParameter"], 

3359 resources=[ 

3360 f"arn:{self.partition}:ssm:{monitoring_region}:{self.account}:" 

3361 f"parameter{parameter_name}" 

3362 ], 

3363 ) 

3364 ) 

3365 

3366 def _create_kubectl_lambda(self) -> None: 

3367 """Create Lambda function to apply Kubernetes manifests using Python client. 

3368 

3369 Note: This creates the Lambda and provider but does NOT create the custom resource. 

3370 The custom resource is created in _apply_kubernetes_manifests() after ALB is created, 

3371 so that target group ARNs can be passed to the manifests. 

3372 """ 

3373 project_name = self.config.get_project_name() 

3374 

3375 # Create IAM role for kubectl Lambda 

3376 kubectl_lambda_role = iam.Role( 

3377 self, 

3378 "KubectlLambdaRole", 

3379 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 

3380 managed_policies=[ 

3381 iam.ManagedPolicy.from_aws_managed_policy_name( 

3382 "service-role/AWSLambdaVPCAccessExecutionRole" 

3383 ), 

3384 iam.ManagedPolicy.from_aws_managed_policy_name( 

3385 "service-role/AWSLambdaBasicExecutionRole" 

3386 ), 

3387 ], 

3388 ) 

3389 

3390 # Add EKS permissions 

3391 kubectl_lambda_role.add_to_policy( 

3392 iam.PolicyStatement( 

3393 actions=[ 

3394 "eks:DescribeCluster", 

3395 "eks:ListClusters", 

3396 ], 

3397 resources=[self.cluster.cluster_arn], 

3398 ) 

3399 ) 

3400 

3401 # Add permissions to assume cluster admin role 

3402 kubectl_lambda_role.add_to_policy( 

3403 iam.PolicyStatement(actions=["sts:AssumeRole"], resources=["*"]) 

3404 ) 

3405 

3406 # Allow the convergence apply tasks to record per-phase status to SSM 

3407 # (base-manifests / post-helm-manifests), mirroring the helm worker, so 

3408 # `gco stacks addons status` surfaces the apply passes alongside charts. 

3409 kubectl_lambda_role.add_to_policy( 

3410 iam.PolicyStatement( 

3411 actions=["ssm:PutParameter"], 

3412 resources=[ 

3413 f"arn:{self.partition}:ssm:{self.deployment_region}:{self.account}:" 

3414 f"parameter/{project_name}/addons/*" 

3415 ], 

3416 ) 

3417 ) 

3418 

3419 # Create security group for kubectl Lambda 

3420 kubectl_lambda_sg = ec2.SecurityGroup( 

3421 self, 

3422 "KubectlLambdaSG", 

3423 vpc=self.vpc, 

3424 description="Security group for kubectl Lambda to access EKS cluster", 

3425 security_group_name=f"{self.config.get_project_name()}-kubectl-lambda-sg-{self.deployment_region}", 

3426 allow_all_outbound=True, # Lambda needs outbound access to EKS API 

3427 ) 

3428 

3429 # Allow Lambda security group to access EKS cluster security group on port 443 

3430 # The EKS cluster security group is automatically created by EKS 

3431 self.cluster.cluster_security_group.add_ingress_rule( 

3432 peer=kubectl_lambda_sg, 

3433 connection=ec2.Port.tcp(443), 

3434 description="Allow kubectl Lambda to access EKS API", 

3435 ) 

3436 

3437 # Create Lambda function (Python-only, no Docker!) 

3438 # Store function name as string attribute for cross-stack references 

3439 # This avoids CDK cross-environment resolution issues when account is unresolved 

3440 self.kubectl_lambda_function_name = f"{project_name}-kubectl-{self.deployment_region}" 

3441 self.kubectl_lambda = lambda_.Function( 

3442 self, 

3443 "KubectlApplierFunction", 

3444 function_name=self.kubectl_lambda_function_name, 

3445 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

3446 handler="handler.lambda_handler", 

3447 code=lambda_.Code.from_asset("lambda/kubectl-applier-simple-build"), 

3448 timeout=Duration.minutes(15), # Max Lambda timeout 

3449 memory_size=512, 

3450 role=kubectl_lambda_role, 

3451 vpc=self.vpc, 

3452 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS), 

3453 security_groups=[kubectl_lambda_sg], # Use the security group we created 

3454 environment={ 

3455 "CLUSTER_NAME": self.cluster.cluster_name, 

3456 "REGION": self.deployment_region, 

3457 # Lets the convergence apply tasks record per-phase status to 

3458 # SSM (/<project>/addons/<region>/{base,post-helm}-manifests). 

3459 "PROJECT_NAME": project_name, 

3460 }, 

3461 tracing=lambda_.Tracing.ACTIVE, 

3462 ) 

3463 

3464 # Add EKS access entry for the Lambda role to authenticate with the cluster 

3465 # This grants the Lambda role cluster admin permissions 

3466 self.kubectl_lambda_access_entry = eks.AccessEntry( 

3467 self, 

3468 "KubectlLambdaAccessEntry", 

3469 cluster=self.cluster, # type: ignore[arg-type] 

3470 principal=kubectl_lambda_role.role_arn, 

3471 access_policies=[ 

3472 eks.AccessPolicy.from_access_policy_name( 

3473 "AmazonEKSClusterAdminPolicy", access_scope_type=eks.AccessScopeType.CLUSTER 

3474 ) 

3475 ], 

3476 ) 

3477 

3478 # No custom-resource provider needed: the kubectl-applier Lambda is now 

3479 # invoked directly by the convergence state machine (the base and 

3480 # post-Helm apply tasks), not through a CloudFormation custom resource. 

3481 

3482 # cdk-nag suppression: the kubectl-applier Lambda requires broad 

3483 # EKS and Kubernetes API access to apply arbitrary manifests. 

3484 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

3485 

3486 acknowledge_nag_findings( 

3487 kubectl_lambda_role, 

3488 [ 

3489 { 

3490 "id": "AwsSolutions-IAM5", 

3491 "reason": ( 

3492 "The kubectl-applier Lambda requires broad EKS and Kubernetes API " 

3493 "access to apply arbitrary manifests (RBAC, ServiceAccounts, " 

3494 "Deployments, Jobs, NetworkPolicies) across multiple namespaces. " 

3495 "Resource: * is required because the set of Kubernetes resources " 

3496 "is dynamic and not known at synth time." 

3497 ), 

3498 "appliesTo": ["Resource::*"], 

3499 }, 

3500 ], 

3501 ) 

3502 

3503 def _apply_kubernetes_manifests(self) -> None: 

3504 """Build the complete base/Helm/post-Helm convergence pipeline. 

3505 

3506 This is called after the Gateway certificate and shared storage exist. 

3507 The post-Helm pass creates the internal ALB through Gateway API only 

3508 after the mandatory AWS Load Balancer Controller is installed. 

3509 """ 

3510 

3511 # Build image replacements dict 

3512 # Include one deployment token to force pod rollouts and bind live 

3513 # validation to this exact asynchronous convergence execution. 

3514 deployment_timestamp = _deployment_timestamp() 

3515 self.addon_deployment_token = deployment_timestamp 

3516 

3517 # Get resource thresholds from config 

3518 thresholds = self.config.get_resource_thresholds() 

3519 

3520 # Get manifest processor resource quotas. 

3521 # Resource quotas and the security/image policy now live under the 

3522 # shared job_validation_policy section because both the REST 

3523 # manifest_processor and the SQS queue_processor read them. Service- 

3524 # specific knobs (replicas, validation_enabled, max_request_body_bytes, 

3525 # etc.) stay under manifest_processor. Inference TLS proxy CPU and HPA 

3526 # settings live in their own optional block. 

3527 mp_config = self.config.get_manifest_processor_config() 

3528 inference_proxy_config = self.config.get_inference_proxy_config() 

3529 job_policy = self.node.try_get_context("job_validation_policy") or {} 

3530 job_quotas = _validated_manifest_caps( 

3531 job_policy.get("resource_quotas", {}), 

3532 _validated_resource_quota(self.node.try_get_context("resource_quota") or {}), 

3533 ) 

3534 allowed_kinds = job_policy.get( 

3535 "allowed_kinds", 

3536 # Fallback mirrors manifest_processor.DEFAULT_ALLOWED_KINDS (kept 

3537 # inline so CDK synth never imports service modules; lockstep is 

3538 # pinned by tests/test_manifest_processor_extended.py::TestAllowedKindsLockstep). 

3539 [ 

3540 "Job", 

3541 "CronJob", 

3542 "Deployment", 

3543 "StatefulSet", 

3544 "DaemonSet", 

3545 "Service", 

3546 "ConfigMap", 

3547 "Pod", 

3548 "TrainJob", 

3549 ], 

3550 ) 

3551 

3552 security_policy = validate_manifest_security_policy( 

3553 job_policy.get("manifest_security_policy", {}) 

3554 ) 

3555 

3556 def _policy_str(value: object) -> str: 

3557 if type(value) is not bool: 

3558 raise ValueError("job validation policy values must be booleans") 

3559 return "true" if value else "false" 

3560 

3561 require_accelerator_toleration = _policy_str( 

3562 job_policy.get("require_accelerator_toleration", True) 

3563 ) 

3564 validation_enabled = _policy_str(mp_config.get("validation_enabled", True)) 

3565 

3566 image_replacements = { 

3567 "{{BACKEND_TLS_CERTIFICATE_ARN}}": self.backend_tls_certificate_arn, 

3568 "{{HEALTH_MONITOR_IMAGE}}": self.health_monitor_image.image_uri, 

3569 "{{MANIFEST_PROCESSOR_IMAGE}}": self.manifest_processor_image.image_uri, 

3570 "{{INFERENCE_PROXY_IMAGE}}": self.inference_proxy_image.image_uri, 

3571 "{{INFERENCE_MONITOR_IMAGE}}": self.inference_monitor_image.image_uri, 

3572 # External, pinned upstream image for the shared Mooncake master 

3573 # (bundles the mooncake_master binary). Same default as disaggregated 

3574 # role pods; per-endpoint spec.mooncake.store.master_image overrides. 

3575 "{{MOONCAKE_MASTER_IMAGE}}": MOONCAKE_MASTER_DEFAULT_IMAGE, 

3576 "{{CLUSTER_NAME}}": self.cluster.cluster_name, 

3577 "{{REGION}}": self.deployment_region, 

3578 "{{AUTH_SECRET_ARN}}": self.auth_secret_arn, 

3579 "{{SERVICE_ACCOUNT_ROLE_ARN}}": self.service_account_role.role_arn, 

3580 "{{MANIFEST_PROCESSOR_ROLE_ARN}}": self.manifest_processor_role.role_arn, 

3581 "{{INFERENCE_PROXY_ROLE_ARN}}": self.inference_proxy_role.role_arn, 

3582 "{{HEALTH_MONITOR_ROLE_ARN}}": self.health_monitor_role.role_arn, 

3583 "{{EFS_FILE_SYSTEM_ID}}": self.efs_file_system.file_system_id, 

3584 "{{EFS_ACCESS_POINT_ID}}": self.efs_access_point.access_point_id, 

3585 "{{JOB_QUEUE_URL}}": self.job_queue.queue_url, 

3586 "{{JOB_QUEUE_ARN}}": self.job_queue.queue_arn, 

3587 "{{DEPLOYMENT_TIMESTAMP}}": deployment_timestamp, 

3588 # Resource thresholds 

3589 "{{CPU_THRESHOLD}}": str(thresholds.cpu_threshold), 

3590 "{{MEMORY_THRESHOLD}}": str(thresholds.memory_threshold), 

3591 "{{GPU_THRESHOLD}}": str(thresholds.gpu_threshold), 

3592 "{{PENDING_PODS_THRESHOLD}}": str(thresholds.pending_pods_threshold), 

3593 "{{PENDING_REQUESTED_CPU_VCPUS}}": str(thresholds.pending_requested_cpu_vcpus), 

3594 "{{PENDING_REQUESTED_MEMORY_GB}}": str(thresholds.pending_requested_memory_gb), 

3595 "{{PENDING_REQUESTED_GPUS}}": str(thresholds.pending_requested_gpus), 

3596 # Deployment prefix (#139). Injected so in-cluster services (the 

3597 # inference monitor) resolve project-scoped SSM paths 

3598 # (/<project>/regional-shared-bucket/*) instead of a hardcoded 

3599 # /gco/ namespace, letting two deployments share an account+region. 

3600 "{{PROJECT_NAME}}": self.config.get_project_name(), 

3601 # DynamoDB table names (from global stack) 

3602 "{{TEMPLATES_TABLE_NAME}}": f"{self.config.get_project_name()}-job-templates", 

3603 "{{WEBHOOKS_TABLE_NAME}}": f"{self.config.get_project_name()}-webhooks", 

3604 "{{JOBS_TABLE_NAME}}": f"{self.config.get_project_name()}-jobs", 

3605 "{{INFERENCE_ENDPOINTS_TABLE_NAME}}": ( 

3606 f"{self.config.get_project_name()}-inference-endpoints" 

3607 ), 

3608 # DynamoDB region (global stack region, may differ from cluster region) 

3609 "{{DYNAMODB_REGION}}": self.config.get_global_region(), 

3610 # Global region for cross-region SSM reads/writes (e.g. the health 

3611 # monitor's /<project>/alb-hostname-<region> sync in the global region). 

3612 "{{GLOBAL_REGION}}": self.config.get_global_region(), 

3613 # Manifest processor resource quotas (sourced from shared policy). 

3614 "{{MP_MAX_CPU_PER_MANIFEST}}": job_quotas["max_cpu_per_manifest"], 

3615 "{{MP_MAX_MEMORY_PER_MANIFEST}}": job_quotas["max_memory_per_manifest"], 

3616 "{{MP_MAX_GPU_PER_MANIFEST}}": job_quotas["max_gpu_per_manifest"], 

3617 # Require accelerator (GPU/Neuron/EFA) jobs to carry a matching 

3618 # toleration (shared policy). Mirrored on the SQS path via 

3619 # {{QP_REQUIRE_ACCELERATOR_TOLERATION}} so neither path is a bypass. 

3620 "{{MP_REQUIRE_ACCELERATOR_TOLERATION}}": require_accelerator_toleration, 

3621 # Manifest processor namespace allowlist (sourced from shared policy). 

3622 # Both the REST manifest processor and the SQS queue processor 

3623 # read from job_validation_policy.allowed_namespaces so a single 

3624 # edit takes effect on both submission paths at the next deploy. 

3625 "{{MP_ALLOWED_NAMESPACES}}": ",".join( 

3626 job_policy.get("allowed_namespaces", ["gco-jobs"]) 

3627 ), 

3628 # Manifest processor Kubernetes resource kind allowlist (shared policy). 

3629 "{{MP_ALLOWED_KINDS}}": ",".join(allowed_kinds), 

3630 # Manifest processor image registry allowlist (sourced from shared 

3631 # policy). Augmented with the project's own ECR registry hostnames 

3632 # so jobs built via ``gco images build`` aren't rejected by the 

3633 # REST submission path. Identical augmentation runs on the SQS 

3634 # path below — see ``{{QP_TRUSTED_REGISTRIES}}``. 

3635 "{{MP_TRUSTED_REGISTRIES}}": ",".join( 

3636 _augment_trusted_registries_with_project_ecr( 

3637 job_policy.get("trusted_registries", []), 

3638 account=self.account, 

3639 regions=self.config.get_regions(), 

3640 global_region=self.config.get_global_region(), 

3641 url_suffix=self.url_suffix, 

3642 ) 

3643 ), 

3644 "{{MP_TRUSTED_DOCKERHUB_ORGS}}": ",".join(job_policy.get("trusted_dockerhub_orgs", [])), 

3645 # Manifest parsing and pod-security policy use the same values as 

3646 # the queue processor, preventing REST/SQS validation drift. 

3647 "{{MP_VALIDATION_ENABLED}}": validation_enabled, 

3648 "{{MP_YAML_MAX_DEPTH}}": str(mp_config.get("yaml_max_depth", 50)), 

3649 "{{MP_BLOCK_PRIVILEGED}}": _policy_str(security_policy["block_privileged"]), 

3650 "{{MP_BLOCK_PRIVILEGE_ESCALATION}}": _policy_str( 

3651 security_policy["block_privilege_escalation"] 

3652 ), 

3653 "{{MP_BLOCK_HOST_NETWORK}}": _policy_str(security_policy["block_host_network"]), 

3654 "{{MP_BLOCK_HOST_PID}}": _policy_str(security_policy["block_host_pid"]), 

3655 "{{MP_BLOCK_HOST_IPC}}": _policy_str(security_policy["block_host_ipc"]), 

3656 "{{MP_BLOCK_HOST_PATH}}": _policy_str(security_policy["block_host_path"]), 

3657 "{{MP_BLOCK_ADDED_CAPABILITIES}}": _policy_str( 

3658 security_policy["block_added_capabilities"] 

3659 ), 

3660 "{{MP_BLOCK_RUN_AS_ROOT}}": _policy_str(security_policy["block_run_as_root"]), 

3661 # Manifest processor request body size cap (HTTP 413 middleware). 

3662 # Lives at cdk.json::manifest_processor.max_request_body_bytes. 

3663 "{{MP_MAX_REQUEST_BODY_BYTES}}": str( 

3664 mp_config.get("max_request_body_bytes", 1_048_576) 

3665 ), 

3666 # Inference request bodies use the same operator-configured cap, 

3667 # but retain a service-specific placeholder for future tuning. 

3668 "{{INFERENCE_PROXY_MAX_REQUEST_BODY_BYTES}}": str( 

3669 mp_config.get("max_request_body_bytes", 1_048_576) 

3670 ), 

3671 # Shared pure renderer keeps production and Kind typed values in 

3672 # lockstep (Quantity string for request, YAML integers for the HPA 

3673 # target and the min/max replica bounds). 

3674 **_compute_inference_proxy_tls_replacements(inference_proxy_config), 

3675 # Manifest-processor sizing is an operator decision made in 

3676 # cdk.json (validated in ConfigLoader): the fixed replica count and 

3677 # the application container's limits render verbatim. The optional 

3678 # CPU HPA (35-manifest-processor-hpa.yaml) is gated by 

3679 # {{MP_HPA_ENABLED}} below; when it is on, the Deployment carries 

3680 # gco.aws/hpa-controls-replicas="true" so the applier stops 

3681 # re-asserting replicas and the HPA's scale value survives. 

3682 "{{MP_REPLICAS}}": str(mp_config["replicas"]), 

3683 "{{MP_CPU_LIMIT}}": str(mp_config["resource_limits"]["cpu"]), 

3684 "{{MP_MEMORY_LIMIT}}": str(mp_config["resource_limits"]["memory"]), 

3685 "{{MP_HPA_CONTROLS_REPLICAS}}": ( 

3686 "true" if mp_config["autoscaling"]["enabled"] else "false" 

3687 ), 

3688 # Regional worker for the DynamoDB-backed global queue. Multiple API 

3689 # replicas are safe because JobStore claims are conditional and 

3690 # lease-backed; each replica also reconciles K8s status transitions. 

3691 "{{CENTRAL_QUEUE_WORKER_ENABLED}}": ( 

3692 "true" if mp_config.get("central_queue_worker_enabled", True) else "false" 

3693 ), 

3694 "{{CENTRAL_QUEUE_POLL_INTERVAL_SECONDS}}": str( 

3695 mp_config.get("central_queue_poll_interval_seconds", 10) 

3696 ), 

3697 "{{CENTRAL_QUEUE_BATCH_SIZE}}": str(mp_config.get("central_queue_batch_size", 5)), 

3698 "{{CENTRAL_QUEUE_RECONCILE_LIMIT}}": str( 

3699 mp_config.get("central_queue_reconcile_limit", 100) 

3700 ), 

3701 "{{CENTRAL_QUEUE_LEASE_SECONDS}}": str( 

3702 mp_config.get("central_queue_lease_seconds", 300) 

3703 ), 

3704 "{{CENTRAL_QUEUE_LEASE_RENEWAL_SECONDS}}": str( 

3705 mp_config.get("central_queue_lease_renewal_seconds", 60) 

3706 ), 

3707 "{{QUEUE_TARGET_REGIONS}}": ",".join(self.config.get_regions()), 

3708 } 

3709 

3710 # Always-on Cluster_Shared_Bucket replacements. Populated from the 

3711 # SharedBucketIdentity resolved in __init__ via cross-region SSM 

3712 # read from GCOGlobalStack. Never gated on the analytics toggle — 

3713 # the gco-cluster-shared-bucket ConfigMap is applied to every 

3714 # regional cluster. 

3715 image_replacements.update( 

3716 _compute_kubectl_cluster_shared_replacements(self.cluster_shared_identity) 

3717 ) 

3718 

3719 # Always-on Regional_Shared_Bucket replacements. Read straight off the 

3720 # local constructs created by _create_regional_shared_bucket (which 

3721 # runs earlier in __init__, before _apply_kubernetes_manifests), so no 

3722 # SSM round-trip is needed — unlike the cluster-shared bucket, this one 

3723 # is owned by this stack. Never gated on a toggle: the bucket is 

3724 # unconditional, so the gco-regional-shared-bucket ConfigMap is applied 

3725 # to every regional cluster. 

3726 image_replacements.update( 

3727 _compute_kubectl_regional_shared_replacements( 

3728 name=self.regional_shared_bucket.bucket_name, 

3729 arn=self.regional_shared_bucket.bucket_arn, 

3730 region=self.deployment_region, 

3731 ) 

3732 ) 

3733 

3734 # Cluster observability (on by default): gate the gp3 StorageClass and 

3735 # the ServiceMonitors/dashboards on the toggle. When enabled the gating 

3736 # placeholders resolve so those manifests apply; when disabled the keys 

3737 # are absent, so the manifests keep an unreplaced placeholder and the 

3738 # applier skips them (same mechanism FSx/Valkey use). 

3739 _obs_config = self.config.get_cluster_observability_config() 

3740 image_replacements.update( 

3741 _compute_kubectl_observability_replacements( 

3742 bool(_obs_config["enabled"]), 

3743 grafana_admin_password_rotation_schedule=str( 

3744 _obs_config["grafana"]["admin_password_rotation_schedule"] 

3745 ), 

3746 ) 

3747 ) 

3748 # Scheduler gates: resolve through the same enablement helper that 

3749 # selects the Helm charts, so the default Kueue queue topology and the 

3750 # Slinky Slurm NetworkPolicies apply exactly when their scheduler does. 

3751 _helm_config = self.node.try_get_context("helm") or {} 

3752 _helm_overrides = _parse_helm_enabled_overrides( 

3753 self.node.try_get_context(_HELM_OVERRIDE_CONTEXT_KEY) 

3754 ) 

3755 image_replacements.update( 

3756 _compute_kubectl_scheduler_replacements( 

3757 kueue_enabled=_helm_chart_enabled(_helm_config, _helm_overrides, "kueue"), 

3758 slurm_enabled=_helm_chart_enabled(_helm_config, _helm_overrides, "slurm"), 

3759 kubeflow_trainer_enabled=_helm_chart_enabled( 

3760 _helm_config, _helm_overrides, "kubeflow_trainer" 

3761 ), 

3762 ) 

3763 ) 

3764 

3765 # Cost monitoring (on by default): gate the cost-monitor Deployment 

3766 # and the Grafana cost dashboard on the toggle via the same 

3767 # unreplaced-placeholder mechanism. The image/role placeholders exist 

3768 # only when the pipeline is active, so a disabled deployment leaves 

3769 # 34-cost-monitor.yaml and the cost dashboard unapplied. 

3770 if self._cost_monitoring_active(): 

3771 _cost_config = self.config.get_cost_monitoring_config() 

3772 image_replacements.update( 

3773 { 

3774 "{{COST_MONITORING_ENABLED}}": "true", 

3775 "{{COST_MONITOR_IMAGE}}": self.cost_monitor_image.image_uri, 

3776 "{{COST_MONITOR_ROLE_ARN}}": self.cost_monitor_role.role_arn, 

3777 # The bucket's CloudFormation-generated name is published by 

3778 # the monitoring stack (deployed after this one); the service 

3779 # resolves it from SSM at runtime instead of receiving a 

3780 # reconstructed name here. 

3781 "{{COST_REPORT_BUCKET_PARAMETER}}": self._cost_report_bucket_parameter_name(), 

3782 "{{COST_REPORT_BUCKET_PARAMETER_REGION}}": self.config.get_monitoring_region(), 

3783 "{{COST_REPORT_INTERVAL_MINUTES}}": str( 

3784 _cost_config["reports"]["interval_minutes"] 

3785 ), 

3786 } 

3787 ) 

3788 

3789 # Optional manifest-processor CPU autoscaler. When disabled the key is 

3790 # absent, 35-manifest-processor-hpa.yaml keeps an unreplaced placeholder, 

3791 # the applier skips it and prunes any HPA a previous deploy created. 

3792 if mp_config["autoscaling"]["enabled"]: 

3793 image_replacements.update( 

3794 { 

3795 "{{MP_HPA_ENABLED}}": "true", 

3796 "{{MP_HPA_MAX_REPLICAS}}": str(mp_config["autoscaling"]["max_replicas"]), 

3797 "{{MP_HPA_CPU_TARGET_UTILIZATION}}": str( 

3798 mp_config["autoscaling"]["cpu_target_utilization_percentage"] 

3799 ), 

3800 } 

3801 ) 

3802 

3803 # MLflow (on by default, requires observability): gate the client 

3804 # egress NetworkPolicy (post-helm-mlflow-network.yaml) on the toggle 

3805 # via the same unreplaced-placeholder mechanism; a disabled 

3806 # deployment leaves the file unapplied and the applier prunes both 

3807 # the policy and the chart-managed metadata claim helm uninstall 

3808 # leaves behind (metadata is discarded, artifacts stay in S3). 

3809 if self._mlflow_active(): 

3810 image_replacements.update({"{{MLFLOW_ENABLED}}": "true"}) 

3811 

3812 # Add queue processor replacements if enabled 

3813 qp_config = self.node.try_get_context("queue_processor") or {} 

3814 

3815 # In-VPC ranges job pods may reach on any port (03-network-policies.yaml 

3816 # allow-vpc-egress) and the MLflow server admits probes from 

3817 # (post-helm-mlflow-network.yaml). Generates a YAML block of ipBlock 

3818 # entries from the vpc_endpoint_cidrs array. The placeholder 

3819 # {{VPC_ENDPOINT_CIDR_BLOCKS}} sits at 8-space indentation in the 

3820 # manifest, so the first entry needs no leading indent (the manifest 

3821 # provides it) and subsequent entries are indented to align. 

3822 vpc_endpoint_cidrs = self.node.try_get_context("vpc_endpoint_cidrs") or ["10.0.0.0/16"] 

3823 cidr_lines = [] 

3824 for i, cidr in enumerate(vpc_endpoint_cidrs): 

3825 prefix = "" if i == 0 else " " 

3826 cidr_lines.append(f'{prefix}- ipBlock:\n cidr: "{cidr}"') 

3827 image_replacements["{{VPC_ENDPOINT_CIDR_BLOCKS}}"] = "\n".join(cidr_lines) 

3828 

3829 # NetworkPolicy enforcement on EKS Auto Mode is a ConfigMap-driven 

3830 # switch (06-network-policy-controller.yaml); render the operator's 

3831 # choice as the literal the controller reads. 

3832 image_replacements["{{NETWORK_POLICY_ENFORCEMENT}}"] = ( 

3833 "true" 

3834 if self.config.get_eks_cluster_config().get("network_policy_enforcement", True) 

3835 else "false" 

3836 ) 

3837 

3838 # Resource governance for gco-jobs namespace: ResourceQuota caps aggregate 

3839 # resource consumption across the namespace, LimitRange caps per-container 

3840 # maxima. Values come from cdk.json `resource_quota` context merged 

3841 # over gco.stacks.constants.DEFAULT_RESOURCE_QUOTA (per-container 

3842 # maxima sized to one full accelerator-node slice) and validated at 

3843 # synth: every value must parse as a Kubernetes quantity and the 

3844 # container maxima must fit inside the namespace ceilings. 

3845 resource_quota = _validated_resource_quota( 

3846 self.node.try_get_context("resource_quota") or {} 

3847 ) 

3848 image_replacements["{{QUOTA_MAX_CPU}}"] = resource_quota["max_cpu"] 

3849 image_replacements["{{QUOTA_MAX_MEMORY}}"] = resource_quota["max_memory"] 

3850 image_replacements["{{QUOTA_MAX_GPU}}"] = resource_quota["max_gpu"] 

3851 image_replacements["{{QUOTA_MAX_PODS}}"] = resource_quota["max_pods"] 

3852 image_replacements["{{LIMIT_MAX_CPU}}"] = resource_quota["container_max_cpu"] 

3853 image_replacements["{{LIMIT_MAX_MEMORY}}"] = resource_quota["container_max_memory"] 

3854 image_replacements["{{LIMIT_MAX_GPU}}"] = resource_quota["container_max_gpu"] 

3855 

3856 if self.queue_processor_enabled: 

3857 image_replacements["{{QUEUE_PROCESSOR_IMAGE}}"] = self.queue_processor_image.image_uri 

3858 image_replacements["{{QP_POLLING_INTERVAL}}"] = str( 

3859 qp_config.get("polling_interval", 10) 

3860 ) 

3861 image_replacements["{{QP_MAX_CONCURRENT_JOBS}}"] = str( 

3862 qp_config.get("max_concurrent_jobs", 10) 

3863 ) 

3864 image_replacements["{{QP_MESSAGES_PER_JOB}}"] = str( 

3865 qp_config.get("messages_per_job", 1) 

3866 ) 

3867 image_replacements["{{QP_SUCCESSFUL_JOBS_HISTORY}}"] = str( 

3868 qp_config.get("successful_jobs_history", 20) 

3869 ) 

3870 image_replacements["{{QP_FAILED_JOBS_HISTORY}}"] = str( 

3871 qp_config.get("failed_jobs_history", 10) 

3872 ) 

3873 image_replacements["{{QP_ALLOWED_NAMESPACES}}"] = ",".join( 

3874 job_policy.get("allowed_namespaces", ["gco-jobs"]) 

3875 ) 

3876 image_replacements["{{QP_ALLOWED_KINDS}}"] = ",".join(allowed_kinds) 

3877 # Resource caps, image allowlist, and security policy are shared 

3878 # with the REST manifest processor. Source them from the 

3879 # job_validation_policy section so a single change in cdk.json 

3880 # takes effect on both submission paths at the next deploy. 

3881 image_replacements["{{QP_MAX_GPU_PER_MANIFEST}}"] = job_quotas["max_gpu_per_manifest"] 

3882 image_replacements["{{QP_MAX_CPU_PER_MANIFEST}}"] = job_quotas["max_cpu_per_manifest"] 

3883 image_replacements["{{QP_MAX_MEMORY_PER_MANIFEST}}"] = job_quotas[ 

3884 "max_memory_per_manifest" 

3885 ] 

3886 image_replacements["{{QP_TRUSTED_REGISTRIES}}"] = ",".join( 

3887 _augment_trusted_registries_with_project_ecr( 

3888 job_policy.get("trusted_registries", []), 

3889 account=self.account, 

3890 regions=self.config.get_regions(), 

3891 global_region=self.config.get_global_region(), 

3892 url_suffix=self.url_suffix, 

3893 ) 

3894 ) 

3895 image_replacements["{{QP_TRUSTED_DOCKERHUB_ORGS}}"] = ",".join( 

3896 job_policy.get("trusted_dockerhub_orgs", []) 

3897 ) 

3898 

3899 # Security policy toggles — shared with the REST manifest_processor. 

3900 # Both services read the same cdk.json section so a single policy 

3901 # flip (e.g. block_run_as_root: true) takes effect on both paths. 

3902 image_replacements["{{QP_BLOCK_PRIVILEGED}}"] = _policy_str( 

3903 security_policy["block_privileged"] 

3904 ) 

3905 image_replacements["{{QP_BLOCK_PRIVILEGE_ESCALATION}}"] = _policy_str( 

3906 security_policy["block_privilege_escalation"] 

3907 ) 

3908 image_replacements["{{QP_BLOCK_HOST_NETWORK}}"] = _policy_str( 

3909 security_policy["block_host_network"] 

3910 ) 

3911 image_replacements["{{QP_BLOCK_HOST_PID}}"] = _policy_str( 

3912 security_policy["block_host_pid"] 

3913 ) 

3914 image_replacements["{{QP_BLOCK_HOST_IPC}}"] = _policy_str( 

3915 security_policy["block_host_ipc"] 

3916 ) 

3917 image_replacements["{{QP_BLOCK_HOST_PATH}}"] = _policy_str( 

3918 security_policy["block_host_path"] 

3919 ) 

3920 image_replacements["{{QP_BLOCK_ADDED_CAPABILITIES}}"] = _policy_str( 

3921 security_policy["block_added_capabilities"] 

3922 ) 

3923 image_replacements["{{QP_BLOCK_RUN_AS_ROOT}}"] = _policy_str( 

3924 security_policy["block_run_as_root"] 

3925 ) 

3926 # Require accelerator (GPU/Neuron/EFA) jobs to carry a matching 

3927 # toleration — shared with the REST manifest_processor via 

3928 # {{MP_REQUIRE_ACCELERATOR_TOLERATION}}. 

3929 image_replacements["{{QP_REQUIRE_ACCELERATOR_TOLERATION}}"] = ( 

3930 require_accelerator_toleration 

3931 ) 

3932 

3933 # Add Valkey endpoint if enabled 

3934 if hasattr(self, "valkey_cache") and self.valkey_cache: 

3935 image_replacements["{{VALKEY_ENDPOINT}}"] = self.valkey_cache.attr_endpoint_address 

3936 image_replacements["{{VALKEY_PORT}}"] = self.valkey_cache.attr_endpoint_port 

3937 

3938 # Add Aurora pgvector endpoint if enabled 

3939 if hasattr(self, "aurora_cluster") and self.aurora_cluster: 

3940 image_replacements["{{AURORA_PGVECTOR_ENDPOINT}}"] = ( 

3941 self.aurora_cluster.cluster_endpoint.hostname 

3942 ) 

3943 image_replacements["{{AURORA_PGVECTOR_READER_ENDPOINT}}"] = ( 

3944 self.aurora_cluster.cluster_read_endpoint.hostname 

3945 ) 

3946 image_replacements["{{AURORA_PGVECTOR_PORT}}"] = str( 

3947 self.aurora_cluster.cluster_endpoint.port 

3948 ) 

3949 image_replacements["{{AURORA_PGVECTOR_SECRET_ARN}}"] = self.aurora_secret.secret_arn 

3950 

3951 # Add vector-store discovery if enabled. Every value is deterministic 

3952 # at synth time (the global stack names the table and index from the 

3953 # same config), so no cross-stack reference is needed; the manifest's 

3954 # {{REGION}} key points pods at their cluster's LOCAL global-table 

3955 # replica. When disabled, the placeholders stay unreplaced and the 

3956 # applier skips 26-storage-vector-store.yaml entirely. 

3957 if self.config.get_vector_store_enabled(): 

3958 vector_store_config = self.config.get_vector_store_config() 

3959 image_replacements["{{VECTOR_STORE_TABLE_NAME}}"] = ( 

3960 f"{self.config.get_project_name()}-vector-store" 

3961 ) 

3962 image_replacements["{{VECTOR_STORE_INDEX_NAME}}"] = "corpus-embedding-index" 

3963 image_replacements["{{VECTOR_STORE_EMBEDDING_MODEL_ID}}"] = str( 

3964 vector_store_config["embedding_model_id"] 

3965 ) 

3966 image_replacements["{{VECTOR_STORE_DIMENSIONS}}"] = str( 

3967 vector_store_config["dimensions"] 

3968 ) 

3969 

3970 # Add FSx replacements if enabled 

3971 if self.fsx_file_system: 

3972 image_replacements["{{FSX_FILE_SYSTEM_ID}}"] = self.fsx_file_system.ref 

3973 image_replacements["{{FSX_DNS_NAME}}"] = self.fsx_file_system.attr_dns_name 

3974 image_replacements["{{FSX_MOUNT_NAME}}"] = self.fsx_file_system.attr_lustre_mount_name 

3975 image_replacements["{{PRIVATE_SUBNET_ID}}"] = self.vpc.private_subnets[0].subnet_id 

3976 image_replacements["{{FSX_SECURITY_GROUP_ID}}"] = ( 

3977 self.fsx_security_group.security_group_id 

3978 ) 

3979 

3980 # ── Trigger the convergence pipeline (fire-and-forget) ─────────────── 

3981 # A single custom resource starts the HelmInstallStateMachine, which now 

3982 # owns the WHOLE cluster convergence: apply base manifests -> install 

3983 # Helm charts -> apply post-Helm (CRD-dependent) manifests -> publish 

3984 # the Gateway-created ALB and optionally register it with Global 

3985 # Accelerator. The resource returns 

3986 # as soon as the execution is *started* (no isComplete waiter), so the 

3987 # cluster's CloudFormation lifecycle is never bound to the multi-minute 

3988 # add-on convergence — a slow chart can't blow CloudFormation's ~1h 

3989 # custom-resource ceiling and roll back (destroy) the freshly-created 

3990 # cluster. Status lives in SSM and is surfaced via `gco stacks addons 

3991 # status`; re-converge out-of-band with `gco stacks addons install`. 

3992 # 

3993 # The execution input carries everything the state-machine tasks need: 

3994 # chart selection/overrides, the manifest ImageReplacements (for the base 

3995 # and post-Helm kubectl passes), the endpoint-registry identity, and the 

3996 # optional Global Accelerator EndpointGroupArn. 

3997 convergence_properties: dict[str, Any] = { 

3998 "ClusterName": self.cluster.cluster_name, 

3999 "Region": self.deployment_region, 

4000 # Helm chart selection + per-chart value overrides (e.g. Volcano 

4001 # image_registry redirected to the ECR mirror when enabled). 

4002 "EnabledCharts": self._get_enabled_helm_charts(), 

4003 "Charts": self._helm_chart_value_overrides(), 

4004 "KedaOperatorRoleArn": self.keda_operator_role.role_arn, 

4005 # Template substitutions for the base + post-Helm kubectl passes. 

4006 "ImageReplacements": image_replacements, 

4007 # Project name lets the orchestrator persist the execution input 

4008 # to SSM so `gco stacks addons install` can replay the whole 

4009 # pipeline without reconstructing chart/manifest config. 

4010 "ProjectName": self.config.get_project_name(), 

4011 "RegistryRegion": self.config.get_global_region(), 

4012 # Force re-invocation on every deployment (new charts.yaml, 

4013 # manifest, or image) so convergence re-runs end to end. 

4014 "DeploymentTimestamp": deployment_timestamp, 

4015 } 

4016 if self.global_accelerator_enabled: 

4017 convergence_properties["EndpointGroupArn"] = self.endpoint_group_arn 

4018 

4019 converge_trigger = CustomResource( 

4020 self, 

4021 "HelmInstallCharts", 

4022 service_token=self.helm_installer_provider.service_token, 

4023 properties=convergence_properties, 

4024 ) 

4025 converge_trigger.node.add_dependency(self.helm_installer_provider_log_group) 

4026 converge_trigger.node.add_dependency(self.aws_load_balancer_controller_policy) 

4027 

4028 # The trigger (and therefore the whole convergence pipeline) must run 

4029 # after the cluster, shared storage, managed-addon IRSA patches, and Pod 

4030 # Identity associations exist: the base manifests reference their tokens, 

4031 # and the rollout-restarts at the end of the base pass need the patched 

4032 # service accounts (otherwise the mutating webhook can't inject 

4033 # AWS_ROLE_ARN and the controllers fail with "no EC2 IMDS role found" — 

4034 # PVCs stuck Pending, missing Container Insights metrics; see the 

4035 # UpdateEfsCsiAddonRole resource in _create_efs_csi_driver_addon). These 

4036 # gates previously sat on the synchronous KubectlApplyManifests custom 

4037 # resource; the base apply now lives in the state machine, so the gate 

4038 # moves to the trigger. 

4039 converge_trigger.node.add_dependency(self.cluster) 

4040 converge_trigger.node.add_dependency(self.efs_file_system) 

4041 if self.fsx_file_system: 

4042 converge_trigger.node.add_dependency(self.fsx_file_system) 

4043 for attr in ( 

4044 "_efs_csi_addon_role_update", 

4045 "_fsx_csi_addon_role_update", 

4046 "_cloudwatch_addon_role_update", 

4047 ): 

4048 update_cr = getattr(self, attr, None) 

4049 if update_cr is not None: 

4050 converge_trigger.node.add_dependency(update_cr) 

4051 # The trigger also needs both EKS access entries before it starts the 

4052 # asynchronous pipeline. Keeping these explicit is essential on delete: 

4053 # the ordered Helm teardown runs while its Kubernetes authentication is 

4054 # still valid, then the trigger/access entries/cluster can disappear. 

4055 for attr in ( 

4056 "kubectl_lambda_access_entry", 

4057 "helm_installer_access_entry", 

4058 "ga_registration_access_entry", 

4059 ): 

4060 access_entry = getattr(self, attr, None) 

4061 if access_entry is not None: 

4062 converge_trigger.node.add_dependency(access_entry) 

4063 for assoc in self._pod_identity_associations: 

4064 converge_trigger.node.add_dependency(assoc) 

4065 

4066 # Deletion must run in the opposite safety order: synchronous Helm 

4067 # teardown first (quiescing endpoint writers and removing Gateway 

4068 # resources), then the unconditional endpoint deregistration guard, 

4069 # then the convergence trigger and its EKS access entries. Build the 

4070 # create-time chain as trigger -> endpoint guard -> Helm teardown so 

4071 # CloudFormation reverses it during stack deletion. 

4072 helm_teardown = getattr(self, "helm_teardown_resource", None) 

4073 ga_deregistration = getattr(self, "ga_deregistration_resource", None) 

4074 if helm_teardown is not None: 

4075 if ga_deregistration is not None: 

4076 helm_teardown.node.add_dependency(ga_deregistration) 

4077 ga_deregistration.node.add_dependency(converge_trigger) 

4078 else: 

4079 helm_teardown.node.add_dependency(converge_trigger) 

4080 elif ga_deregistration is not None: 

4081 ga_deregistration.node.add_dependency(converge_trigger) 

4082 

4083 def _create_ga_registration_lambda(self) -> None: 

4084 """Create the exact Gateway ALB discovery and endpoint-publication Lambda. 

4085 

4086 Every partition uses this function to discover ``gco-system/gco-gateway`` 

4087 and publish its internal ALB hostname to the regional SSM registry. 

4088 Commercial partitions additionally pass an endpoint-group ARN so the 

4089 same exact ALB is registered with Global Accelerator. 

4090 """ 

4091 project_name = self.config.get_project_name() 

4092 

4093 # Create Lambda function for GA registration using external handler 

4094 ga_registration_lambda = lambda_.Function( 

4095 self, 

4096 "GaRegistrationFunction", 

4097 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

4098 handler="handler.lambda_handler", 

4099 code=lambda_.Code.from_asset("lambda/ga-registration"), 

4100 timeout=Duration.minutes(15), # Max Lambda timeout; handler uses 14 min budget 

4101 memory_size=256, 

4102 vpc=self.vpc, 

4103 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS), 

4104 environment={ 

4105 "CLUSTER_NAME": self.cluster.cluster_name, 

4106 "REGION": self.deployment_region, 

4107 }, 

4108 tracing=lambda_.Tracing.ACTIVE, 

4109 ) 

4110 

4111 # Grant permissions 

4112 ga_registration_lambda.add_to_role_policy( 

4113 iam.PolicyStatement( 

4114 effect=iam.Effect.ALLOW, 

4115 actions=["eks:DescribeCluster"], 

4116 resources=[self.cluster.cluster_arn], 

4117 ) 

4118 ) 

4119 ga_registration_lambda.add_to_role_policy( 

4120 iam.PolicyStatement( 

4121 effect=iam.Effect.ALLOW, 

4122 actions=[ 

4123 "elasticloadbalancing:DescribeLoadBalancers", 

4124 "elasticloadbalancing:DescribeTags", # Required for tag-based ALB detection 

4125 ], 

4126 resources=["*"], 

4127 ) 

4128 ) 

4129 if self.global_accelerator_enabled: 

4130 ga_registration_lambda.add_to_role_policy( 

4131 iam.PolicyStatement( 

4132 effect=iam.Effect.ALLOW, 

4133 actions=[ 

4134 "globalaccelerator:AddEndpoints", 

4135 "globalaccelerator:RemoveEndpoints", 

4136 "globalaccelerator:UpdateEndpointGroup", 

4137 "globalaccelerator:DescribeEndpointGroup", 

4138 # The teardown-time cleanup_gateway_endpoint task runs 

4139 # on this Lambda and strictly waits for the accelerator 

4140 # to reach DEPLOYED after endpoint removal. 

4141 "globalaccelerator:DescribeAccelerator", 

4142 ], 

4143 resources=["*"], 

4144 ) 

4145 ) 

4146 ga_registration_lambda.add_to_role_policy( 

4147 iam.PolicyStatement( 

4148 effect=iam.Effect.ALLOW, 

4149 actions=["ssm:GetParameter", "ssm:PutParameter", "ssm:DeleteParameter"], 

4150 resources=[ 

4151 f"arn:{self.partition}:ssm:{self.config.get_global_region()}:" 

4152 f"{self.account}:parameter/{project_name}/*" 

4153 ], 

4154 ) 

4155 ) 

4156 

4157 # Retain the access entry so asynchronous convergence cannot start 

4158 # until the endpoint publisher can read the exact Gateway object. 

4159 ga_registration_role = cast(iam.IRole, ga_registration_lambda.role) 

4160 self.ga_registration_access_entry = eks.AccessEntry( 

4161 self, 

4162 "GaRegistrationLambdaAccessEntry", 

4163 cluster=self.cluster, # type: ignore[arg-type] 

4164 principal=ga_registration_role.role_arn, 

4165 access_policies=[ 

4166 eks.AccessPolicy.from_access_policy_name( 

4167 "AmazonEKSClusterAdminPolicy", access_scope_type=eks.AccessScopeType.CLUSTER 

4168 ) 

4169 ], 

4170 ) 

4171 

4172 # Allow Lambda to access EKS API 

4173 self.cluster.cluster_security_group.add_ingress_rule( 

4174 peer=ec2.Peer.ipv4(self.vpc.vpc_cidr_block), 

4175 connection=ec2.Port.tcp(443), 

4176 description="Allow GA registration Lambda to access EKS API", 

4177 ) 

4178 

4179 # Global Accelerator exists only in supported partitions. Resolve its 

4180 # endpoint group lazily there; regional endpoint publication itself is 

4181 # unconditional and needs no GA lookup. 

4182 self.endpoint_group_arn: str | None = None 

4183 if self.global_accelerator_enabled: 

4184 global_region = self.config.get_global_region() 

4185 get_endpoint_group_arn = cr.AwsCustomResource( 

4186 self, 

4187 "GetEndpointGroupArn", 

4188 on_create=cr.AwsSdkCall( 

4189 service="SSM", 

4190 action="getParameter", 

4191 parameters={ 

4192 "Name": f"/{project_name}/endpoint-group-{self.deployment_region}-arn" 

4193 }, 

4194 region=global_region, 

4195 physical_resource_id=cr.PhysicalResourceId.of( 

4196 f"{project_name}-get-endpoint-group-arn-{self.deployment_region}" 

4197 ), 

4198 ), 

4199 on_update=cr.AwsSdkCall( 

4200 service="SSM", 

4201 action="getParameter", 

4202 parameters={ 

4203 "Name": f"/{project_name}/endpoint-group-{self.deployment_region}-arn" 

4204 }, 

4205 region=global_region, 

4206 ), 

4207 role=self.aws_custom_resource_role, 

4208 ) 

4209 get_endpoint_group_arn.node.add_dependency(self.aws_custom_resource_role) 

4210 self.endpoint_group_arn = get_endpoint_group_arn.get_response_field("Parameter.Value") 

4211 

4212 # Invoked directly by the convergence state machine's final task. 

4213 self.ga_registration_lambda = ga_registration_lambda 

4214 

4215 # cdk-nag suppression: the GA registration Lambda needs broad 

4216 # Global Accelerator and ELB Describe access with Resource: *. 

4217 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

4218 

4219 acknowledge_nag_findings( 

4220 ga_registration_lambda, 

4221 [ 

4222 { 

4223 "id": "AwsSolutions-IAM5", 

4224 "reason": ( 

4225 "The endpoint-publication Lambda needs ELB Describe access to " 

4226 "resolve the exact Gateway-owned ALB. In partitions with Global " 

4227 "Accelerator it also needs the service's endpoint-group mutation " 

4228 "APIs. These APIs do not support resource-level scoping." 

4229 ), 

4230 "appliesTo": ["Resource::*"], 

4231 }, 

4232 ], 

4233 ) 

4234 

4235 # Wire the delete-time teardown guard that deregisters this region's ALB 

4236 # from Global Accelerator before its VPC subnets are deleted. 

4237 self._create_ga_deregistration_resource() 

4238 

4239 def _create_ga_deregistration_resource(self) -> None: 

4240 """Create the unconditional endpoint-registry delete guard. 

4241 

4242 On stack deletion the guard always removes this region's SSM hostname. 

4243 When an endpoint group exists it first deregisters the ALB and waits for 

4244 Global Accelerator to release its managed ENIs. The resource therefore 

4245 exists in every partition even though the GA portion is optional. 

4246 """ 

4247 project_name = self.config.get_project_name() 

4248 

4249 # Dedicated Lambda built from the SAME asset as the registration Lambda 

4250 # (it reuses the shared remove/wait helpers, entry point 

4251 # handler.on_delete_event). Deliberately NOT in the VPC: it only calls 

4252 # the public Global Accelerator API and must not create its own ENIs in 

4253 # the VPC it is helping to tear down. 

4254 ga_deregistration_lambda = lambda_.Function( 

4255 self, 

4256 "GaDeregistrationFunction", 

4257 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

4258 handler="handler.on_delete_event", 

4259 code=lambda_.Code.from_asset("lambda/ga-registration"), 

4260 timeout=Duration.minutes(15), # covers the GA redeploy wait budget 

4261 memory_size=256, 

4262 tracing=lambda_.Tracing.ACTIVE, 

4263 ) 

4264 if self.global_accelerator_enabled: 

4265 ga_deregistration_lambda.add_to_role_policy( 

4266 iam.PolicyStatement( 

4267 effect=iam.Effect.ALLOW, 

4268 actions=[ 

4269 "globalaccelerator:DescribeAccelerator", 

4270 "globalaccelerator:DescribeEndpointGroup", 

4271 "globalaccelerator:RemoveEndpoints", 

4272 "globalaccelerator:UpdateEndpointGroup", 

4273 ], 

4274 resources=["*"], 

4275 ) 

4276 ) 

4277 

4278 ga_deregistration_lambda.add_to_role_policy( 

4279 iam.PolicyStatement( 

4280 effect=iam.Effect.ALLOW, 

4281 actions=["ssm:DeleteParameter"], 

4282 resources=[ 

4283 f"arn:{self.partition}:ssm:{self.config.get_global_region()}:{self.account}:" 

4284 f"parameter/{project_name}/alb-hostname-{self.deployment_region}" 

4285 ], 

4286 ) 

4287 ) 

4288 

4289 # Strict live validation retains the exact generation through the 

4290 # provider's final delete invocation; its identity-fenced post-stack 

4291 # cleanup removes it. Ordinary deployments retain DESTROY semantics. 

4292 ga_deregistration_log_group = logs.LogGroup( 

4293 self, 

4294 "GaDeregistrationProviderLogGroup", 

4295 retention=logs.RetentionDays.ONE_WEEK, 

4296 removal_policy=self.provider_log_group_removal_policy, 

4297 ) 

4298 ga_deregistration_provider = cr.Provider( 

4299 self, 

4300 "GaDeregistrationProvider", 

4301 on_event_handler=ga_deregistration_lambda, 

4302 log_group=ga_deregistration_log_group, 

4303 ) 

4304 

4305 deregistration_properties: dict[str, Any] = { 

4306 "Region": self.deployment_region, 

4307 "RegistryRegion": self.config.get_global_region(), 

4308 "ProjectName": project_name, 

4309 } 

4310 if self.endpoint_group_arn is not None: 

4311 deregistration_properties["EndpointGroupArn"] = self.endpoint_group_arn 

4312 

4313 ga_deregistration = CustomResource( 

4314 self, 

4315 "GaDeregistration", 

4316 service_token=ga_deregistration_provider.service_token, 

4317 properties=deregistration_properties, 

4318 ) 

4319 

4320 # Teardown ordering: this deregistration must run BEFORE the VPC (and its 

4321 # public subnets, where Global Accelerator pins its managed ENIs) is 

4322 # deleted. Depending on the VPC means CloudFormation creates the VPC 

4323 # first and — critically — deletes this custom resource first on 

4324 # teardown, releasing the GA ENIs so the subnets can be removed cleanly. 

4325 self.ga_deregistration_resource = ga_deregistration 

4326 ga_deregistration.node.add_dependency(self.vpc) 

4327 ga_deregistration.node.add_dependency(ga_deregistration_log_group) 

4328 

4329 # cdk-nag: the deregistration Lambda needs globalaccelerator Describe*/ 

4330 # RemoveEndpoints with Resource: * (these Global Accelerator APIs do not 

4331 # support resource-level IAM scoping), mirroring the registration Lambda. 

4332 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

4333 

4334 acknowledge_nag_findings( 

4335 ga_deregistration_lambda, 

4336 [ 

4337 { 

4338 "id": "AwsSolutions-IAM5", 

4339 "reason": ( 

4340 "Where Global Accelerator is enabled, the delete guard needs its " 

4341 "Describe and endpoint-group mutation APIs to release managed ENIs. " 

4342 "Those APIs do not support resource-level IAM scoping; non-GA " 

4343 "partitions receive no Global Accelerator actions." 

4344 ), 

4345 "appliesTo": ["Resource::*"], 

4346 }, 

4347 ], 

4348 ) 

4349 

4350 def _get_volcano_image_mirror_config(self) -> dict[str, Any]: 

4351 """Parse the ``volcano_image_mirror`` block from cdk.json. 

4352 

4353 Returns a normalized dict ``{enabled, ecr_namespace}``. Validation is 

4354 strict so a misconfiguration fails at synth rather than silently leaving 

4355 Volcano pointed at docker.io. 

4356 

4357 - ``enabled`` (default False) — master toggle. 

4358 - ``ecr_namespace`` (default ``"<project_name>/dockerhub"``, i.e. 

4359 ``gco/dockerhub`` for the stock project) — the ECR repository 

4360 namespace the mirrored Volcano images live under. Must start with 

4361 ``<project_name>/`` so it inherits the project's existing 

4362 ``<project_name>/*`` machinery (node pull access, replication rule, 

4363 trusted-registry allow-list) with no extra IAM, and must be a valid 

4364 (possibly nested) ECR repository path. 

4365 """ 

4366 raw = self.node.try_get_context("volcano_image_mirror") or {} 

4367 if not isinstance(raw, dict): 

4368 raise ValueError(f"volcano_image_mirror must be a mapping, got {type(raw).__name__}") 

4369 

4370 # The mirror namespace lives under this deployment's project prefix 

4371 # (``<project_name>/``) so it inherits the project's ECR access, 

4372 # replication rule, and trusted-registry allow-list (#139). Defaults to 

4373 # ``<project_name>/dockerhub`` — ``gco/dockerhub`` for the stock project. 

4374 project_prefix = f"{self.config.get_project_name()}/" 

4375 enabled = bool(raw.get("enabled", False)) 

4376 ecr_namespace = ( 

4377 str(raw.get("ecr_namespace", f"{project_prefix}dockerhub")).strip().strip("/") 

4378 ) 

4379 

4380 if not enabled: 

4381 return {"enabled": False, "ecr_namespace": ecr_namespace} 

4382 

4383 # Must live under the project prefix and be a valid nested ECR repo 

4384 # path (lowercase alphanumerics + . _ - per segment, slash-separated). 

4385 if not ecr_namespace.startswith(project_prefix): 

4386 raise ValueError( 

4387 f"volcano_image_mirror.ecr_namespace must start with {project_prefix!r} so it " 

4388 f"inherits the project's {project_prefix}* ECR access/replication, got " 

4389 f"{ecr_namespace!r}" 

4390 ) 

4391 segment = r"[a-z0-9]+(?:[._-][a-z0-9]+)*" 

4392 if not re.fullmatch(rf"{segment}(?:/{segment})+", ecr_namespace): 

4393 raise ValueError( 

4394 "volcano_image_mirror.ecr_namespace must be a valid ECR repository " 

4395 f"path (lowercase alphanumerics + . _ - per slash-separated segment), " 

4396 f"got {ecr_namespace!r}" 

4397 ) 

4398 

4399 return {"enabled": True, "ecr_namespace": ecr_namespace} 

4400 

4401 def _configure_volcano_image_mirror(self) -> None: 

4402 """Resolve the optional Volcano image-mirror registry (no infra). 

4403 

4404 Volcano is the only default chart whose images live exclusively on 

4405 docker.io (``volcanosh/vc-*``). On a cold EKS Auto Mode cluster those 

4406 anonymous pulls are slow / rate-limited, so Volcano's blocking 

4407 ``helm --wait`` could never finish inside the installer Lambda's 

4408 wall-clock guard and the whole add-on batch looped on it. 

4409 

4410 The fix is to mirror Volcano's pinned images into the project's own ECR 

4411 under ``gco/*`` and point Volcano's ``basic.image_registry`` there, so 

4412 the cluster makes fast, same-account ECR pulls with the pull-only node 

4413 role it already has — no Docker Hub credential, no pull-through cache 

4414 rule, and no registry permissions policy. The mirror itself is populated 

4415 out-of-band (``gco images mirror``) before the add-ons 

4416 converge; this method only computes the registry override and creates no 

4417 CloudFormation resources. 

4418 

4419 Sets ``self.volcano_mirror_registry`` to 

4420 ``<account>.dkr.ecr.<region>.<url-suffix>/<ecr_namespace>`` that 

4421 ``_helm_chart_value_overrides`` feeds into Volcano's 

4422 ``basic.image_registry``; left ``None`` when disabled. 

4423 """ 

4424 # Always define the attribute so downstream code can branch on it. 

4425 self.volcano_mirror_registry: str | None = None 

4426 

4427 cfg = self._get_volcano_image_mirror_config() 

4428 if not cfg["enabled"]: 

4429 return 

4430 

4431 ecr_namespace = cfg["ecr_namespace"] 

4432 self.volcano_mirror_registry = ( 

4433 f"{self.account}.dkr.ecr.{self.deployment_region}.{self.url_suffix}/{ecr_namespace}" 

4434 ) 

4435 

4436 def _helm_chart_value_overrides(self) -> dict[str, Any]: 

4437 """Per-chart helm value overrides injected into the install payload. 

4438 

4439 Returned dict is forwarded verbatim as the ``Charts`` property of the 

4440 ``HelmInstallCharts`` custom resource; the installer deep-merges each 

4441 chart's ``values`` over ``charts.yaml``. The mandatory 

4442 ``aws-load-balancer-controller`` chart always receives the cluster, 

4443 region, VPC, and dedicated IRSA role values. Optional overrides are: 

4444 

4445 - ``volcano``: point ``basic.image_registry`` at the project's ECR 

4446 image mirror when enabled, so every Volcano image (controller, 

4447 scheduler, admission webhook, and the pre-install admission-init 

4448 hook) resolves from ECR instead of docker.io. The upstream names 

4449 (``volcanosh/vc-*``) are preserved, so each resolves to 

4450 ``<mirror_registry>/volcanosh/vc-*``. 

4451 - ``kube-prometheus-stack``: inject the ``cdk.json``-derived dynamic 

4452 values (Grafana/Prometheus/Alertmanager persistence sizes, Prometheus 

4453 retention, the gp3 ``storageClassName``, and the GPU/Neuron/EFA 

4454 node-exporter tolerations) over the static hardening values in 

4455 ``charts.yaml`` when ``cluster_observability.enabled`` is true. 

4456 

4457 The result is never empty because Gateway API requires the controller. 

4458 """ 

4459 overrides: dict[str, Any] = { 

4460 "aws-load-balancer-controller": { 

4461 "values": { 

4462 "clusterName": self.cluster.cluster_name, 

4463 "region": self.deployment_region, 

4464 "vpcId": self.vpc.vpc_id, 

4465 "serviceAccount": { 

4466 "annotations": { 

4467 "eks.amazonaws.com/role-arn": ( 

4468 self.aws_load_balancer_controller_role.role_arn 

4469 ) 

4470 } 

4471 }, 

4472 } 

4473 } 

4474 } 

4475 

4476 if getattr(self, "volcano_mirror_registry", None): 

4477 overrides["volcano"] = { 

4478 "values": { 

4479 "basic": { 

4480 "image_registry": self.volcano_mirror_registry, 

4481 } 

4482 } 

4483 } 

4484 

4485 if self.config.get_cluster_observability_enabled(): 

4486 overrides["kube-prometheus-stack"] = self._observability_chart_values() 

4487 

4488 if self._cost_monitoring_active(): 

4489 overrides["opencost"] = self._opencost_chart_values() 

4490 

4491 if self._mlflow_active(): 

4492 overrides["mlflow"] = self._mlflow_chart_values() 

4493 

4494 return overrides 

4495 

4496 def _cost_monitoring_active(self) -> bool: 

4497 """Return whether the per-region cost monitoring pipeline deploys here. 

4498 

4499 Delegates to ``ConfigLoader.get_cost_monitoring_enabled``, which is 

4500 already the conjunction of the ``cost_monitoring`` toggle and its 

4501 ``cluster_observability`` data-source dependency — disabling either 

4502 switches OpenCost, the cost-monitor service, and the cost dashboard 

4503 off together. 

4504 """ 

4505 return self.config.get_cost_monitoring_enabled() 

4506 

4507 def _mlflow_active(self) -> bool: 

4508 """Return whether the MLflow tracking server deploys on this cluster. 

4509 

4510 Delegates to ``ConfigLoader.get_mlflow_enabled`` — the conjunction of 

4511 ``cluster_observability.mlflow.enabled`` and observability itself, 

4512 so the chart, its IRSA role, the gated backend PVC, and the value 

4513 overrides all switch together. 

4514 """ 

4515 return self.config.get_mlflow_enabled() 

4516 

4517 def _mlflow_chart_values(self) -> dict[str, Any]: 

4518 """Build the MLflow value overrides that carry deployment tokens. 

4519 

4520 Only four things are dynamic — everything static (image pin, PVC 

4521 wiring, resources, posture toggles) lives in ``charts.yaml``: 

4522 

4523 - ``mlflow.artifactsDestination``: run artifacts go to the 

4524 cluster-shared bucket under ``mlflow-artifacts/<region>/`` — 

4525 region-suffixed because each regional tracking server numbers 

4526 experiments independently, so a shared root would interleave 

4527 unrelated runs' artifacts. The server proxies artifact traffic 

4528 (``--serve-artifacts`` is the server default), so client pods 

4529 never need S3 credentials of their own. 

4530 - ``serviceAccount.annotations``: the IRSA role ARN, which is how 

4531 the server-side artifact proxy gets its S3 credentials. 

4532 - ``storage.size``: the metadata claim size from 

4533 ``cluster_observability.mlflow.persistence_size``. 

4534 - ``server.value_options.allowed_hosts``: the complete 

4535 host-validation allow-list — service DNS plus wildcard patterns 

4536 derived from ``vpc_endpoint_cidrs`` (see 

4537 ``_mlflow_allowed_hosts``); the deep merge keeps the static 

4538 ``workers`` value while replacing the charts.yaml DNS-only 

4539 fallback with this full list. 

4540 """ 

4541 s3_destination = ( 

4542 f"s3://{self.cluster_shared_identity.name}/mlflow-artifacts/{self.deployment_region}" 

4543 ) 

4544 vpc_endpoint_cidrs = self.node.try_get_context("vpc_endpoint_cidrs") or ["10.0.0.0/16"] 

4545 return { 

4546 "values": { 

4547 "mlflow": { 

4548 "artifactsDestination": s3_destination, 

4549 }, 

4550 "serviceAccount": { 

4551 "annotations": { 

4552 "eks.amazonaws.com/role-arn": self.mlflow_role.role_arn, 

4553 }, 

4554 }, 

4555 "storage": { 

4556 "size": str( 

4557 self.config.get_cluster_observability_config()["mlflow"]["persistence_size"] 

4558 ), 

4559 }, 

4560 "server": { 

4561 "value_options": { 

4562 "allowed_hosts": _mlflow_allowed_hosts(vpc_endpoint_cidrs), 

4563 }, 

4564 }, 

4565 } 

4566 } 

4567 

4568 def _opencost_chart_values(self) -> dict[str, Any]: 

4569 """Build the OpenCost value overrides that carry deployment tokens. 

4570 

4571 Only the cluster identity is dynamic — every static hardening value 

4572 (Prometheus wiring, ServiceMonitor, resource limits, security 

4573 contexts) lives in ``charts.yaml``. The identity is 

4574 ``opencost.exporter.defaultClusterId`` — the value OpenCost stamps on 

4575 every allocation row, which is what lets the multi-region Athena data 

4576 distinguish clusters. The chart's root-level ``clusterName`` is NOT 

4577 set here: that value is the Kubernetes DNS zone (``cluster.local``) 

4578 used to build the Prometheus URL, and overriding it with the EKS 

4579 cluster name breaks in-cluster DNS resolution. 

4580 """ 

4581 return { 

4582 "values": { 

4583 "opencost": { 

4584 "exporter": { 

4585 "defaultClusterId": self.cluster.cluster_name, 

4586 }, 

4587 }, 

4588 } 

4589 } 

4590 

4591 def _observability_chart_values(self) -> dict[str, Any]: 

4592 """Build the kube-prometheus-stack value overrides from cdk.json. 

4593 

4594 Sizes/retention come from ``cluster_observability`` in ``cdk.json``; the 

4595 gp3 ``storageClassName`` is the shared ``_OBSERVABILITY_STORAGE_CLASS`` 

4596 (also the name of the gated StorageClass manifest), and the 

4597 node-exporter tolerations reuse the shared accelerator-node tolerations 

4598 so the DaemonSet schedules on tainted GPU/Neuron/EFA nodes. Deep-merged 

4599 by the installer over the static hardening values in ``charts.yaml``. 

4600 """ 

4601 obs = self.config.get_cluster_observability_config() 

4602 storage_class = _OBSERVABILITY_STORAGE_CLASS 

4603 return { 

4604 "values": { 

4605 "grafana": { 

4606 "persistence": { 

4607 "storageClassName": storage_class, 

4608 "size": obs["grafana"]["persistence_size"], 

4609 }, 

4610 }, 

4611 "prometheus": { 

4612 "prometheusSpec": { 

4613 "retention": obs["prometheus"]["retention"], 

4614 "storageSpec": { 

4615 "volumeClaimTemplate": { 

4616 "spec": { 

4617 "storageClassName": storage_class, 

4618 "resources": { 

4619 "requests": { 

4620 "storage": obs["prometheus"]["persistence_size"], 

4621 }, 

4622 }, 

4623 }, 

4624 }, 

4625 }, 

4626 }, 

4627 }, 

4628 "alertmanager": { 

4629 "enabled": obs["alertmanager"]["enabled"], 

4630 "alertmanagerSpec": { 

4631 "storage": { 

4632 "volumeClaimTemplate": { 

4633 "spec": { 

4634 "storageClassName": storage_class, 

4635 "resources": { 

4636 "requests": { 

4637 "storage": obs["alertmanager"]["persistence_size"], 

4638 }, 

4639 }, 

4640 }, 

4641 }, 

4642 }, 

4643 }, 

4644 }, 

4645 "prometheus-node-exporter": { 

4646 "tolerations": GCORegionalStack._ADDON_NODE_TOLERATIONS, 

4647 }, 

4648 } 

4649 } 

4650 

4651 def _get_enabled_helm_charts(self) -> list[str]: 

4652 """Return the list of Helm charts to install based on cdk.json helm config. 

4653 

4654 Reads the 'helm' section from cdk.json context. Each key maps to one or 

4655 more Helm chart names. Charts are returned in dependency order with Kueue 

4656 last (its webhook intercepts all Job/Deployment mutations). 

4657 """ 

4658 helm_config = self.node.try_get_context("helm") or {} 

4659 

4660 # Mapping from cdk.json helm key → Helm chart name(s) in charts.yaml 

4661 # Order matters: dependencies first, Kueue last 

4662 chart_map: list[tuple[str, list[str]]] = [ 

4663 ("aws_load_balancer_controller", ["aws-load-balancer-controller"]), 

4664 ("keda", ["keda"]), 

4665 ("aws_efa_device_plugin", ["aws-efa-device-plugin"]), 

4666 ("aws_neuron_device_plugin", ["aws-neuron-device-plugin"]), 

4667 ("volcano", ["volcano"]), 

4668 ("kuberay", ["kuberay-operator"]), 

4669 ("cert_manager", ["cert-manager"]), 

4670 ("slurm", ["slinky-slurm-operator", "slinky-slurm"]), 

4671 ("yunikorn", ["yunikorn"]), 

4672 ("kubeflow_trainer", ["kubeflow-trainer"]), 

4673 ("kueue", ["kueue"]), # Must be last 

4674 ] 

4675 

4676 # Charts that are mandatory platform components and cannot be disabled 

4677 # via cdk.json (see _MANDATORY_CHART_KEYS). KEDA is always installed: 

4678 # it backs the built-in SQS queue processor (a ScaledJob) and is the 

4679 # only metrics bridge that lets autoscalers consume GPU/CloudWatch 

4680 # metrics (the keda-metrics-apiserver serves external.metrics.k8s.io). 

4681 # Disabling it would silently break both, so the cdk.json toggle is 

4682 # ignored for KEDA. A `helm_enabled_overrides` context value (see 

4683 # _parse_helm_enabled_overrides) can force optional charts on for one 

4684 # deploy without editing cdk.json. 

4685 if {key for key, _names in chart_map} != _HELM_CHART_CONFIG_KEYS: 

4686 raise RuntimeError( 

4687 "chart_map keys drifted from _HELM_CHART_CONFIG_KEYS; update both together" 

4688 ) 

4689 overrides = _parse_helm_enabled_overrides( 

4690 self.node.try_get_context(_HELM_OVERRIDE_CONTEXT_KEY) 

4691 ) 

4692 

4693 enabled_charts = [] 

4694 for config_key, chart_names in chart_map: 

4695 if _helm_chart_enabled(helm_config, overrides, config_key): 

4696 enabled_charts.extend(chart_names) 

4697 

4698 # kube-prometheus-stack is driven by the separate on-by-default 

4699 # cluster_observability toggle (not the helm block), so include it here 

4700 # when enabled. Its install order comes from its file position in 

4701 # charts.yaml (before kueue), not from where it sits in this list — the 

4702 # installer runs one task per chart in charts.yaml order and skips any 

4703 # task whose chart is absent from this enabled set. 

4704 if self.config.get_cluster_observability_enabled(): 

4705 enabled_charts.append("kube-prometheus-stack") 

4706 

4707 # OpenCost is driven by the on-by-default cost_monitoring toggle and 

4708 # additionally requires observability (its Prometheus data source). 

4709 # charts.yaml places it after kube-prometheus-stack so the Prometheus 

4710 # Operator CRDs exist before its ServiceMonitor renders. 

4711 if self._cost_monitoring_active(): 

4712 enabled_charts.append("opencost") 

4713 

4714 # MLflow is driven by the on-by-default cluster_observability.mlflow 

4715 # sub-toggle and requires observability itself (monitoring namespace, 

4716 # gp3 StorageClass, ServiceMonitor discovery, tunnel access path). 

4717 if self._mlflow_active(): 

4718 enabled_charts.append("mlflow") 

4719 

4720 return enabled_charts 

4721 

4722 def _create_helm_installer_lambda(self) -> None: 

4723 """Create Lambda function to install Helm charts (KEDA, NVIDIA DRA, etc.). 

4724 

4725 This Lambda uses Helm to install charts that require complex setup 

4726 (TLS certificates, CRDs, etc.) that are difficult to manage via raw manifests. 

4727 

4728 Charts installed: 

4729 - KEDA: Kubernetes Event-Driven Autoscaling (mandatory, always installed) 

4730 - Volcano, KubeRay, Kueue, cert-manager, and other schedulers (toggle via cdk.json) 

4731 """ 

4732 project_name = self.config.get_project_name() 

4733 

4734 # Create IAM role for Helm installer Lambda 

4735 helm_lambda_role = iam.Role( 

4736 self, 

4737 "HelmInstallerLambdaRole", 

4738 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 

4739 managed_policies=[ 

4740 iam.ManagedPolicy.from_aws_managed_policy_name( 

4741 "service-role/AWSLambdaVPCAccessExecutionRole" 

4742 ), 

4743 iam.ManagedPolicy.from_aws_managed_policy_name( 

4744 "service-role/AWSLambdaBasicExecutionRole" 

4745 ), 

4746 ], 

4747 ) 

4748 

4749 # Add EKS permissions 

4750 helm_lambda_role.add_to_policy( 

4751 iam.PolicyStatement( 

4752 actions=["eks:DescribeCluster", "eks:ListClusters"], 

4753 resources=[self.cluster.cluster_arn], 

4754 ) 

4755 ) 

4756 

4757 # Create security group for Helm installer Lambda 

4758 helm_lambda_sg = ec2.SecurityGroup( 

4759 self, 

4760 "HelmInstallerLambdaSG", 

4761 vpc=self.vpc, 

4762 description="Security group for Helm installer Lambda to access EKS cluster", 

4763 security_group_name=f"{project_name}-helm-lambda-sg-{self.deployment_region}", 

4764 allow_all_outbound=True, 

4765 ) 

4766 

4767 # Allow Lambda to access EKS cluster API 

4768 self.cluster.cluster_security_group.add_ingress_rule( 

4769 peer=helm_lambda_sg, 

4770 connection=ec2.Port.tcp(443), 

4771 description="Allow Helm installer Lambda to access EKS API", 

4772 ) 

4773 

4774 # Build Docker image for Helm installer Lambda 

4775 # Points at helm-installer-build/ which is rebuilt fresh every deploy 

4776 # by _build_helm_installer_lambda() in cli/stacks.py 

4777 ecr_assets.DockerImageAsset( 

4778 self, 

4779 "HelmInstallerImage", 

4780 directory="lambda/helm-installer-build", 

4781 platform=ecr_assets.Platform.LINUX_AMD64, 

4782 ) 

4783 

4784 # Create Lambda function using Docker image 

4785 # Store function name as string attribute for cross-stack references 

4786 # This avoids CDK cross-environment resolution issues when account is unresolved 

4787 self.helm_installer_lambda_function_name = f"{project_name}-helm-{self.deployment_region}" 

4788 self.helm_installer_lambda = lambda_.DockerImageFunction( 

4789 self, 

4790 "HelmInstallerFunction", 

4791 function_name=self.helm_installer_lambda_function_name, 

4792 code=lambda_.DockerImageCode.from_image_asset( 

4793 directory="lambda/helm-installer-build", 

4794 platform=ecr_assets.Platform.LINUX_AMD64, 

4795 ), 

4796 timeout=Duration.minutes(15), 

4797 memory_size=1024, 

4798 architecture=lambda_.Architecture.X86_64, 

4799 role=helm_lambda_role, 

4800 vpc=self.vpc, 

4801 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS), 

4802 security_groups=[helm_lambda_sg], 

4803 environment={ 

4804 "CLUSTER_NAME": self.cluster.cluster_name, 

4805 "REGION": self.deployment_region, 

4806 "PROJECT_NAME": project_name, 

4807 }, 

4808 tracing=lambda_.Tracing.ACTIVE, 

4809 ) 

4810 

4811 # Allow the installer to record per-chart add-on status to SSM so the 

4812 # add-on layer's health is observable out-of-band (decoupled from the 

4813 # CloudFormation rollback path). Read back via `gco stacks addons-status`. 

4814 helm_lambda_role.add_to_policy( 

4815 iam.PolicyStatement( 

4816 actions=["ssm:PutParameter"], 

4817 resources=[ 

4818 f"arn:{self.partition}:ssm:{self.deployment_region}:{self.account}:" 

4819 f"parameter/{project_name}/addons/*" 

4820 ], 

4821 ) 

4822 ) 

4823 

4824 # Add EKS access entry for the Lambda role 

4825 self.helm_installer_access_entry = eks.AccessEntry( 

4826 self, 

4827 "HelmInstallerLambdaAccessEntry", 

4828 cluster=self.cluster, # type: ignore[arg-type] 

4829 principal=helm_lambda_role.role_arn, 

4830 access_policies=[ 

4831 eks.AccessPolicy.from_access_policy_name( 

4832 "AmazonEKSClusterAdminPolicy", access_scope_type=eks.AccessScopeType.CLUSTER 

4833 ) 

4834 ], 

4835 ) 

4836 

4837 # ------------------------------------------------------------------ 

4838 # Step Functions state machine: one task per chart, in charts.yaml 

4839 # order. Each chart gets its own retry + Step Functions console 

4840 # visibility, and — critically — no single Lambda invocation is bound 

4841 # by the 15-minute Lambda limit, so a slow operator (e.g. a cold NVIDIA 

4842 # image pull) just costs extra retries instead of failing the deploy. 

4843 # ------------------------------------------------------------------ 

4844 chart_order = _load_helm_chart_order() 

4845 

4846 def _chart_task(chart_name: str) -> sfn_tasks.LambdaInvoke: 

4847 task = sfn_tasks.LambdaInvoke( 

4848 self, 

4849 f"HelmChart-{chart_name}", 

4850 lambda_function=self.helm_installer_lambda, 

4851 payload=sfn.TaskInput.from_object( 

4852 { 

4853 "Action": "install_chart", 

4854 "Chart": chart_name, 

4855 "ClusterName": sfn.JsonPath.string_at("$.ClusterName"), 

4856 "Region": sfn.JsonPath.string_at("$.Region"), 

4857 "EnabledCharts": sfn.JsonPath.list_at("$.EnabledCharts"), 

4858 "Charts": sfn.JsonPath.object_at("$.Charts"), 

4859 "KedaOperatorRoleArn": sfn.JsonPath.string_at("$.KedaOperatorRoleArn"), 

4860 } 

4861 ), 

4862 payload_response_only=True, 

4863 # Keep the execution input intact so the next chart task can 

4864 # still read $.ClusterName, $.EnabledCharts, etc. 

4865 result_path="$.lastChart", 

4866 task_timeout=sfn.Timeout.duration(Duration.minutes(16)), 

4867 ) 

4868 # Per-chart retry with backoff. A cold image pull or a webhook race 

4869 # clears on a later attempt; only after exhausting these does the 

4870 # chart (and the deploy) fail. 

4871 task.add_retry( 

4872 errors=["States.ALL"], 

4873 max_attempts=4, 

4874 interval=Duration.seconds(30), 

4875 backoff_rate=2.0, 

4876 max_delay=Duration.minutes(5), 

4877 ) 

4878 return task 

4879 

4880 def _kubectl_task(task_id: str, *, post_helm: bool) -> sfn_tasks.LambdaInvoke: 

4881 """One kubectl-apply pass (base or post-Helm) as a state-machine task. 

4882 

4883 Reads ClusterName / Region / ImageReplacements from the execution 

4884 input; the handler raises on any manifest failure so the task's 

4885 Retry/Catch can react. 

4886 """ 

4887 task = sfn_tasks.LambdaInvoke( 

4888 self, 

4889 task_id, 

4890 lambda_function=self.kubectl_lambda, 

4891 payload=sfn.TaskInput.from_object( 

4892 { 

4893 "Action": "apply_manifests", 

4894 "ClusterName": sfn.JsonPath.string_at("$.ClusterName"), 

4895 "Region": sfn.JsonPath.string_at("$.Region"), 

4896 "ImageReplacements": sfn.JsonPath.object_at("$.ImageReplacements"), 

4897 "PostHelm": "true" if post_helm else "false", 

4898 } 

4899 ), 

4900 payload_response_only=True, 

4901 # Keep the execution input intact so later tasks still read 

4902 # $.ClusterName, $.ImageReplacements, $.EndpointGroupArn, etc. 

4903 result_path="$.lastApply", 

4904 task_timeout=sfn.Timeout.duration(Duration.minutes(15)), 

4905 ) 

4906 task.add_retry( 

4907 errors=["States.ALL"], 

4908 max_attempts=3, 

4909 interval=Duration.seconds(30), 

4910 backoff_rate=2.0, 

4911 max_delay=Duration.minutes(3), 

4912 ) 

4913 return task 

4914 

4915 def _manifest_validation_task() -> sfn_tasks.LambdaInvoke: 

4916 """Require every effective raw manifest object to exist and be ready.""" 

4917 task = sfn_tasks.LambdaInvoke( 

4918 self, 

4919 "ValidateKubernetesManifests", 

4920 lambda_function=self.kubectl_lambda, 

4921 payload=sfn.TaskInput.from_object( 

4922 { 

4923 "Action": "validate_manifests", 

4924 "ClusterName": sfn.JsonPath.string_at("$.ClusterName"), 

4925 "Region": sfn.JsonPath.string_at("$.Region"), 

4926 "ImageReplacements": sfn.JsonPath.object_at("$.ImageReplacements"), 

4927 "DeploymentToken": sfn.JsonPath.string_at("$.DeploymentToken"), 

4928 } 

4929 ), 

4930 payload_response_only=True, 

4931 result_path="$.manifestValidation", 

4932 task_timeout=sfn.Timeout.duration(Duration.minutes(15)), 

4933 ) 

4934 # Cold EKS Auto Mode clusters can need more than the original 

4935 # ~9-minute retry schedule for node capacity, cert-manager Secrets, 

4936 # PDBs, and EndpointSlices to converge. Validation is read-only; 

4937 # eight retries sample through ~21 minutes without replaying apply. 

4938 task.add_retry( 

4939 errors=["States.ALL"], 

4940 max_attempts=8, 

4941 interval=Duration.minutes(1), 

4942 backoff_rate=2.0, 

4943 max_delay=Duration.minutes(3), 

4944 ) 

4945 return task 

4946 

4947 def _helm_validation_task() -> sfn_tasks.LambdaInvoke: 

4948 """Require every configured Helm release and rendered object to be ready.""" 

4949 task = sfn_tasks.LambdaInvoke( 

4950 self, 

4951 "ValidateHelmReleases", 

4952 lambda_function=self.helm_installer_lambda, 

4953 payload=sfn.TaskInput.from_object( 

4954 { 

4955 "Action": "validate_releases", 

4956 "ClusterName": sfn.JsonPath.string_at("$.ClusterName"), 

4957 "Region": sfn.JsonPath.string_at("$.Region"), 

4958 "EnabledCharts": sfn.JsonPath.list_at("$.EnabledCharts"), 

4959 "Charts": sfn.JsonPath.object_at("$.Charts"), 

4960 "DeploymentToken": sfn.JsonPath.string_at("$.DeploymentToken"), 

4961 } 

4962 ), 

4963 payload_response_only=True, 

4964 result_path="$.helmValidation", 

4965 task_timeout=sfn.Timeout.duration(Duration.minutes(16)), 

4966 ) 

4967 task.add_retry( 

4968 errors=["States.ALL"], 

4969 max_attempts=4, 

4970 interval=Duration.minutes(1), 

4971 backoff_rate=2.0, 

4972 max_delay=Duration.minutes(3), 

4973 ) 

4974 return task 

4975 

4976 def _endpoint_publication_task() -> sfn_tasks.LambdaInvoke: 

4977 """Publish the exact Gateway ALB and optionally register it with GA.""" 

4978 payload: dict[str, Any] = { 

4979 "Action": "publish_gateway_endpoint", 

4980 "ClusterName": sfn.JsonPath.string_at("$.ClusterName"), 

4981 "Region": sfn.JsonPath.string_at("$.Region"), 

4982 "RegistryRegion": sfn.JsonPath.string_at("$.RegistryRegion"), 

4983 "ProjectName": sfn.JsonPath.string_at("$.ProjectName"), 

4984 } 

4985 if self.global_accelerator_enabled: 

4986 payload["EndpointGroupArn"] = sfn.JsonPath.string_at("$.EndpointGroupArn") 

4987 # The configured health-check contract is a synth-time 

4988 # constant, so it is baked into the task payload as literals 

4989 # rather than threaded through the execution input: replayed 

4990 # pre-upgrade inputs (`gco stacks addons install`) carry no 

4991 # such keys, and a JsonPath reference to an absent input key 

4992 # would fail the whole convergence pipeline. The handler's 

4993 # defaults match the values it historically hardcoded. 

4994 ga_config = self.config.get_global_accelerator_config() 

4995 payload["GaHealthCheckPath"] = ga_config.get("health_check_path", "/api/v1/health") 

4996 payload["GaHealthCheckInterval"] = ga_config.get("health_check_interval", 30) 

4997 payload["GaHealthCheckThreshold"] = ga_config.get("health_check_threshold", 3) 

4998 

4999 task = sfn_tasks.LambdaInvoke( 

5000 self, 

5001 "PublishGatewayEndpoint", 

5002 lambda_function=self.ga_registration_lambda, 

5003 payload=sfn.TaskInput.from_object(payload), 

5004 payload_response_only=True, 

5005 result_path="$.endpointPublication", 

5006 task_timeout=sfn.Timeout.duration(Duration.minutes(16)), 

5007 ) 

5008 task.add_retry( 

5009 errors=["States.ALL"], 

5010 max_attempts=3, 

5011 interval=Duration.seconds(30), 

5012 backoff_rate=2.0, 

5013 max_delay=Duration.minutes(3), 

5014 ) 

5015 return task 

5016 

5017 chart_tasks = [_chart_task(name) for name in chart_order] 

5018 

5019 # The state machine owns the full convergence pipeline: 

5020 # base apply -> Helm charts -> post-Helm apply -> exhaustive raw 

5021 # manifest validation -> exhaustive Helm/rendered-object validation 

5022 # -> unconditional Gateway endpoint publication, with optional Global 

5023 # Accelerator registration. 

5024 # 

5025 # Individual chart failures still continue so every release gets an 

5026 # install attempt and diagnostic. The terminal validators then make the 

5027 # overall execution fail unless every expected object and release is 

5028 # present and ready. Post-Helm apply, both validators, and endpoint 

5029 # publication deliberately have no catch-to-success path: topology may 

5030 # trust only an exact SUCCEEDED execution for the current deployment 

5031 # token. 

5032 done = sfn.Succeed(self, "HelmInstallComplete") 

5033 base_apply = _kubectl_task("ApplyBaseManifests", post_helm=False) 

5034 post_apply = _kubectl_task("ApplyPostHelmManifests", post_helm=True) 

5035 manifest_validation = _manifest_validation_task() 

5036 helm_validation = _helm_validation_task() 

5037 

5038 endpoint_publication = _endpoint_publication_task() 

5039 post_apply.next(manifest_validation) 

5040 manifest_validation.next(helm_validation) 

5041 helm_validation.next(endpoint_publication) 

5042 endpoint_publication.next(done) 

5043 

5044 if chart_tasks: 

5045 for i, task in enumerate(chart_tasks): 

5046 next_state: sfn.IChainable = ( 

5047 chart_tasks[i + 1] if i + 1 < len(chart_tasks) else post_apply 

5048 ) 

5049 task.add_catch( 

5050 next_state, 

5051 errors=["States.ALL"], 

5052 result_path="$.lastChartError", 

5053 ) 

5054 task.next(next_state) 

5055 base_apply.next(chart_tasks[0]) 

5056 else: # pragma: no cover - charts.yaml is always present in the repo 

5057 base_apply.next(post_apply) 

5058 

5059 # base apply has NO catch on purpose: a persistent base-manifest failure 

5060 # fails the execution rather than converging onto an incomplete base. 

5061 start_state: sfn.IChainable = base_apply 

5062 

5063 helm_sm_log_group = logs.LogGroup( 

5064 self, 

5065 "HelmInstallStateMachineLogGroup", 

5066 retention=logs.RetentionDays.ONE_WEEK, 

5067 removal_policy=RemovalPolicy.DESTROY, 

5068 ) 

5069 

5070 self.helm_install_state_machine = sfn.StateMachine( 

5071 self, 

5072 "HelmInstallStateMachine", 

5073 definition_body=sfn.DefinitionBody.from_chainable(start_state), 

5074 state_machine_type=sfn.StateMachineType.STANDARD, 

5075 timeout=Duration.hours(2), 

5076 tracing_enabled=True, 

5077 logs=sfn.LogOptions(destination=helm_sm_log_group, level=sfn.LogLevel.ALL), 

5078 ) 

5079 

5080 # Thin fire-and-forget provider: onEvent starts the execution and 

5081 # returns immediately. It does no Helm/Kubernetes work, so it never 

5082 # approaches the Lambda timeout — all the heavy lifting lives in the 

5083 # state machine, which converges charts in the background. 

5084 helm_orchestrator_on_event = lambda_.Function( 

5085 self, 

5086 "HelmOrchestratorOnEvent", 

5087 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

5088 handler="handler.on_event", 

5089 code=lambda_.Code.from_asset("lambda/helm-orchestrator"), 

5090 timeout=Duration.minutes(1), 

5091 memory_size=256, 

5092 environment={ 

5093 "STATE_MACHINE_ARN": self.helm_install_state_machine.state_machine_arn, 

5094 }, 

5095 tracing=lambda_.Tracing.ACTIVE, 

5096 ) 

5097 self.helm_install_state_machine.grant_start_execution(helm_orchestrator_on_event) 

5098 self.helm_install_state_machine.grant_execution( 

5099 helm_orchestrator_on_event, 

5100 "states:StopExecution", 

5101 "states:DescribeExecution", 

5102 ) 

5103 

5104 # Let on_event persist the execution input to SSM so the add-on install 

5105 # can be replayed out-of-band (gco stacks addons install) without the 

5106 # CLI reconstructing chart config or the KEDA role ARN. 

5107 helm_orchestrator_on_event.add_to_role_policy( 

5108 iam.PolicyStatement( 

5109 actions=[ 

5110 "ssm:PutParameter", 

5111 "ssm:GetParameter", 

5112 "ssm:DeleteParameter", 

5113 ], 

5114 resources=[ 

5115 f"arn:{self.partition}:ssm:{self.deployment_region}:{self.account}:" 

5116 f"parameter/{project_name}/addons/*" 

5117 ], 

5118 ) 

5119 ) 

5120 

5121 # HelmInstallCharts depends on this explicit bounded-retention group, 

5122 # forcing the trigger's final provider invocation to finish before 

5123 # CloudFormation removes the group. 

5124 self.helm_installer_provider = cr.Provider( 

5125 self, 

5126 "HelmInstallerProvider", 

5127 on_event_handler=helm_orchestrator_on_event, 

5128 log_group=self.helm_installer_provider_log_group, 

5129 ) 

5130 

5131 # Unlike create/update convergence, stack deletion must be synchronous: 

5132 # Helm releases can own admission webhooks, load balancers, and CRs that 

5133 # have to disappear while the Kubernetes API and installer AccessEntry 

5134 # still exist. A delete-only provider waits on a reverse-order state 

5135 # machine and fails CloudFormation if any real uninstall fails. 

5136 self._create_helm_teardown(chart_order) 

5137 

5138 # cdk-nag suppressions for the install path. 

5139 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

5140 

5141 acknowledge_nag_findings( 

5142 helm_lambda_role, 

5143 [ 

5144 { 

5145 "id": "AwsSolutions-IAM5", 

5146 "reason": ( 

5147 "The Helm installer Lambda requires broad EKS and Kubernetes API " 

5148 "access to install Helm charts (KEDA, NVIDIA DRA, etc.) that create " 

5149 "CRDs, RBAC rules, and workloads across multiple namespaces. " 

5150 "Resource: * is required because the set of Kubernetes resources " 

5151 "is dynamic and not known at synth time." 

5152 ), 

5153 "appliesTo": ["Resource::*"], 

5154 }, 

5155 ], 

5156 ) 

5157 # The state machine role (auto-generated) invokes the worker Lambda 

5158 # across versions using the AWS-standard ``:*`` qualifier that cannot be 

5159 # enumerated at synth time. 

5160 acknowledge_nag_findings( 

5161 self.helm_install_state_machine, 

5162 [ 

5163 { 

5164 "id": "AwsSolutions-IAM5", 

5165 "reason": ( 

5166 "The state machine invokes the helm worker Lambda; CDK grants " 

5167 "lambda:InvokeFunction with the :* version qualifier, which is the " 

5168 "standard form and cannot be narrowed at synth time." 

5169 ), 

5170 "appliesTo": ["Resource::*"], 

5171 }, 

5172 ], 

5173 ) 

5174 acknowledge_nag_findings( 

5175 helm_orchestrator_on_event, 

5176 [ 

5177 { 

5178 "id": "AwsSolutions-IAM5", 

5179 "reason": ( 

5180 "Lambda active tracing requires X-Ray write APIs against " 

5181 "Resource::*, as X-Ray does not expose resource-level " 

5182 "permissions for these telemetry calls." 

5183 ), 

5184 "appliesTo": ["Resource::*"], 

5185 }, 

5186 { 

5187 "id": "AwsSolutions-IAM5", 

5188 "reason": ( 

5189 "If execution metadata persistence fails, the orchestrator must " 

5190 "stop the just-started convergence execution before CloudFormation " 

5191 "can roll back. This grant is limited to executions of the single " 

5192 "regional Helm install state machine." 

5193 ), 

5194 "appliesTo": [ 

5195 "Resource::arn:<AWS::Partition>:states:<AWS::Region>:" 

5196 "<AWS::AccountId>:execution:" 

5197 '{"Fn::Select":[6,{"Fn::Split":[":",' 

5198 '{"Ref":"HelmInstallStateMachine7DB71CDC"}]}]}:*' 

5199 ], 

5200 }, 

5201 ], 

5202 ) 

5203 

5204 # The cr.Provider framework auto-generates a framework-onEvent Lambda and 

5205 # its role (and, were an is_complete_handler set, a waiter state machine — 

5206 # which this fire-and-forget provider does NOT create). None of these are 

5207 # configurable by us: the framework role invokes our handler Lambda via 

5208 # the standard ``<lambda-arn>:*`` version qualifier that cannot be narrowed 

5209 # at synth time. Suppress the relevant rules across the whole provider 

5210 # subtree; appliesTo is omitted because the findings are granted on 

5211 # CDK-managed resources we do not author. The SF1/SF2/X-Ray entries are 

5212 # retained defensively to cover any helper state machine the framework may 

5213 # emit across CDK versions; they are harmless no-ops when none exists. 

5214 acknowledge_nag_findings( 

5215 self.helm_installer_provider, 

5216 [ 

5217 { 

5218 "id": "AwsSolutions-IAM5", 

5219 "reason": ( 

5220 "CDK custom-resource provider framework roles invoke the " 

5221 "orchestrator Lambdas via the standard '<lambda-arn>:*' version " 

5222 "qualifier, which cannot be enumerated at synth time." 

5223 ), 

5224 "appliesTo": [ 

5225 "Resource::<HelmOrchestratorOnEventD0D51D9B.Arn>:*", 

5226 ], 

5227 }, 

5228 { 

5229 "id": "AwsSolutions-SF1", 

5230 "reason": ( 

5231 "The waiter state machine is auto-generated by the CDK " 

5232 "cr.Provider framework and does not expose log configuration; " 

5233 "ALL-event logging cannot be enabled on it." 

5234 ), 

5235 }, 

5236 { 

5237 "id": "AwsSolutions-SF2", 

5238 "reason": ( 

5239 "The waiter state machine is auto-generated by the CDK " 

5240 "cr.Provider framework and does not expose tracing " 

5241 "configuration; X-Ray cannot be enabled on it." 

5242 ), 

5243 }, 

5244 { 

5245 "id": "Serverless-StepFunctionStateMachineXray", 

5246 "reason": ( 

5247 "The waiter state machine is auto-generated by the CDK " 

5248 "cr.Provider framework and does not expose tracing " 

5249 "configuration; X-Ray cannot be enabled on it." 

5250 ), 

5251 }, 

5252 ], 

5253 ) 

5254 

5255 def _create_helm_teardown(self, chart_order: list[str]) -> None: 

5256 """Create the synchronous, reverse-order Helm stack-delete path. 

5257 

5258 Create/update remain fire-and-forget through ``HelmInstallCharts``. This 

5259 separate custom resource is a no-op for those events, but on Delete it 

5260 starts a state machine whose per-chart tasks call ``uninstall_chart`` in 

5261 reverse install order. The provider polls to terminal state, so a failed 

5262 release blocks deletion before EKS authentication or the API disappears. 

5263 """ 

5264 

5265 lbc_chart = "aws-load-balancer-controller" 

5266 if not chart_order or chart_order[0] != lbc_chart: 

5267 raise RuntimeError( 

5268 "aws-load-balancer-controller must be the first Helm chart for safe teardown" 

5269 ) 

5270 

5271 provider_code = lambda_.Code.from_asset("lambda/helm-installer") 

5272 drain_checker = lambda_.Function( 

5273 self, 

5274 "HelmTeardownDrainChecker", 

5275 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

5276 handler="teardown_provider.drain_install_executions", 

5277 code=provider_code, 

5278 timeout=Duration.minutes(1), 

5279 memory_size=256, 

5280 environment={ 

5281 "INSTALL_STATE_MACHINE_ARN": self.helm_install_state_machine.state_machine_arn, 

5282 }, 

5283 tracing=lambda_.Tracing.ACTIVE, 

5284 ) 

5285 

5286 def _uninstall_task(chart_name: str) -> sfn_tasks.LambdaInvoke: 

5287 timeout_minutes = 5 if chart_name == lbc_chart else 4 if chart_name == "keda" else 2 

5288 task = sfn_tasks.LambdaInvoke( 

5289 self, 

5290 f"HelmUninstallChart-{chart_name}", 

5291 lambda_function=self.helm_installer_lambda, 

5292 payload=sfn.TaskInput.from_object( 

5293 { 

5294 "Action": "uninstall_chart", 

5295 "Chart": chart_name, 

5296 "ClusterName": sfn.JsonPath.string_at("$.ClusterName"), 

5297 "Region": sfn.JsonPath.string_at("$.Region"), 

5298 "EnabledCharts": sfn.JsonPath.list_at("$.EnabledCharts"), 

5299 "Charts": sfn.JsonPath.object_at("$.Charts"), 

5300 "KedaOperatorRoleArn": sfn.JsonPath.string_at("$.KedaOperatorRoleArn"), 

5301 } 

5302 ), 

5303 payload_response_only=True, 

5304 result_path="$.lastChart", 

5305 task_timeout=sfn.Timeout.duration(Duration.minutes(timeout_minutes)), 

5306 ) 

5307 return task 

5308 

5309 def _drain_check_task() -> sfn_tasks.LambdaInvoke: 

5310 return sfn_tasks.LambdaInvoke( 

5311 self, 

5312 "CheckRunningConvergence", 

5313 lambda_function=drain_checker, 

5314 payload=sfn.TaskInput.from_object({}), 

5315 payload_response_only=True, 

5316 result_path="$.drainCheck", 

5317 task_timeout=sfn.Timeout.duration(Duration.minutes(1)), 

5318 ) 

5319 

5320 def _quiesce_task() -> sfn_tasks.LambdaInvoke: 

5321 task = sfn_tasks.LambdaInvoke( 

5322 self, 

5323 "QuiesceHealthMonitor", 

5324 lambda_function=self.helm_installer_lambda, 

5325 payload=sfn.TaskInput.from_object( 

5326 { 

5327 "Action": "quiesce_health_monitor", 

5328 "ClusterName": sfn.JsonPath.string_at("$.ClusterName"), 

5329 "Region": sfn.JsonPath.string_at("$.Region"), 

5330 } 

5331 ), 

5332 payload_response_only=True, 

5333 result_path="$.healthMonitorQuiesce", 

5334 task_timeout=sfn.Timeout.duration(Duration.minutes(3)), 

5335 ) 

5336 return task 

5337 

5338 def _endpoint_cleanup_task() -> sfn_tasks.LambdaInvoke: 

5339 """Fence SSM/GA publication after all endpoint writers are quiesced.""" 

5340 payload: dict[str, Any] = { 

5341 "Action": "cleanup_gateway_endpoint", 

5342 "Region": sfn.JsonPath.string_at("$.Region"), 

5343 "RegistryRegion": sfn.JsonPath.string_at("$.RegistryRegion"), 

5344 "ProjectName": sfn.JsonPath.string_at("$.ProjectName"), 

5345 } 

5346 if self.global_accelerator_enabled: 

5347 payload["EndpointGroupArn"] = sfn.JsonPath.string_at("$.EndpointGroupArn") 

5348 return sfn_tasks.LambdaInvoke( 

5349 self, 

5350 "CleanupGatewayEndpoint", 

5351 lambda_function=self.ga_registration_lambda, 

5352 payload=sfn.TaskInput.from_object(payload), 

5353 payload_response_only=True, 

5354 result_path="$.endpointCleanup", 

5355 task_timeout=sfn.Timeout.duration(Duration.minutes(15)), 

5356 ) 

5357 

5358 def _gateway_cleanup_task() -> sfn_tasks.LambdaInvoke: 

5359 return sfn_tasks.LambdaInvoke( 

5360 self, 

5361 "DeleteGatewayResources", 

5362 lambda_function=self.kubectl_lambda, 

5363 payload=sfn.TaskInput.from_object( 

5364 { 

5365 "Action": "delete_gateway_resources", 

5366 "ClusterName": sfn.JsonPath.string_at("$.ClusterName"), 

5367 "Region": sfn.JsonPath.string_at("$.Region"), 

5368 } 

5369 ), 

5370 payload_response_only=True, 

5371 result_path="$.gatewayCleanup", 

5372 task_timeout=sfn.Timeout.duration(Duration.minutes(5)), 

5373 ) 

5374 

5375 non_lbc_tasks = [ 

5376 _uninstall_task(name) for name in reversed(chart_order) if name != lbc_chart 

5377 ] 

5378 endpoint_cleanup = _endpoint_cleanup_task() 

5379 pre_gateway_cleanup = sfn.Parallel( 

5380 self, 

5381 "CleanupEndpointAndCharts", 

5382 result_path="$.preGatewayCleanup", 

5383 ) 

5384 pre_gateway_cleanup.branch(endpoint_cleanup) 

5385 if non_lbc_tasks: 

5386 for index, task in enumerate(non_lbc_tasks[:-1]): 

5387 task.next(non_lbc_tasks[index + 1]) 

5388 pre_gateway_cleanup.branch(non_lbc_tasks[0]) 

5389 

5390 lbc_uninstall = _uninstall_task(lbc_chart) 

5391 gateway_cleanup = _gateway_cleanup_task() 

5392 done = sfn.Succeed(self, "HelmTeardownComplete") 

5393 quiesce = _quiesce_task() 

5394 

5395 quiesce.next(pre_gateway_cleanup) 

5396 pre_gateway_cleanup.next(gateway_cleanup) 

5397 gateway_cleanup.next(lbc_uninstall) 

5398 lbc_uninstall.next(done) 

5399 

5400 # StopExecution cannot cancel a Lambda invocation already in flight, 

5401 # and ListExecutions is eventually consistent. The provider stops the 

5402 # initially visible executions before this unconditional 16-minute 

5403 # drain. The checker then stops any late-visible work and loops through 

5404 # another complete drain interval before quiescence. The SSM teardown 

5405 # fence blocks supported convergence entrypoints from creating new work. 

5406 drain_in_flight = sfn.Wait( 

5407 self, 

5408 "DrainInFlightConvergence", 

5409 time=sfn.WaitTime.seconds_path("$.WaitForInFlightSeconds"), 

5410 ) 

5411 drain_check = _drain_check_task() 

5412 late_work = sfn.Choice(self, "LateConvergenceFound") 

5413 drain_in_flight.next(drain_check) 

5414 drain_check.next(late_work) 

5415 late_work.when( 

5416 sfn.Condition.number_greater_than("$.drainCheck.StoppedExecutions", 0), 

5417 drain_in_flight, 

5418 ).otherwise(quiesce) 

5419 start_state: sfn.IChainable = drain_in_flight 

5420 

5421 teardown_log_group = logs.LogGroup( 

5422 self, 

5423 "HelmTeardownStateMachineLogGroup", 

5424 retention=logs.RetentionDays.ONE_WEEK, 

5425 removal_policy=RemovalPolicy.DESTROY, 

5426 ) 

5427 self.helm_teardown_state_machine = sfn.StateMachine( 

5428 self, 

5429 "HelmTeardownStateMachine", 

5430 definition_body=sfn.DefinitionBody.from_chainable(start_state), 

5431 state_machine_type=sfn.StateMachineType.STANDARD, 

5432 # 16m drain + 3m quiesce + max(15m endpoint cleanup, 24m ordinary 

5433 # chart cleanup) + 5m Gateway deletion + 5m LBC uninstall = 53m. 

5434 # Three minutes of workflow margin leave another three minutes for 

5435 # the provider's final poll inside CloudFormation's one-hour ceiling. 

5436 timeout=Duration.minutes(56), 

5437 tracing_enabled=True, 

5438 logs=sfn.LogOptions(destination=teardown_log_group, level=sfn.LogLevel.ALL), 

5439 ) 

5440 

5441 teardown_on_event = lambda_.Function( 

5442 self, 

5443 "HelmTeardownOnEvent", 

5444 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

5445 handler="teardown_provider.on_event", 

5446 code=provider_code, 

5447 timeout=Duration.minutes(1), 

5448 memory_size=256, 

5449 environment={ 

5450 "TEARDOWN_STATE_MACHINE_ARN": self.helm_teardown_state_machine.state_machine_arn, 

5451 "INSTALL_STATE_MACHINE_ARN": self.helm_install_state_machine.state_machine_arn, 

5452 }, 

5453 tracing=lambda_.Tracing.ACTIVE, 

5454 ) 

5455 teardown_is_complete = lambda_.Function( 

5456 self, 

5457 "HelmTeardownIsComplete", 

5458 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

5459 handler="teardown_provider.is_complete", 

5460 code=provider_code, 

5461 timeout=Duration.minutes(1), 

5462 memory_size=256, 

5463 environment={ 

5464 "TEARDOWN_STATE_MACHINE_ARN": self.helm_teardown_state_machine.state_machine_arn, 

5465 "INSTALL_STATE_MACHINE_ARN": self.helm_install_state_machine.state_machine_arn, 

5466 }, 

5467 tracing=lambda_.Tracing.ACTIVE, 

5468 ) 

5469 self.helm_teardown_state_machine.grant_start_execution(teardown_on_event) 

5470 self.helm_teardown_state_machine.grant_read(teardown_is_complete) 

5471 install_execution_detail = ( 

5472 "Resource::arn:<AWS::Partition>:states:<AWS::Region>:<AWS::AccountId>:execution:" 

5473 '{"Fn::Select":[6,{"Fn::Split":[":",' 

5474 '{"Ref":"HelmInstallStateMachine7DB71CDC"}]}]}:*' 

5475 ) 

5476 teardown_execution_detail = ( 

5477 "Resource::arn:<AWS::Partition>:states:<AWS::Region>:<AWS::AccountId>:execution:" 

5478 '{"Fn::Select":[6,{"Fn::Split":[":",' 

5479 '{"Ref":"HelmTeardownStateMachine1C15895F"}]}]}:*' 

5480 ) 

5481 for handler in (teardown_on_event, drain_checker): 

5482 self.helm_install_state_machine.grant( 

5483 handler, 

5484 "states:ListExecutions", 

5485 ) 

5486 self.helm_install_state_machine.grant_execution( 

5487 handler, 

5488 "states:StopExecution", 

5489 "states:DescribeExecution", 

5490 ) 

5491 teardown_on_event.add_to_role_policy( 

5492 iam.PolicyStatement( 

5493 actions=["ssm:PutParameter"], 

5494 resources=[ 

5495 f"arn:{self.partition}:ssm:{self.deployment_region}:{self.account}:" 

5496 f"parameter/{self.config.get_project_name()}/addons/" 

5497 f"{self.deployment_region}/_teardown" 

5498 ], 

5499 ) 

5500 ) 

5501 

5502 # Strict live validation preserves this generation until exact 

5503 # post-stack cleanup. Ordinary deployments retain DESTROY semantics. 

5504 provider_log_group = logs.LogGroup( 

5505 self, 

5506 "HelmTeardownProviderLogGroup", 

5507 retention=logs.RetentionDays.ONE_WEEK, 

5508 removal_policy=self.provider_log_group_removal_policy, 

5509 ) 

5510 self.helm_teardown_provider = cr.Provider( 

5511 self, 

5512 "HelmTeardownProvider", 

5513 on_event_handler=teardown_on_event, 

5514 is_complete_handler=teardown_is_complete, 

5515 query_interval=Duration.seconds(15), 

5516 total_timeout=Duration.minutes(59), 

5517 log_group=provider_log_group, 

5518 ) 

5519 teardown_properties: dict[str, Any] = { 

5520 "ClusterName": self.cluster.cluster_name, 

5521 "Region": self.deployment_region, 

5522 "RegistryRegion": self.config.get_global_region(), 

5523 "ProjectName": self.config.get_project_name(), 

5524 "EnabledCharts": self._get_enabled_helm_charts(), 

5525 "Charts": self._helm_chart_value_overrides(), 

5526 "KedaOperatorRoleArn": self.keda_operator_role.role_arn, 

5527 } 

5528 if self.endpoint_group_arn is not None: 

5529 teardown_properties["EndpointGroupArn"] = self.endpoint_group_arn 

5530 self.helm_teardown_resource = CustomResource( 

5531 self, 

5532 "HelmTeardown", 

5533 service_token=self.helm_teardown_provider.service_token, 

5534 properties=teardown_properties, 

5535 ) 

5536 self.helm_teardown_resource.node.add_dependency(self.cluster) 

5537 self.helm_teardown_resource.node.add_dependency(self.helm_installer_access_entry) 

5538 self.helm_teardown_resource.node.add_dependency(self.kubectl_lambda_access_entry) 

5539 self.helm_teardown_resource.node.add_dependency(self.helm_install_state_machine) 

5540 self.helm_teardown_resource.node.add_dependency(self.aws_load_balancer_controller_policy) 

5541 self.helm_teardown_resource.node.add_dependency(provider_log_group) 

5542 

5543 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

5544 

5545 acknowledge_nag_findings( 

5546 self.helm_teardown_state_machine, 

5547 [ 

5548 { 

5549 "id": "AwsSolutions-IAM5", 

5550 "reason": ( 

5551 "The teardown state machine's X-Ray integration requires " 

5552 "Resource::*, and its Lambda task grants use CDK's required :* " 

5553 "version qualifier. The drain-checker detail names the single " 

5554 "dedicated function created by this stack." 

5555 ), 

5556 "appliesTo": [ 

5557 "Resource::*", 

5558 "Resource::<HelmTeardownDrainCheckerCCF8D9D1.Arn>:*", 

5559 ], 

5560 }, 

5561 ], 

5562 ) 

5563 acknowledge_nag_findings( 

5564 drain_checker, 

5565 [ 

5566 { 

5567 "id": "AwsSolutions-IAM5", 

5568 "reason": ( 

5569 "The drain checker uses X-Ray Resource::* APIs and stops only " 

5570 "runtime-generated executions of the single regional Helm install " 

5571 "state machine before Kubernetes teardown." 

5572 ), 

5573 "appliesTo": ["Resource::*", install_execution_detail], 

5574 } 

5575 ], 

5576 ) 

5577 for handler in (teardown_on_event, teardown_is_complete): 

5578 acknowledge_nag_findings( 

5579 handler, 

5580 [ 

5581 { 

5582 "id": "AwsSolutions-IAM5", 

5583 "reason": ( 

5584 "X-Ray write APIs require Resource::*. StopExecution and " 

5585 "DescribeExecution are otherwise limited to runtime-generated " 

5586 "execution ARNs belonging to the two regional Helm state machines." 

5587 ), 

5588 "appliesTo": [ 

5589 "Resource::*", 

5590 install_execution_detail, 

5591 teardown_execution_detail, 

5592 ], 

5593 } 

5594 ], 

5595 ) 

5596 acknowledge_nag_findings( 

5597 self.helm_teardown_provider, 

5598 [ 

5599 { 

5600 "id": "AwsSolutions-IAM5", 

5601 "reason": ( 

5602 "The CDK provider framework invokes only versioned onEvent/isComplete " 

5603 "handlers and its generated waiter invokes only its versioned timeout " 

5604 "and completion handlers; every wildcard is a Lambda qualifier." 

5605 ), 

5606 "appliesTo": [ 

5607 "Resource::<HelmTeardownIsComplete5ECB4605.Arn>:*", 

5608 "Resource::<HelmTeardownOnEvent3DB6F756.Arn>:*", 

5609 ("Resource::<HelmTeardownProviderframeworkisComplete3D7339F4.Arn>:*"), 

5610 ("Resource::<HelmTeardownProviderframeworkonTimeout3415E5E9.Arn>:*"), 

5611 ], 

5612 }, 

5613 { 

5614 "id": "AwsSolutions-SF1", 

5615 "reason": ( 

5616 "The provider waiter state machine is generated by CDK and does not " 

5617 "expose logging configuration." 

5618 ), 

5619 }, 

5620 { 

5621 "id": "AwsSolutions-SF2", 

5622 "reason": ( 

5623 "The provider waiter state machine is generated by CDK and does not " 

5624 "expose tracing configuration." 

5625 ), 

5626 }, 

5627 { 

5628 "id": "Serverless-StepFunctionStateMachineXray", 

5629 "reason": ( 

5630 "The provider waiter state machine is generated by CDK and does not " 

5631 "expose tracing configuration." 

5632 ), 

5633 }, 

5634 ], 

5635 ) 

5636 

5637 def _create_efs(self) -> None: 

5638 """Create EFS file system for shared storage across jobs. 

5639 

5640 Creates an EFS file system with mount targets in each private subnet, 

5641 allowing pods to share data and persist outputs. The EFS is configured 

5642 with: 

5643 - Encryption at rest 

5644 - Automatic backups (disabled only for disposable live validation) 

5645 - General Purpose performance mode (suitable for most workloads) 

5646 - Bursting throughput mode 

5647 

5648 Kubernetes resources (StorageClass, PV, PVC) are created via manifests. 

5649 """ 

5650 project_name = self.config.get_project_name() 

5651 

5652 # Create security group for EFS 

5653 self.efs_security_group = ec2.SecurityGroup( 

5654 self, 

5655 "EfsSecurityGroup", 

5656 vpc=self.vpc, 

5657 description=f"Security group for {project_name} EFS in {self.deployment_region}", 

5658 security_group_name=f"{project_name}-efs-sg-{self.deployment_region}", 

5659 allow_all_outbound=False, # EFS doesn't need outbound 

5660 ) 

5661 

5662 # Allow NFS traffic from EKS cluster security group 

5663 self.efs_security_group.add_ingress_rule( 

5664 peer=self.cluster.cluster_security_group, 

5665 connection=ec2.Port.tcp(2049), 

5666 description="Allow NFS from EKS cluster", 

5667 ) 

5668 

5669 # Create EFS file system 

5670 self.efs_file_system = efs.FileSystem( 

5671 self, 

5672 "GCOEfs", 

5673 vpc=self.vpc, 

5674 file_system_name=f"{project_name}-efs-{self.deployment_region}", 

5675 security_group=self.efs_security_group, 

5676 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS), 

5677 encrypted=True, 

5678 performance_mode=efs.PerformanceMode.GENERAL_PURPOSE, 

5679 throughput_mode=efs.ThroughputMode.BURSTING, 

5680 removal_policy=RemovalPolicy.DESTROY, # For dev/test; use RETAIN for production 

5681 enable_automatic_backups=not self.disable_efs_automatic_backups, 

5682 ) 

5683 if self.disable_efs_automatic_backups: 

5684 cfn_file_system = self.efs_file_system.node.default_child 

5685 if not isinstance(cfn_file_system, efs.CfnFileSystem): 

5686 raise TypeError("GCOEfs default child must be AWS::EFS::FileSystem") 

5687 cfn_file_system.backup_policy = efs.CfnFileSystem.BackupPolicyProperty( 

5688 status="DISABLED" 

5689 ) 

5690 

5691 # Add file system policy to allow mounting without IAM authorization 

5692 # This allows any client that can reach the mount target to mount the file system 

5693 self.efs_file_system.add_to_resource_policy( 

5694 iam.PolicyStatement( 

5695 effect=iam.Effect.ALLOW, 

5696 principals=[iam.AnyPrincipal()], 

5697 actions=[ 

5698 "elasticfilesystem:ClientMount", 

5699 "elasticfilesystem:ClientWrite", 

5700 "elasticfilesystem:ClientRootAccess", 

5701 ], 

5702 conditions={"Bool": {"elasticfilesystem:AccessedViaMountTarget": "true"}}, 

5703 ) 

5704 ) 

5705 

5706 # Create access point for the gco-jobs directory 

5707 self.efs_access_point = self.efs_file_system.add_access_point( 

5708 "JobsAccessPoint", 

5709 path="/gco-jobs", 

5710 create_acl=efs.Acl(owner_uid="1000", owner_gid="1000", permissions="755"), 

5711 posix_user=efs.PosixUser(uid="1000", gid="1000"), 

5712 ) 

5713 

5714 # Output EFS information 

5715 CfnOutput( 

5716 self, 

5717 "EfsFileSystemId", 

5718 value=self.efs_file_system.file_system_id, 

5719 description="EFS File System ID for shared job storage", 

5720 ) 

5721 

5722 CfnOutput( 

5723 self, 

5724 "EfsAccessPointId", 

5725 value=self.efs_access_point.access_point_id, 

5726 description="EFS Access Point ID for job outputs", 

5727 ) 

5728 

5729 def _create_fsx_lustre(self) -> None: 

5730 """Create FSx for Lustre file system for high-performance storage. 

5731 

5732 FSx for Lustre provides high-performance parallel file system storage 

5733 ideal for ML training workloads that require high throughput and low latency. 

5734 

5735 This is optional and controlled by the fsx_lustre.enabled config setting. 

5736 

5737 Supported deployment types: 

5738 - SCRATCH_1: Temporary storage, no data replication 

5739 - SCRATCH_2: Temporary storage with better burst performance 

5740 - PERSISTENT_1: Persistent storage with data replication 

5741 - PERSISTENT_2: Latest persistent storage with higher throughput 

5742 """ 

5743 fsx_config = self.config.get_fsx_lustre_config(self.deployment_region) 

5744 

5745 if not fsx_config.get("enabled", False): 

5746 self.fsx_file_system = None 

5747 return 

5748 

5749 project_name = self.config.get_project_name() 

5750 

5751 # Create security group for FSx 

5752 self.fsx_security_group = ec2.SecurityGroup( 

5753 self, 

5754 "FsxSecurityGroup", 

5755 vpc=self.vpc, 

5756 description=f"Security group for {project_name} FSx Lustre in {self.deployment_region}", 

5757 security_group_name=f"{project_name}-fsx-sg-{self.deployment_region}", 

5758 allow_all_outbound=False, 

5759 ) 

5760 

5761 # Allow Lustre traffic from EKS cluster security group 

5762 # Lustre uses ports 988 (control) and 1021-1023 (data) 

5763 self.fsx_security_group.add_ingress_rule( 

5764 peer=self.cluster.cluster_security_group, 

5765 connection=ec2.Port.tcp(988), 

5766 description="Allow Lustre control traffic from EKS cluster", 

5767 ) 

5768 self.fsx_security_group.add_ingress_rule( 

5769 peer=self.cluster.cluster_security_group, 

5770 connection=ec2.Port.tcp_range(1021, 1023), 

5771 description="Allow Lustre data traffic from EKS cluster", 

5772 ) 

5773 

5774 # Allow self-referencing traffic for FSx Lustre internal communication 

5775 # FSx Lustre nodes need to communicate with each other on port 988 

5776 self.fsx_security_group.add_ingress_rule( 

5777 peer=self.fsx_security_group, 

5778 connection=ec2.Port.tcp(988), 

5779 description="Allow Lustre internal traffic on port 988", 

5780 ) 

5781 self.fsx_security_group.add_ingress_rule( 

5782 peer=self.fsx_security_group, 

5783 connection=ec2.Port.tcp_range(1021, 1023), 

5784 description="Allow Lustre internal traffic on ports 1021-1023", 

5785 ) 

5786 

5787 # Get deployment type 

5788 deployment_type = fsx_config.get("deployment_type", "SCRATCH_2") 

5789 storage_capacity = fsx_config.get("storage_capacity_gib", 1200) 

5790 

5791 # Build Lustre configuration based on deployment type 

5792 lustre_config = { 

5793 "deploymentType": deployment_type, 

5794 "dataCompressionType": fsx_config.get("data_compression_type", "LZ4"), 

5795 } 

5796 

5797 # Add throughput for PERSISTENT types 

5798 if deployment_type.startswith("PERSISTENT"): 

5799 lustre_config["perUnitStorageThroughput"] = fsx_config.get( 

5800 "per_unit_storage_throughput", 200 

5801 ) 

5802 

5803 # Add S3 import/export if configured 

5804 import_path = fsx_config.get("import_path") 

5805 export_path = fsx_config.get("export_path") 

5806 

5807 if import_path: 

5808 lustre_config["importPath"] = import_path 

5809 lustre_config["autoImportPolicy"] = fsx_config.get( 

5810 "auto_import_policy", "NEW_CHANGED_DELETED" 

5811 ) 

5812 

5813 if export_path: 

5814 lustre_config["exportPath"] = export_path 

5815 

5816 # Get file system type version (default to 2.15 for kernel 6.x compatibility) 

5817 # IMPORTANT: Lustre 2.10 is NOT compatible with kernel 6.x (AL2023, Bottlerocket 1.19+) 

5818 # See: https://docs.aws.amazon.com/fsx/latest/LustreGuide/lustre-client-matrix.html 

5819 file_system_type_version = fsx_config.get("file_system_type_version", "2.15") 

5820 

5821 # Create FSx for Lustre file system 

5822 self.fsx_file_system = fsx.CfnFileSystem( 

5823 self, 

5824 "GCOFsxLustre", 

5825 file_system_type="LUSTRE", 

5826 file_system_type_version=file_system_type_version, 

5827 storage_capacity=storage_capacity, 

5828 subnet_ids=[self.vpc.private_subnets[0].subnet_id], 

5829 security_group_ids=[self.fsx_security_group.security_group_id], 

5830 lustre_configuration=lustre_config, 

5831 tags=[ 

5832 {"key": "Name", "value": f"{project_name}-fsx-{self.deployment_region}"}, 

5833 {"key": "Project", "value": project_name}, 

5834 ], 

5835 ) 

5836 

5837 # Ensure FSx file system waits for security group ingress rules to be created 

5838 # This prevents "security group does not permit Lustre LNET traffic" errors 

5839 self.fsx_file_system.node.add_dependency(self.fsx_security_group) 

5840 

5841 # Create FSx CSI Driver add-on for Kubernetes integration 

5842 self._create_fsx_csi_driver_addon() 

5843 

5844 # Output FSx information 

5845 CfnOutput( 

5846 self, 

5847 "FsxFileSystemId", 

5848 value=self.fsx_file_system.ref, 

5849 description="FSx for Lustre File System ID", 

5850 ) 

5851 

5852 CfnOutput( 

5853 self, 

5854 "FsxDnsName", 

5855 value=self.fsx_file_system.attr_dns_name, 

5856 description="FSx for Lustre DNS Name", 

5857 ) 

5858 

5859 CfnOutput( 

5860 self, 

5861 "FsxMountName", 

5862 value=self.fsx_file_system.attr_lustre_mount_name, 

5863 description="FSx for Lustre Mount Name", 

5864 ) 

5865 

5866 def _create_valkey_cache(self) -> None: 

5867 """Create an ElastiCache Serverless Valkey cache for K/V caching. 

5868 

5869 Provides a low-latency key-value store that inference endpoints and 

5870 jobs can use for prompt caching, session state, feature stores, or 

5871 any shared state across pods. Valkey Serverless auto-scales and 

5872 requires no node management. 

5873 

5874 The cache is placed in the VPC private subnets and accessible from 

5875 any pod via the cluster security group. 

5876 """ 

5877 valkey_config = self.config.get_valkey_config() 

5878 if not valkey_config.get("enabled", False): 

5879 return 

5880 

5881 from aws_cdk import aws_elasticache as elasticache 

5882 

5883 # Security group for Valkey (allow access from EKS cluster) 

5884 valkey_sg = ec2.SecurityGroup( 

5885 self, 

5886 "ValkeySG", 

5887 vpc=self.vpc, 

5888 description="Security group for Valkey Serverless cache", 

5889 allow_all_outbound=False, 

5890 ) 

5891 valkey_sg.add_ingress_rule( 

5892 ec2.Peer.ipv4(self.vpc.vpc_cidr_block), 

5893 ec2.Port.tcp(6379), 

5894 "Allow Valkey access from VPC", 

5895 ) 

5896 

5897 # The Valkey SG ingress allows 6379 from the VPC CIDR (an ``Fn::GetAtt`` 

5898 # token cdk-nag can't resolve), so the SG-ingress rules throw. Scope the 

5899 # acknowledgment to the Valkey SG construct itself. 

5900 from gco.stacks.nag_suppressions import acknowledge_security_group_cidr_findings 

5901 

5902 acknowledge_security_group_cidr_findings( 

5903 valkey_sg, 

5904 reason=( 

5905 "The Valkey Serverless cache security group allows the Valkey " 

5906 "port (6379) from the VPC CIDR only, referenced via an " 

5907 "``Fn::GetAtt`` token that cdk-nag cannot resolve at synth " 

5908 "time. Ingress is restricted to intra-VPC traffic from the " 

5909 "job pods that use the cache." 

5910 ), 

5911 ) 

5912 

5913 # ElastiCache Serverless accepts only 2-3 subnets ("Serverless Cache 

5914 # should have total subnetIds between 2 and 3" — caught live by the 

5915 # example-job validation run ex241-2913b044 in us-east-1, where the 

5916 # VPC's span-every-AZ layout yields six private subnets). CDK orders 

5917 # ``vpc.private_subnets`` deterministically by AZ, so taking the 

5918 # first three keeps the selection stable across deploys; the cache 

5919 # is reachable from every subnet regardless (routing, not placement). 

5920 private_subnet_ids = [s.subnet_id for s in self.vpc.private_subnets[:3]] 

5921 

5922 self.valkey_cache = elasticache.CfnServerlessCache( 

5923 self, 

5924 "ValkeyCache", 

5925 engine="valkey", 

5926 serverless_cache_name=f"{self.config.get_project_name()}-{self.deployment_region}", 

5927 description=f"GCO K/V cache for {self.deployment_region}", 

5928 major_engine_version="8", 

5929 security_group_ids=[valkey_sg.security_group_id], 

5930 subnet_ids=private_subnet_ids, 

5931 cache_usage_limits=elasticache.CfnServerlessCache.CacheUsageLimitsProperty( 

5932 data_storage=elasticache.CfnServerlessCache.DataStorageProperty( 

5933 maximum=valkey_config.get("max_data_storage_gb", 5), 

5934 minimum=1, 

5935 unit="GB", 

5936 ), 

5937 ecpu_per_second=elasticache.CfnServerlessCache.ECPUPerSecondProperty( 

5938 maximum=valkey_config.get("max_ecpu_per_second", 5000), 

5939 minimum=1000, 

5940 ), 

5941 ), 

5942 snapshot_retention_limit=valkey_config.get("snapshot_retention_limit", 1), 

5943 tags=[ 

5944 CfnTag(key="Project", value=self.config.get_project_name()), 

5945 CfnTag(key="gco:project", value=self.config.get_project_name()), 

5946 CfnTag(key="Region", value=self.deployment_region), 

5947 ], 

5948 ) 

5949 

5950 CfnOutput( 

5951 self, 

5952 "ValkeyEndpoint", 

5953 value=self.valkey_cache.attr_endpoint_address, 

5954 description="Valkey Serverless cache endpoint", 

5955 ) 

5956 CfnOutput( 

5957 self, 

5958 "ValkeyPort", 

5959 value=self.valkey_cache.attr_endpoint_port, 

5960 description="Valkey Serverless cache port", 

5961 ) 

5962 

5963 # Store endpoint in SSM for discovery by pods 

5964 ssm.StringParameter( 

5965 self, 

5966 "ValkeyEndpointParam", 

5967 parameter_name=f"/{self.config.get_project_name()}/valkey-endpoint-{self.deployment_region}", 

5968 string_value=self.valkey_cache.attr_endpoint_address, 

5969 description=f"Valkey endpoint for {self.deployment_region}", 

5970 ) 

5971 

5972 def _create_aurora_pgvector(self) -> None: 

5973 """Create an Aurora Serverless v2 PostgreSQL cluster with pgvector. 

5974 

5975 Provides a fully managed vector database that inference endpoints and 

5976 jobs can use for RAG (retrieval-augmented generation), semantic search, 

5977 embedding storage, and similarity queries. Aurora Serverless v2 

5978 auto-scales capacity and requires no instance management. 

5979 

5980 The cluster is placed in the VPC private subnets and accessible from 

5981 any pod via the cluster security group. Credentials are stored in 

5982 Secrets Manager and the endpoint is published to SSM + a K8s ConfigMap 

5983 for automatic discovery. 

5984 

5985 See: https://aws.amazon.com/blogs/database/accelerate-generative-ai-workloads-on-amazon-aurora-with-optimized-reads-and-pgvector/ 

5986 """ 

5987 aurora_config = self.config.get_aurora_pgvector_config() 

5988 if not aurora_config.get("enabled", False): 

5989 return 

5990 

5991 from aws_cdk import aws_rds as rds 

5992 

5993 project_name = self.config.get_project_name() 

5994 

5995 # Security group for Aurora (allow PostgreSQL access from EKS cluster only) 

5996 aurora_sg = ec2.SecurityGroup( 

5997 self, 

5998 "AuroraPgvectorSG", 

5999 vpc=self.vpc, 

6000 description="Security group for Aurora Serverless v2 pgvector", 

6001 allow_all_outbound=False, 

6002 ) 

6003 aurora_sg.add_ingress_rule( 

6004 self.cluster.cluster_security_group, 

6005 ec2.Port.tcp(5432), 

6006 "Allow PostgreSQL access from EKS cluster", 

6007 ) 

6008 

6009 # Subnet group for Aurora (private subnets only) 

6010 subnet_group = rds.SubnetGroup( 

6011 self, 

6012 "AuroraPgvectorSubnetGroup", 

6013 description=f"Subnet group for GCO Aurora pgvector in {self.deployment_region}", 

6014 vpc=self.vpc, 

6015 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS), 

6016 ) 

6017 

6018 # RDS creates the exported postgresql log group itself, outside 

6019 # CloudFormation, and never deletes it — live example-job validation 

6020 # run ex241-edf33111-r2 found it as the only post-teardown residue. 

6021 # Pre-creating the group under the exact name RDS uses 

6022 # (/aws/rds/cluster/<cluster-identifier>/<export>) hands its whole 

6023 # lifecycle to this stack; RDS then writes into the existing group. 

6024 aurora_log_group = logs.LogGroup( 

6025 self, 

6026 "AuroraPgvectorPostgresqlLogs", 

6027 log_group_name=( 

6028 f"/aws/rds/cluster/{project_name}-pgvector-{self.deployment_region}/postgresql" 

6029 ), 

6030 retention=logs.RetentionDays.ONE_MONTH, 

6031 removal_policy=RemovalPolicy.DESTROY, 

6032 ) 

6033 

6034 # Aurora Serverless v2 cluster with PostgreSQL 16 + pgvector 

6035 self.aurora_cluster = rds.DatabaseCluster( 

6036 self, 

6037 "AuroraPgvectorCluster", 

6038 engine=rds.DatabaseClusterEngine.aurora_postgres( 

6039 # ``of()`` rather than a ``VER_X_Y`` enum member: the pin in 

6040 # constants.py is a plain version string so an Aurora minor 

6041 # bump never has to wait for an aws-cdk-lib enum release. 

6042 version=rds.AuroraPostgresEngineVersion.of( 

6043 AURORA_POSTGRES_VERSION, 

6044 AURORA_POSTGRES_VERSION.split(".", 1)[0], 

6045 ), 

6046 ), 

6047 serverless_v2_min_capacity=aurora_config.get("min_acu", 0), 

6048 serverless_v2_max_capacity=aurora_config.get("max_acu", 16), 

6049 writer=rds.ClusterInstance.serverless_v2( 

6050 "Writer", 

6051 auto_minor_version_upgrade=True, 

6052 ), 

6053 readers=[ 

6054 rds.ClusterInstance.serverless_v2( 

6055 "Reader", 

6056 auto_minor_version_upgrade=True, 

6057 scale_with_writer=True, 

6058 ), 

6059 ], 

6060 vpc=self.vpc, 

6061 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS), 

6062 subnet_group=subnet_group, 

6063 security_groups=[aurora_sg], 

6064 default_database_name="gco_vectors", 

6065 backup=rds.BackupProps( 

6066 retention=Duration.days(aurora_config.get("backup_retention_days", 7)), 

6067 ), 

6068 deletion_protection=aurora_config.get("deletion_protection", False), 

6069 removal_policy=RemovalPolicy.DESTROY, 

6070 storage_encrypted=True, 

6071 iam_authentication=True, 

6072 cloudwatch_logs_exports=["postgresql"], 

6073 monitoring_interval=Duration.seconds(60), 

6074 cluster_identifier=f"{project_name}-pgvector-{self.deployment_region}", 

6075 ) 

6076 self.aurora_secret = cast(secretsmanager.ISecret, self.aurora_cluster.secret) 

6077 # The group must exist before the cluster starts exporting, and must 

6078 # outlive it on delete (reverse order) so late writes cannot recreate 

6079 # an unowned group. 

6080 self.aurora_cluster.node.add_dependency(aurora_log_group) 

6081 

6082 # aws-cdk-lib >= 2.262 ships a built-in "CloudFormation Validate" 

6083 # pack whose W9008 wants StorageEncrypted on every CfnDBInstance. The 

6084 # cluster above sets storage_encrypted=True, and Aurora cluster 

6085 # members inherit the cluster's storage encryption — the 

6086 # instance-level property is not applicable to Aurora members, so the 

6087 # finding cannot be satisfied at the instance. ``Validations.acknowledge`` 

6088 # is the API that feeds the validation report's suppression pass 

6089 # (``Annotations.acknowledge_warning`` only silences the console 

6090 # annotation). Note: the current CDK implementation collects these 

6091 # acknowledgments app-wide per rule ID, so this quiets W9008 

6092 # everywhere — attaching it here records this cluster as the 

6093 # provenance in the report, and the five cdk-nag packs' own 

6094 # RDS storage-encryption rules remain scoped and would still fail 

6095 # a genuinely unencrypted instance elsewhere. 

6096 Validations.of(self.aurora_cluster).acknowledge( 

6097 Acknowledgment( 

6098 id="CloudFormation-Validate::W9008", 

6099 reason=( 

6100 "Aurora cluster members inherit the cluster's " 

6101 "storage_encrypted=True; StorageEncrypted is not applicable " 

6102 "on Aurora member DBInstances." 

6103 ), 

6104 ) 

6105 ) 

6106 

6107 # E9006 checks EngineVersion against the enum embedded in the CDK's 

6108 # bundled CloudFormation resource spec, which lags new Aurora minor 

6109 # releases (at 17.10's release the spec listed 17.9 and even 18.3, 

6110 # but not 17.10). The pin in constants.py is validated against the 

6111 # authoritative source instead: the monthly dependency scan compares 

6112 # it with live ``rds describe-db-engine-versions`` output, and the 

6113 # live release validation deploys it for real. Same app-wide 

6114 # collection caveat as W9008 above; the compensating controls are 

6115 # those live checks, which a stale spec enum cannot see. 

6116 Validations.of(self.aurora_cluster).acknowledge( 

6117 Acknowledgment( 

6118 id="CloudFormation-Validate::E9006", 

6119 reason=( 

6120 "EngineVersion is validated against live RDS (monthly " 

6121 "dependency scan + live release validation); the CDK's " 

6122 "embedded CloudFormation spec enum lags new Aurora minors." 

6123 ), 

6124 ) 

6125 ) 

6126 

6127 # Construct-level cdk-nag suppressions for Aurora pgvector 

6128 from gco.stacks.nag_suppressions import NagSuppression, acknowledge_nag_findings 

6129 

6130 acknowledge_nag_findings( 

6131 self.aurora_cluster, 

6132 [ 

6133 NagSuppression( 

6134 id="AwsSolutions-RDS10", 

6135 reason=( 

6136 "Deletion protection is intentionally disabled for dev/test deployments. " 

6137 "Production deployments should set aurora_pgvector.deletion_protection=true " 

6138 "in cdk.json." 

6139 ), 

6140 ), 

6141 NagSuppression( 

6142 id="AwsSolutions-SMG4", 

6143 reason=( 

6144 "Aurora manages credential rotation via the RDS integration with Secrets " 

6145 "Manager. Manual Secrets Manager rotation is not required. " 

6146 "See: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/rds-secrets-manager.html" 

6147 ), 

6148 ), 

6149 NagSuppression( 

6150 id="HIPAA.Security-RDSInstanceDeletionProtectionEnabled", 

6151 reason=( 

6152 "Deletion protection is intentionally disabled for dev/test deployments. " 

6153 "Production deployments should set aurora_pgvector.deletion_protection=true " 

6154 "in cdk.json." 

6155 ), 

6156 ), 

6157 NagSuppression( 

6158 id="NIST.800.53.R5-RDSInstanceDeletionProtectionEnabled", 

6159 reason=( 

6160 "Deletion protection is intentionally disabled for dev/test deployments. " 

6161 "Production deployments should set aurora_pgvector.deletion_protection=true " 

6162 "in cdk.json." 

6163 ), 

6164 ), 

6165 NagSuppression( 

6166 id="PCI.DSS.321-SecretsManagerUsingKMSKey", 

6167 reason=( 

6168 "Aurora Serverless v2 credentials in Secrets Manager are encrypted with " 

6169 "AWS-managed keys by default. Customer-managed KMS can be enabled if " 

6170 "required for PCI compliance." 

6171 ), 

6172 ), 

6173 ], 

6174 ) 

6175 

6176 # Outputs 

6177 CfnOutput( 

6178 self, 

6179 "AuroraPgvectorEndpoint", 

6180 value=self.aurora_cluster.cluster_endpoint.hostname, 

6181 description="Aurora pgvector cluster writer endpoint", 

6182 ) 

6183 CfnOutput( 

6184 self, 

6185 "AuroraPgvectorReaderEndpoint", 

6186 value=self.aurora_cluster.cluster_read_endpoint.hostname, 

6187 description="Aurora pgvector cluster reader endpoint", 

6188 ) 

6189 CfnOutput( 

6190 self, 

6191 "AuroraPgvectorPort", 

6192 value=str(self.aurora_cluster.cluster_endpoint.port), 

6193 description="Aurora pgvector cluster port", 

6194 ) 

6195 CfnOutput( 

6196 self, 

6197 "AuroraPgvectorSecretArn", 

6198 value=self.aurora_secret.secret_arn, 

6199 description="Aurora pgvector credentials secret ARN", 

6200 ) 

6201 

6202 # Store endpoint in SSM for discovery by pods and external tools 

6203 ssm.StringParameter( 

6204 self, 

6205 "AuroraPgvectorEndpointParam", 

6206 parameter_name=f"/{project_name}/aurora-pgvector-endpoint-{self.deployment_region}", 

6207 string_value=self.aurora_cluster.cluster_endpoint.hostname, 

6208 description=f"Aurora pgvector endpoint for {self.deployment_region}", 

6209 ) 

6210 

6211 # Grant the ServiceAccountRole read access to the Aurora secret 

6212 # so pods can retrieve credentials via the ConfigMap + Secrets Manager. 

6213 self.aurora_secret.grant_read(self.service_account_role) 

6214 

6215 def _create_fsx_csi_driver_addon(self) -> None: 

6216 """Create FSx CSI Driver add-on for Kubernetes integration. 

6217 

6218 The FSx CSI driver enables Kubernetes pods to mount FSx for Lustre 

6219 file systems as persistent volumes. 

6220 """ 

6221 # Create IAM role for FSx CSI Driver using IRSA + Pod Identity 

6222 self.fsx_csi_role = GCORegionalStack._create_irsa_role( 

6223 self, 

6224 "FsxCsiDriverRole", 

6225 oidc_provider_arn=self.oidc_provider.open_id_connect_provider_arn, 

6226 oidc_issuer_url=self.cluster.cluster_open_id_connect_issuer_url, 

6227 service_account_names=["fsx-csi-controller-sa"], 

6228 namespaces=["kube-system"], 

6229 ) 

6230 

6231 # Add FSx CSI driver permissions 

6232 self.fsx_csi_role.add_to_policy( 

6233 iam.PolicyStatement( 

6234 effect=iam.Effect.ALLOW, 

6235 actions=[ 

6236 "fsx:DescribeFileSystems", 

6237 "fsx:DescribeVolumes", 

6238 "fsx:CreateVolume", 

6239 "fsx:DeleteVolume", 

6240 "fsx:TagResource", 

6241 ], 

6242 resources=["*"], 

6243 ) 

6244 ) 

6245 

6246 self.fsx_csi_role.add_to_policy( 

6247 iam.PolicyStatement( 

6248 effect=iam.Effect.ALLOW, 

6249 actions=[ 

6250 "ec2:DescribeInstances", 

6251 "ec2:DescribeVolumes", 

6252 "ec2:DescribeVpcs", 

6253 "ec2:DescribeSubnets", 

6254 "ec2:DescribeSecurityGroups", 

6255 ], 

6256 resources=["*"], 

6257 ) 

6258 ) 

6259 

6260 # cdk-nag suppression: the FSx CSI driver role grants 

6261 # ec2:Describe* APIs that don't support resource-level scoping. 

6262 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

6263 

6264 acknowledge_nag_findings( 

6265 self.fsx_csi_role, 

6266 [ 

6267 { 

6268 "id": "AwsSolutions-IAM5", 

6269 "reason": ( 

6270 "The FSx CSI driver role grants ec2:Describe* for volume " 

6271 "and network discovery. These AWS APIs do not support " 

6272 "resource-level IAM scoping — Resource: * is the only " 

6273 "valid form." 

6274 ), 

6275 "appliesTo": ["Resource::*"], 

6276 }, 

6277 ], 

6278 ) 

6279 

6280 # Create FSx CSI Driver add-on 

6281 fsx_addon = eks.Addon( 

6282 self, 

6283 "FsxCsiDriverAddon", 

6284 cluster=self.cluster, # type: ignore[arg-type] 

6285 addon_name="aws-fsx-csi-driver", 

6286 addon_version=EKS_ADDON_FSX_CSI_DRIVER, 

6287 preserve_on_delete=False, 

6288 configuration_values={ 

6289 # DaemonSet node agent must run on every node type; the 

6290 # Deployment-shaped controller deliberately carries no 

6291 # accelerator tolerations (see _ADDON_NODE_TOLERATIONS). 

6292 "node": { 

6293 "tolerations": self._ADDON_NODE_TOLERATIONS, 

6294 }, 

6295 }, 

6296 ) 

6297 

6298 # Append the PassRole statement for the FSx CSI role to the shared 

6299 # AwsCustomResource execution role. See 

6300 # _create_aws_custom_resource_role for the full rationale. 

6301 self.aws_custom_resource_role.add_to_policy( 

6302 iam.PolicyStatement( 

6303 effect=iam.Effect.ALLOW, 

6304 actions=["iam:PassRole"], 

6305 resources=[self.fsx_csi_role.role_arn], 

6306 ) 

6307 ) 

6308 

6309 # Update the add-on to use the IRSA role 

6310 update_fsx_addon = cr.AwsCustomResource( 

6311 self, 

6312 "UpdateFsxCsiAddonRole", 

6313 on_create=cr.AwsSdkCall( 

6314 service="EKS", 

6315 action="updateAddon", 

6316 parameters={ 

6317 "clusterName": self.cluster.cluster_name, 

6318 "addonName": "aws-fsx-csi-driver", 

6319 "serviceAccountRoleArn": self.fsx_csi_role.role_arn, 

6320 }, 

6321 physical_resource_id=cr.PhysicalResourceId.of( 

6322 f"{self.cluster.cluster_name}-fsx-csi-role-update" 

6323 ), 

6324 ), 

6325 on_update=cr.AwsSdkCall( 

6326 service="EKS", 

6327 action="updateAddon", 

6328 parameters={ 

6329 "clusterName": self.cluster.cluster_name, 

6330 "addonName": "aws-fsx-csi-driver", 

6331 "serviceAccountRoleArn": self.fsx_csi_role.role_arn, 

6332 }, 

6333 ), 

6334 role=self.aws_custom_resource_role, 

6335 ) 

6336 

6337 update_fsx_addon.node.add_dependency(fsx_addon) 

6338 update_fsx_addon.node.add_dependency(self.fsx_csi_role) 

6339 update_fsx_addon.node.add_dependency(self.aws_custom_resource_role) 

6340 

6341 # Expose the update-addon resource so _apply_kubernetes_manifests can 

6342 # make the kubectl Lambda wait for the IRSA annotation patch to land 

6343 # before it rollout-restarts the fsx-csi-controller. See the EFS CSI 

6344 # equivalent for the full rationale — same race, same fix, same 

6345 # symptom (PVCs stuck Pending with "no EC2 IMDS role found"). 

6346 self._fsx_csi_addon_role_update = update_fsx_addon 

6347 

6348 # Create Pod Identity Association for FSx CSI driver 

6349 eks_l1.CfnPodIdentityAssociation( 

6350 self, 

6351 "PodIdentity-fsx-csi", 

6352 cluster_name=self.cluster.cluster_name, 

6353 namespace="kube-system", 

6354 service_account="fsx-csi-controller-sa", 

6355 role_arn=self.fsx_csi_role.role_arn, 

6356 ) 

6357 

6358 def _create_drift_detection(self) -> None: 

6359 """Create CloudFormation drift detection on a daily schedule. 

6360 

6361 Creates: 

6362 - SNS topic (KMS-encrypted) for drift alerts 

6363 - Lambda function that initiates drift detection on this stack, polls 

6364 until detection completes, and publishes to SNS if drift is found 

6365 - EventBridge rule on a daily schedule (configurable via cdk.json 

6366 ``drift_detection.schedule_hours``) that invokes the Lambda 

6367 

6368 Operators can disable drift detection entirely by setting 

6369 ``drift_detection.enabled`` to ``false`` in cdk.json. When disabled, 

6370 no resources are created. 

6371 """ 

6372 drift_config = self.node.try_get_context("drift_detection") or {} 

6373 if not drift_config.get("enabled", True): 

6374 return 

6375 

6376 schedule_hours = int(drift_config.get("schedule_hours", 24)) 

6377 

6378 # KMS key for SNS topic encryption. SNS with AWS-managed keys doesn't 

6379 # allow CloudFormation/Lambda to publish, so we use a customer-managed 

6380 # key we can grant publish access on. 

6381 drift_topic_key = kms.Key( 

6382 self, 

6383 "DriftDetectionTopicKey", 

6384 description="KMS key for GCO drift detection SNS topic", 

6385 enable_key_rotation=True, 

6386 removal_policy=RemovalPolicy.DESTROY, 

6387 ) 

6388 

6389 self.drift_detection_topic = sns.Topic( 

6390 self, 

6391 "DriftDetectionTopic", 

6392 display_name="GCO CloudFormation Drift Alerts", 

6393 master_key=drift_topic_key, 

6394 ) 

6395 

6396 # IAM role for the drift detection Lambda 

6397 drift_lambda_role = iam.Role( 

6398 self, 

6399 "DriftDetectionLambdaRole", 

6400 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), 

6401 managed_policies=[ 

6402 iam.ManagedPolicy.from_aws_managed_policy_name( 

6403 "service-role/AWSLambdaBasicExecutionRole" 

6404 ), 

6405 ], 

6406 ) 

6407 

6408 # CloudFormation drift APIs operate at the stack level; the API does 

6409 # not support resource-level ARN scoping for these actions, so we scope 

6410 # to this stack's ARN where supported and accept "*" where not. 

6411 drift_lambda_role.add_to_policy( 

6412 iam.PolicyStatement( 

6413 effect=iam.Effect.ALLOW, 

6414 actions=[ 

6415 "cloudformation:DetectStackDrift", 

6416 "cloudformation:DescribeStackDriftDetectionStatus", 

6417 "cloudformation:DescribeStackResourceDrifts", 

6418 "cloudformation:DescribeStackResource", 

6419 "cloudformation:DescribeStackResources", 

6420 ], 

6421 resources=["*"], 

6422 ) 

6423 ) 

6424 

6425 self.drift_detection_topic.grant_publish(drift_lambda_role) 

6426 

6427 # Lambda function — one per stack; stack name is baked into env vars 

6428 drift_lambda = lambda_.Function( 

6429 self, 

6430 "DriftDetectionFunction", 

6431 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME), 

6432 handler="handler.lambda_handler", 

6433 code=lambda_.Code.from_asset("lambda/drift-detection"), 

6434 timeout=Duration.minutes(14), # Leave headroom under Lambda 15-min cap 

6435 memory_size=256, 

6436 role=drift_lambda_role, 

6437 environment={ 

6438 "STACK_NAME": self.stack_name, 

6439 "SNS_TOPIC_ARN": self.drift_detection_topic.topic_arn, 

6440 "REGION": self.deployment_region, 

6441 }, 

6442 tracing=lambda_.Tracing.ACTIVE, 

6443 ) 

6444 

6445 # Dead-letter queue for EventBridge → Lambda target failures. 

6446 # Captures events that fail to reach the Lambda (e.g. due to 

6447 # throttling or permission issues) so operators can retry or 

6448 # investigate. Required by Serverless-EventBusDLQ cdk-nag rule. 

6449 drift_rule_dlq = sqs.Queue( 

6450 self, 

6451 "DriftDetectionRuleDlq", 

6452 retention_period=Duration.days(14), 

6453 enforce_ssl=True, 

6454 encryption=sqs.QueueEncryption.SQS_MANAGED, 

6455 removal_policy=RemovalPolicy.DESTROY, 

6456 ) 

6457 

6458 # DLQs themselves are terminal — they don't need their own DLQ. 

6459 # Suppress the circular AwsSolutions-SQS3 nag finding. 

6460 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

6461 

6462 acknowledge_nag_findings( 

6463 drift_rule_dlq, 

6464 [ 

6465 { 

6466 "id": "AwsSolutions-SQS3", 

6467 "reason": ( 

6468 "This queue IS the dead-letter queue for the " 

6469 "DriftDetectionSchedule EventBridge rule. A DLQ for a " 

6470 "DLQ is circular; if events fail to reach this queue " 

6471 "they are captured by EventBridge's own retry metrics " 

6472 "(CloudWatch FailedInvocations)." 

6473 ), 

6474 }, 

6475 ], 

6476 ) 

6477 

6478 # EventBridge rule — daily schedule by default 

6479 events.Rule( 

6480 self, 

6481 "DriftDetectionSchedule", 

6482 description=(f"Daily CloudFormation drift detection for {self.stack_name}"), 

6483 schedule=events.Schedule.rate(Duration.hours(schedule_hours)), 

6484 targets=[ 

6485 events_targets.LambdaFunction( 

6486 drift_lambda, 

6487 dead_letter_queue=drift_rule_dlq, 

6488 retry_attempts=2, 

6489 ) 

6490 ], 

6491 ) 

6492 

6493 # Outputs for operators to subscribe to the topic 

6494 CfnOutput( 

6495 self, 

6496 "DriftDetectionTopicArn", 

6497 value=self.drift_detection_topic.topic_arn, 

6498 description=( 

6499 f"SNS topic ARN for CloudFormation drift alerts in " 

6500 f"{self.deployment_region}. Subscribe an endpoint (email, " 

6501 f"Slack, PagerDuty) to receive drift notifications." 

6502 ), 

6503 ) 

6504 

6505 # cdk-nag suppressions for this component 

6506 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

6507 

6508 acknowledge_nag_findings( 

6509 drift_lambda_role, 

6510 [ 

6511 { 

6512 "id": "AwsSolutions-IAM4", 

6513 "reason": ( 

6514 "AWSLambdaBasicExecutionRole provides standard " 

6515 "CloudWatch Logs permissions required for Lambda " 

6516 "logging. This is the AWS-recommended managed policy." 

6517 ), 

6518 }, 

6519 { 

6520 "id": "AwsSolutions-IAM5", 

6521 "reason": ( 

6522 "CloudFormation drift detection APIs (DetectStackDrift, " 

6523 "DescribeStackDriftDetectionStatus, " 

6524 "DescribeStackResourceDrifts) cannot be scoped to a " 

6525 "specific stack resource via IAM; the action-level " 

6526 "scoping requires wildcard resources. The Lambda's " 

6527 "environment pins it to a single stack name, so the " 

6528 "effective blast radius is limited. The " 

6529 "``kms:GenerateDataKey*`` action wildcard is the " 

6530 "AWS-recommended grant for publishing to the " 

6531 "KMS-encrypted drift-detection SNS topic." 

6532 ), 

6533 "appliesTo": [ 

6534 "Resource::*", 

6535 "Action::kms:GenerateDataKey*", 

6536 ], 

6537 }, 

6538 ], 

6539 ) 

6540 

6541 def _create_mcp_role(self) -> None: 

6542 """Create dedicated IAM role for the MCP server. 

6543 

6544 The MCP server exposes GCO CLI tools to LLM agents. Without a dedicated 

6545 role, the server would inherit the full ambient credentials of the user 

6546 who launches it (often an administrator). This method creates a 

6547 least-privilege role that the MCP server can assume at startup via 

6548 ``GCO_MCP_ROLE_ARN``. 

6549 

6550 Permissions are scoped to the minimum needed by the tools exposed: 

6551 

6552 - ``eks:DescribeCluster`` on this regional EKS cluster ARN only. 

6553 - ``s3:GetObject`` on model weights buckets. The model bucket lives in 

6554 the global stack, so we scope to the same name pattern used by the 

6555 service account role (``{project_name}-*``). This is a deliberate 

6556 compromise: a precise cross-stack ARN export would force a tight 

6557 dependency on the global stack, and cdk-nag will flag it anyway 

6558 because the bucket name is auto-generated. 

6559 - ``cloudwatch:GetMetricData`` / ``cloudwatch:ListMetrics``. These APIs 

6560 do not support resource-level IAM, so wildcard is required. Read-only. 

6561 - ``sqs:SendMessage`` scoped to this region's job queue ARN only. 

6562 

6563 The trust policy uses ``AccountRootPrincipal`` so any IAM user/role in 

6564 the account can assume it (gated by an explicit sts:AssumeRole 

6565 permission on the caller — standard AWS behavior). Operators who want 

6566 to restrict assumption further should add an external-id or principal 

6567 condition to the trust policy after deployment. 

6568 

6569 Operators can disable this component entirely by setting 

6570 ``mcp_server.enabled`` to ``false`` in cdk.json. 

6571 """ 

6572 mcp_config = self.node.try_get_context("mcp_server") or {} 

6573 if not mcp_config.get("enabled", True): 

6574 return 

6575 

6576 project_name = self.config.get_project_name() 

6577 

6578 self.mcp_server_role = iam.Role( 

6579 self, 

6580 "McpServerRole", 

6581 assumed_by=iam.AccountRootPrincipal(), 

6582 description=( 

6583 "Least-privilege role assumed by the GCO MCP server at startup. " 

6584 "Grants only the permissions needed by MCP tools: eks:DescribeCluster, " 

6585 "s3:GetObject on model buckets, cloudwatch read-only metrics, and " 

6586 "sqs:SendMessage to the regional job queue." 

6587 ), 

6588 max_session_duration=Duration.hours(12), 

6589 ) 

6590 

6591 # eks:DescribeCluster on this region's cluster only 

6592 self.mcp_server_role.add_to_policy( 

6593 iam.PolicyStatement( 

6594 effect=iam.Effect.ALLOW, 

6595 actions=["eks:DescribeCluster"], 

6596 resources=[self.cluster.cluster_arn], 

6597 ) 

6598 ) 

6599 

6600 # s3:GetObject on model weights buckets. Bucket name is auto-generated 

6601 # in the global stack, so we match the same prefix pattern used by the 

6602 # service account role. 

6603 self.mcp_server_role.add_to_policy( 

6604 iam.PolicyStatement( 

6605 effect=iam.Effect.ALLOW, 

6606 actions=["s3:GetObject", "s3:ListBucket"], 

6607 resources=[ 

6608 f"arn:{self.partition}:s3:::{project_name}-*", 

6609 f"arn:{self.partition}:s3:::{project_name}-*/*", 

6610 ], 

6611 ) 

6612 ) 

6613 

6614 # CloudWatch read-only metrics APIs. These APIs do not support 

6615 # resource-level IAM so wildcard is required. 

6616 self.mcp_server_role.add_to_policy( 

6617 iam.PolicyStatement( 

6618 effect=iam.Effect.ALLOW, 

6619 actions=[ 

6620 "cloudwatch:GetMetricData", 

6621 "cloudwatch:GetMetricStatistics", 

6622 "cloudwatch:ListMetrics", 

6623 ], 

6624 resources=["*"], 

6625 ) 

6626 ) 

6627 

6628 # sqs:SendMessage scoped to the regional job queue only 

6629 self.mcp_server_role.add_to_policy( 

6630 iam.PolicyStatement( 

6631 effect=iam.Effect.ALLOW, 

6632 actions=["sqs:SendMessage", "sqs:GetQueueUrl", "sqs:GetQueueAttributes"], 

6633 resources=[self.job_queue.queue_arn], 

6634 ) 

6635 ) 

6636 

6637 # Export the role ARN so operators can set GCO_MCP_ROLE_ARN in their 

6638 # MCP server environment. 

6639 CfnOutput( 

6640 self, 

6641 "McpServerRoleArn", 

6642 value=self.mcp_server_role.role_arn, 

6643 description=( 

6644 "IAM role ARN for the GCO MCP server. Set GCO_MCP_ROLE_ARN to " 

6645 "this value when launching the MCP server so it assumes a " 

6646 "least-privilege role instead of ambient credentials." 

6647 ), 

6648 export_name=f"{project_name}-mcp-server-role-arn-{self.deployment_region}", 

6649 ) 

6650 

6651 # cdk-nag suppressions: CloudWatch metrics APIs cannot be scoped. 

6652 from gco.stacks.nag_suppressions import acknowledge_nag_findings 

6653 

6654 acknowledge_nag_findings( 

6655 self.mcp_server_role, 

6656 [ 

6657 { 

6658 "id": "AwsSolutions-IAM5", 

6659 "reason": ( 

6660 "The CloudWatch metrics APIs (GetMetricData, " 

6661 "GetMetricStatistics, ListMetrics) do not support " 

6662 "resource-level IAM; wildcard resource is required. " 

6663 "The S3 permissions use the {project_name}-* prefix " 

6664 "pattern because the model weights bucket name is " 

6665 "auto-generated by CDK in the global stack and a " 

6666 "cross-stack ARN export would create tight stack " 

6667 "coupling. All actions are read-only or scoped " 

6668 "send-only (SQS)." 

6669 ), 

6670 "appliesTo": [ 

6671 "Resource::*", 

6672 ], 

6673 }, 

6674 ], 

6675 ) 

6676 

6677 def _create_outputs(self) -> None: 

6678 """Create CloudFormation outputs for cluster information""" 

6679 project_name = self.config.get_project_name() 

6680 

6681 # Export cluster information 

6682 CfnOutput( 

6683 self, 

6684 "ClusterName", 

6685 value=self.cluster.cluster_name, 

6686 description=f"EKS cluster name for {self.deployment_region}", 

6687 export_name=f"{project_name}-cluster-name-{self.deployment_region}", 

6688 ) 

6689 

6690 CfnOutput( 

6691 self, 

6692 "AddonDeploymentToken", 

6693 value=self.addon_deployment_token, 

6694 description=( 

6695 "Exact token for the asynchronous Kubernetes and Helm convergence execution" 

6696 ), 

6697 ) 

6698 

6699 CfnOutput( 

6700 self, 

6701 "ClusterArn", 

6702 value=self.cluster.cluster_arn, 

6703 description=f"EKS cluster ARN for {self.deployment_region}", 

6704 export_name=f"{project_name}-cluster-arn-{self.deployment_region}", 

6705 ) 

6706 

6707 CfnOutput( 

6708 self, 

6709 "ClusterEndpoint", 

6710 value=self.cluster.cluster_endpoint, 

6711 description=f"EKS cluster endpoint for {self.deployment_region}", 

6712 export_name=f"{project_name}-cluster-endpoint-{self.deployment_region}", 

6713 ) 

6714 

6715 CfnOutput( 

6716 self, 

6717 "ClusterSecurityGroupId", 

6718 value=self.cluster.cluster_security_group_id, 

6719 description=f"EKS cluster security group ID for {self.deployment_region}", 

6720 export_name=f"{project_name}-cluster-sg-{self.deployment_region}", 

6721 ) 

6722 

6723 CfnOutput( 

6724 self, 

6725 "VpcId", 

6726 value=self.vpc.vpc_id, 

6727 description=f"VPC ID for {self.deployment_region}", 

6728 export_name=f"{project_name}-vpc-id-{self.deployment_region}", 

6729 ) 

6730 

6731 # Export public subnet IDs for ALB 

6732 public_subnet_ids = [subnet.subnet_id for subnet in self.vpc.public_subnets] 

6733 CfnOutput( 

6734 self, 

6735 "PublicSubnetIds", 

6736 value=Fn.join(",", public_subnet_ids), 

6737 description=f"Public subnet IDs for ALB in {self.deployment_region}", 

6738 export_name=f"{project_name}-public-subnets-{self.deployment_region}", 

6739 ) 

6740 

6741 # Note: the ALB is created by the AWS Load Balancer Controller from the 

6742 # gco-system/gco-gateway Gateway API resources; the GA registration 

6743 # Lambda registers its ARN with Global Accelerator 

6744 

6745 def get_cluster(self) -> eks.Cluster: 

6746 """Get the EKS cluster""" 

6747 return self.cluster 

6748 

6749 def get_vpc(self) -> ec2.Vpc: 

6750 """Get the VPC""" 

6751 return self.vpc