Coverage for cli / commands / stacks_cmd.py: 100.00%

940 statements  

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

1"""Stack deployment and management commands.""" 

2 

3import re 

4import sys 

5from collections.abc import Mapping 

6from typing import Any 

7 

8import click 

9 

10from ..config import GCOConfig, _load_cdk_json 

11from ..output import confirm, get_output_formatter, interactive_echo 

12 

13pass_config = click.make_pass_decorator(GCOConfig, ensure=True) 

14 

15_ENABLE_OPTION_HELP = ( 

16 "Force-enable an off-by-default feature or Helm chart for this run only " 

17 "(repeatable, or comma-separated). Threads the request through CDK context " 

18 "rather than rewriting cdk.json, so the committed opt-in defaults survive." 

19) 

20 

21 

22def _validate_enable( 

23 ctx: click.Context, param: click.Parameter, value: tuple[str, ...] 

24) -> dict[str, str]: 

25 """Resolve ``--enable`` names into CDK context pairs at parse time. 

26 

27 Validating in the callback means a typo fails before any AWS call and 

28 before the command's own ``try`` block, so an unknown name reports as a 

29 bad parameter instead of masquerading as a deployment failure. 

30 """ 

31 from gco.enablement_overrides import EnablementOverrideError, route_enablement_overrides 

32 

33 try: 

34 return route_enablement_overrides(value) 

35 except EnablementOverrideError as exc: 

36 raise click.BadParameter(str(exc)) from exc 

37 

38 

39def _apply_enable_overrides(formatter: Any, manager: Any, enable: Mapping[str, str]) -> None: 

40 """Register run-scoped enablement context and disclose it before mutating AWS. 

41 

42 The context rides every CDK invocation of the command so ``list``, ``synth``, 

43 and the lifecycle call all evaluate the same app. 

44 

45 On ``destroy`` this is about synth symmetry, not about what gets deleted: 

46 ``cdk destroy`` issues a CloudFormation ``DeleteStack``, which removes 

47 whatever the *deployed* template contains, and none of the override names 

48 gates a whole stack. The hazard runs the other way — see the ``--enable`` 

49 warning in docs/CUSTOMIZATION.md: re-deploying *without* the overrides 

50 synthesizes a template that no longer declares the forced-on resources, and 

51 CloudFormation deletes them as an ordinary stack update. 

52 """ 

53 if not enable: 

54 return 

55 manager.set_extra_cdk_context(dict(enable)) 

56 for key, value in sorted(enable.items()): 

57 formatter.print_info(f"Run-scoped override: {key}={value}") 

58 

59 

60@click.group() 

61@pass_config 

62def stacks(config: Any) -> None: 

63 """Deploy and manage GCO CDK stacks.""" 

64 pass 

65 

66 

67@stacks.command("list") 

68@click.option( 

69 "--refresh", 

70 is_flag=True, 

71 help="Compatibility flag; stack discovery already runs live", 

72) 

73@pass_config 

74def list_stacks(config: Any, refresh: Any) -> None: 

75 """List stacks synthesized by the local CDK app.""" 

76 from ..stacks import get_stack_manager 

77 

78 formatter = get_output_formatter(config) 

79 

80 try: 

81 manager = get_stack_manager(config) 

82 if refresh: 

83 formatter.print_info( 

84 "Stack discovery runs live on every invocation; --refresh is retained " 

85 "for compatibility." 

86 ) 

87 local_stacks = manager.list_stacks() 

88 

89 formatter.print_info("Available CDK stacks:") 

90 for stack in local_stacks: 

91 print(f" - {stack}") 

92 

93 except Exception as e: 

94 formatter.print_error(f"Failed to list stacks: {e}") 

95 sys.exit(1) 

96 

97 

98@stacks.command("synth") 

99@click.argument("stack_name", required=False) 

100@click.option("--quiet", "-q", is_flag=True, default=True, help="Quiet output") 

101@pass_config 

102def synth_stack(config: Any, stack_name: Any, quiet: Any) -> None: 

103 """Synthesize CloudFormation templates.""" 

104 from ..stacks import get_stack_manager 

105 

106 formatter = get_output_formatter(config) 

107 

108 try: 

109 manager = get_stack_manager(config) 

110 output = manager.synth(stack_name, quiet=quiet) 

111 if output: 

112 print(output) 

113 formatter.print_success("CDK synthesis completed") 

114 except Exception as e: 

115 formatter.print_error(f"CDK synth failed: {e}") 

116 sys.exit(1) 

117 

118 

119@stacks.command("diff") 

120@click.argument("stack_name", required=False) 

121@pass_config 

122def diff_stack(config: Any, stack_name: Any) -> None: 

123 """Show differences between deployed and local stacks.""" 

124 from ..stacks import get_stack_manager 

125 

126 formatter = get_output_formatter(config) 

127 

128 try: 

129 manager = get_stack_manager(config) 

130 diff_output = manager.diff(stack_name) 

131 if diff_output: 

132 print(diff_output) 

133 else: 

134 formatter.print_success("No differences found") 

135 except Exception as e: 

136 formatter.print_error(f"CDK diff failed: {e}") 

137 sys.exit(1) 

138 

139 

140def _print_cluster_access_hint(formatter: Any, config: Any, stack_name: str) -> None: 

141 """Point at the cluster-access commands after a regional deploy. 

142 

143 Reaching the cluster API needs an access entry (authn/authz) on top of 

144 endpoint reachability, and neither is discoverable from the deploy 

145 output alone — the misdirection that stretched the original outage's 

146 diagnosis. Printed only for base regional stacks (the ones that own an 

147 EKS cluster). 

148 """ 

149 prefix = f"{config.project_name}-" 

150 if not stack_name.startswith(prefix): 

151 return 

152 suffix = stack_name[len(prefix) :] 

153 if ( 

154 not suffix 

155 or suffix in ("global", "api-gateway", "monitoring") 

156 or suffix.startswith("regional-api") 

157 ): 

158 return 

159 formatter.print_info( 

160 f"kubectl access to {stack_name}: run 'gco stacks access -r {suffix}' to create " 

161 "your EKS access entry (required even over a tunnel). Private endpoint? " 

162 "'gco cluster tunnel --via-ssm auto' reaches it over SSM; 'gco cluster doctor' " 

163 "diagnoses reachability, authentication, and authorization separately." 

164 ) 

165 

166 

167@stacks.command("deploy") 

168@click.argument("stack_name") 

169@click.option("--yes", "-y", is_flag=True, help="Skip approval prompts") 

170@click.option("--outputs-file", "-o", help="Write outputs to file") 

171@click.option("--tag", "-t", multiple=True, help="Add tags (key=value)") 

172@click.option( 

173 "--enable", 

174 "enable", 

175 multiple=True, 

176 metavar="NAME[,NAME...]", 

177 callback=_validate_enable, 

178 help=_ENABLE_OPTION_HELP, 

179) 

180@pass_config 

181def deploy_stack( 

182 config: Any, 

183 stack_name: Any, 

184 yes: Any, 

185 outputs_file: Any, 

186 tag: Any, 

187 enable: Mapping[str, str], 

188) -> None: 

189 """Deploy a single CDK stack to AWS. 

190 

191 For deploying all stacks in the correct order, use 'deploy-all'. 

192 

193 Examples: 

194 gco stacks deploy gco-us-east-1 

195 gco stacks deploy gco-global -y 

196 gco stacks deploy gco-us-east-1 -t Environment=prod 

197 gco stacks deploy gco-us-east-1 -y --enable fsx_lustre,valkey 

198 

199 --enable is scoped to this one stack, and no override name is confined to a 

200 single stack (vector_store's table lives in the global stack; FSx, Valkey, 

201 and Aurora add monitoring-stack dashboard widgets). A single-stack override 

202 therefore deploys a partially wired feature without complaining. Prefer 

203 'deploy-all --enable' unless you specifically want that. 

204 """ 

205 from ..stacks import get_stack_manager 

206 

207 formatter = get_output_formatter(config) 

208 

209 # Parse tags 

210 tags = {} 

211 for t in tag: 

212 if "=" in t: 

213 k, v = t.split("=", 1) 

214 tags[k] = v 

215 

216 try: 

217 manager = get_stack_manager(config) 

218 _apply_enable_overrides(formatter, manager, enable) 

219 

220 formatter.print_info(f"Deploying {stack_name}...") 

221 

222 success = manager.deploy( 

223 stack_name=stack_name, 

224 require_approval=not yes, 

225 outputs_file=outputs_file, 

226 tags=tags if tags else None, 

227 ) 

228 

229 if success: 

230 formatter.print_success("Deployment completed successfully") 

231 _print_cluster_access_hint(formatter, config, str(stack_name)) 

232 else: 

233 formatter.print_error("Deployment failed") 

234 sys.exit(1) 

235 

236 except Exception as e: 

237 formatter.print_error(f"Deployment failed: {e}") 

238 sys.exit(1) 

239 

240 

241@stacks.command("destroy") 

242@click.argument("stack_name") 

243@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

244@click.option( 

245 "--retain-volumes", 

246 is_flag=True, 

247 help="Report the cluster's orphaned EBS volumes instead of deleting them", 

248) 

249@click.option( 

250 "--enable", 

251 "enable", 

252 multiple=True, 

253 metavar="NAME[,NAME...]", 

254 callback=_validate_enable, 

255 help=_ENABLE_OPTION_HELP, 

256) 

257@pass_config 

258def destroy_stack( 

259 config: Any, 

260 stack_name: Any, 

261 yes: Any, 

262 retain_volumes: Any, 

263 enable: Mapping[str, str], 

264) -> None: 

265 """Destroy a single CDK stack. 

266 

267 For destroying all stacks in the correct order, use 'destroy-all'. 

268 

269 For a regional stack this also deletes the EBS volumes the cluster's CSI 

270 driver provisioned for in-cluster PVCs (Prometheus, Grafana, Alertmanager, 

271 MLflow). Deleting an EKS cluster does not delete them, so they would 

272 otherwise remain billable forever with nothing able to reattach them. Pass 

273 --retain-volumes to list them instead of deleting them. 

274 

275 Examples: 

276 gco stacks destroy gco-us-east-1 

277 gco stacks destroy gco-us-east-1 -y 

278 gco stacks destroy gco-us-east-1 -y --retain-volumes 

279 gco stacks destroy gco-us-east-1 -y --enable fsx_lustre,valkey 

280 """ 

281 from ..stacks import get_stack_manager 

282 

283 formatter = get_output_formatter(config) 

284 

285 if not yes: 

286 confirm(f"Are you sure you want to destroy {stack_name}?", abort=True) 

287 

288 try: 

289 manager = get_stack_manager(config) 

290 _apply_enable_overrides(formatter, manager, enable) 

291 

292 formatter.print_info(f"Destroying {stack_name}...") 

293 

294 success = manager.destroy( 

295 stack_name=stack_name, 

296 force=yes, 

297 ) 

298 

299 if success: 

300 formatter.print_success(f"Stack {stack_name} destroyed successfully") 

301 else: 

302 formatter.print_error("Destroy failed") 

303 sys.exit(1) 

304 

305 # Unlike destroy-all, this path has no orchestrated cleanup barrier, so 

306 # the cluster's dynamically provisioned volumes are swept here. Runs only 

307 # after a reported success, and the sweep itself re-proves the cluster is 

308 # absent before touching anything (#268). 

309 manager.cleanup_cluster_volumes(stack_name, retain=retain_volumes) 

310 

311 except Exception as e: 

312 formatter.print_error(f"Destroy failed: {e}") 

313 sys.exit(1) 

314 

315 

316@stacks.command("deploy-all") 

317@click.option("--yes", "-y", is_flag=True, help="Skip approval prompts") 

318@click.option("--outputs-file", "-o", help="Write outputs to file") 

319@click.option("--tag", "-t", multiple=True, help="Add tags (key=value)") 

320@click.option("--parallel", "-p", is_flag=True, help="Deploy regional stacks in parallel") 

321@click.option("--max-workers", "-w", default=4, help="Max parallel deployments (default: 4)") 

322@click.option( 

323 "--enable", 

324 "enable", 

325 multiple=True, 

326 metavar="NAME[,NAME...]", 

327 callback=_validate_enable, 

328 help=_ENABLE_OPTION_HELP, 

329) 

330@pass_config 

331def deploy_all_orchestrated( 

332 config: Any, 

333 yes: Any, 

334 outputs_file: Any, 

335 tag: Any, 

336 parallel: Any, 

337 max_workers: Any, 

338 enable: Mapping[str, str], 

339) -> None: 

340 """Deploy all stacks in the correct order. 

341 

342 Deploys in three phases: 

343 1. Global stacks (gco-global, gco-api-gateway) 

344 2. Regional stacks (gco-us-east-1, etc.) - can be parallelized 

345 3. Monitoring stack (gco-monitoring) - depends on regional stacks 

346 

347 Use --parallel to deploy regional stacks concurrently, which can 

348 significantly reduce total deployment time when deploying to 

349 multiple regions. 

350 

351 Examples: 

352 gco stacks deploy-all -y 

353 gco stacks deploy-all -y --parallel 

354 gco stacks deploy-all -y -p --max-workers 8 

355 gco stacks deploy-all -y -t Environment=prod 

356 gco stacks deploy-all -y --enable fsx_lustre,valkey,aurora_pgvector,slurm,yunikorn 

357 """ 

358 from ..stacks import get_stack_manager 

359 

360 formatter = get_output_formatter(config) 

361 

362 # Parse tags 

363 tags = {} 

364 for t in tag: 

365 if "=" in t: 

366 k, v = t.split("=", 1) 

367 tags[k] = v 

368 

369 try: 

370 manager = get_stack_manager(config) 

371 _apply_enable_overrides(formatter, manager, enable) 

372 stacks = manager.list_stacks() 

373 

374 formatter.print_info(f"Found {len(stacks)} stacks to deploy") 

375 if parallel: 

376 formatter.print_info(f"Parallel mode enabled (max workers: {max_workers})") 

377 

378 def on_start(stack_name: str) -> None: 

379 formatter.print_info(f"Deploying {stack_name}...") 

380 

381 def on_complete(stack_name: str, success: bool) -> None: 

382 if success: 

383 formatter.print_success(f"{stack_name} deployed") 

384 else: 

385 formatter.print_error(f"{stack_name} failed") 

386 

387 success, successful, failed = manager.deploy_orchestrated( 

388 require_approval=not yes, 

389 outputs_file=outputs_file, 

390 tags=tags if tags else None, 

391 on_stack_start=on_start, 

392 on_stack_complete=on_complete, 

393 parallel=parallel, 

394 max_workers=max_workers, 

395 ) 

396 

397 formatter.print_info("") 

398 formatter.print_info(f"Deployed: {len(successful)}/{len(stacks)} stacks") 

399 

400 if success: 

401 formatter.print_success("All stacks deployed successfully") 

402 else: 

403 formatter.print_error(f"Deployment failed. Failed stacks: {', '.join(failed)}") 

404 sys.exit(1) 

405 

406 except Exception as e: 

407 formatter.print_error(f"Deployment failed: {e}") 

408 sys.exit(1) 

409 

410 

411@stacks.command("destroy-all") 

412@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

413@click.option("--parallel", "-p", is_flag=True, help="Destroy regional stacks in parallel") 

414@click.option("--max-workers", "-w", default=4, help="Max parallel destructions (default: 4)") 

415@click.option( 

416 "--retain-volumes", 

417 is_flag=True, 

418 help="Report each cluster's orphaned EBS volumes instead of deleting them", 

419) 

420@click.option( 

421 "--enable", 

422 "enable", 

423 multiple=True, 

424 metavar="NAME[,NAME...]", 

425 callback=_validate_enable, 

426 help=_ENABLE_OPTION_HELP, 

427) 

428@pass_config 

429def destroy_all_orchestrated( 

430 config: Any, 

431 yes: Any, 

432 parallel: Any, 

433 max_workers: Any, 

434 retain_volumes: Any, 

435 enable: Mapping[str, str], 

436) -> None: 

437 """Destroy all stacks in the correct order. 

438 

439 Destroys in four dependency phases: 

440 1. Monitoring stack (<project>-monitoring) 

441 2. Regional API bridges (<project>-regional-api-<region>) 

442 3. Base regional stacks (<project>-<region>) - can be parallelized 

443 4. Global stacks (<project>-api-gateway, <project>-global) 

444 

445 Automatically retries up to 3 times (with 30s waits) if any stacks fail, 

446 which handles transient issues like orphaned resources during teardown. 

447 

448 Once every regional stack is gone this deletes the EBS volumes their 

449 clusters' CSI drivers provisioned for in-cluster PVCs, which CloudFormation 

450 does not own and deleting an EKS cluster does not remove. Pass 

451 --retain-volumes to list them instead of deleting them. 

452 

453 After a fully successful teardown this also purges the runtime 

454 /{project}/traffic-dial SSM parameters (controller state and manual 

455 overrides), which are written outside CloudFormation. 

456 

457 Use --parallel to destroy regional stacks concurrently, which can 

458 significantly reduce total teardown time when destroying multiple 

459 regional stacks. 

460 

461 Examples: 

462 gco stacks destroy-all -y 

463 gco stacks destroy-all -y --parallel 

464 gco stacks destroy-all -y -p --max-workers 8 

465 gco stacks destroy-all -y --enable fsx_lustre,valkey,aurora_pgvector,slurm,yunikorn 

466 """ 

467 import time 

468 

469 from ..stacks import get_stack_destroy_order, get_stack_manager 

470 

471 formatter = get_output_formatter(config) 

472 # Retry up to 3 times total. CloudFormation stack deletions can fail 

473 # transiently — e.g., EKS leaves behind a cluster security group that 

474 # blocks VPC deletion, but it gets cleaned up async. A 30-second wait 

475 # between attempts is usually enough for the orphaned resources to clear. 

476 max_attempts = 3 

477 

478 try: 

479 manager = get_stack_manager(config) 

480 _apply_enable_overrides(formatter, manager, enable) 

481 stacks = manager.list_stacks() 

482 ordered = get_stack_destroy_order( 

483 stacks, 

484 project_name=config.project_name, 

485 ) 

486 

487 if not yes: 

488 formatter.print_warning("This will destroy ALL GCO stacks:") 

489 for stack in ordered: 

490 if config.output_format == "table": 

491 formatter.print_info(f" - {stack}") 

492 else: 

493 interactive_echo(f" - {stack}") 

494 confirm("\nAre you sure you want to destroy all stacks?", abort=True) 

495 

496 total_stacks = len(stacks) 

497 

498 for attempt in range(1, max_attempts + 1): 

499 if attempt > 1: 

500 # Inspect each regional VPC for resources that block teardown 

501 # (the EKS cluster security group EKS leaves behind, plus any 

502 # lingering ENIs from ELB / Global Accelerator), clear what's 

503 # safe to remove, and report what the next attempt is waiting 

504 # on. The service-managed ENIs drain asynchronously, which is 

505 # what the 30s wait is for. 

506 formatter.print_info( 

507 "Inspecting VPCs for resources that can block teardown " 

508 "(orphaned ENIs, EKS security groups)..." 

509 ) 

510 manager.cleanup_orphaned_network_interfaces() 

511 formatter.print_warning( 

512 f"Attempt {attempt}/{max_attempts}: waiting 30 seconds before retrying..." 

513 ) 

514 time.sleep(30) 

515 

516 formatter.print_info(f"Destroying {len(stacks)} stacks...") 

517 if parallel: 

518 formatter.print_info(f"Parallel mode enabled (max workers: {max_workers})") 

519 

520 def on_start(stack_name: str) -> None: 

521 formatter.print_info(f"Destroying {stack_name}...") 

522 

523 def on_complete(stack_name: str, success: bool) -> None: 

524 if success: 

525 formatter.print_success(f"{stack_name} destroyed") 

526 else: 

527 formatter.print_error(f"{stack_name} failed") 

528 

529 success, successful, failed = manager.destroy_orchestrated( 

530 force=True, 

531 on_stack_start=on_start, 

532 on_stack_complete=on_complete, 

533 parallel=parallel, 

534 max_workers=max_workers, 

535 retain_volumes=retain_volumes, 

536 ) 

537 

538 if success: 

539 break 

540 

541 if attempt < max_attempts: 

542 formatter.print_warning(f"{len(failed)} stack(s) failed: {', '.join(failed)}") 

543 

544 formatter.print_info("") 

545 formatter.print_info(f"Destroyed: {total_stacks - len(failed)}/{total_stacks} stacks") 

546 

547 if success: 

548 formatter.print_success("All stacks destroyed successfully") 

549 else: 

550 formatter.print_error(f"Some stacks failed to destroy: {', '.join(failed)}") 

551 sys.exit(1) 

552 

553 except Exception as e: 

554 formatter.print_error(f"Destroy failed: {e}") 

555 sys.exit(1) 

556 

557 

558@stacks.command("bootstrap") 

559@click.option("--account", "-a", help="AWS account ID") 

560@click.option("--region", "-r", required=True, help="AWS region") 

561@pass_config 

562def bootstrap_cdk(config: Any, account: Any, region: Any) -> None: 

563 """Bootstrap CDK in an AWS account/region. 

564 

565 This is required before deploying stacks to a new account/region. 

566 

567 Example: 

568 gco stacks bootstrap --region us-east-1 

569 gco stacks bootstrap -a 123456789012 -r eu-west-1 

570 """ 

571 from ..stacks import get_stack_manager 

572 

573 formatter = get_output_formatter(config) 

574 

575 try: 

576 manager = get_stack_manager(config) 

577 formatter.print_info(f"Bootstrapping CDK in {region}...") 

578 

579 success = manager.bootstrap(account=account, region=region) 

580 

581 if success: 

582 formatter.print_success(f"CDK bootstrapped in {region}") 

583 else: 

584 formatter.print_error("Bootstrap failed") 

585 sys.exit(1) 

586 

587 except Exception as e: 

588 formatter.print_error(f"Bootstrap failed: {e}") 

589 sys.exit(1) 

590 

591 

592def _print_eks_endpoint_drift(formatter: Any, config: Any, stack_name: str, region: str) -> None: 

593 """Report configured-vs-live EKS endpoint drift for a regional stack. 

594 

595 An endpoint flip that was configured (``gco stacks eks endpoint set``) 

596 but not deployed — or applied out-of-band and never written back to 

597 cdk.json — must be visible in ``gco stacks status`` rather than 

598 silently diverging. Best-effort: any probe or config-read failure 

599 skips the drift report, never the status output. 

600 """ 

601 if stack_name != f"{config.project_name}-{region}": 

602 return # Only base regional stacks own an EKS cluster. 

603 try: 

604 from ..cluster_doctor import endpoint_drift 

605 from ..kubectl_helpers import describe_cluster_access 

606 from ..stacks import get_eks_cluster_config 

607 

608 eks_config = get_eks_cluster_config() 

609 live = describe_cluster_access(stack_name, region) 

610 drift = endpoint_drift( 

611 str(eks_config.get("endpoint_access", "PRIVATE")), 

612 [str(cidr) for cidr in eks_config.get("public_access_cidrs") or []], 

613 live, 

614 ) 

615 except Exception: 

616 return 

617 if drift: 

618 formatter.print_warning( 

619 f"Config drift: {drift}. Run 'gco stacks deploy {stack_name} -y' to " 

620 "converge the endpoint, or update cdk.json to match what is deployed." 

621 ) 

622 

623 

624@stacks.command("status") 

625@click.argument("stack_name") 

626@click.option("--region", "-r", required=True, help="AWS region") 

627@pass_config 

628def stack_status(config: Any, stack_name: Any, region: Any) -> None: 

629 """Get detailed status of a deployed stack. 

630 

631 For a base regional stack this also compares the configured EKS endpoint 

632 access (cdk.json eks_cluster) against the live cluster and reports any 

633 drift. 

634 """ 

635 from ..stacks import get_stack_manager 

636 

637 formatter = get_output_formatter(config) 

638 

639 try: 

640 manager = get_stack_manager(config) 

641 status = manager.get_stack_status(stack_name, region) 

642 

643 if status: 

644 formatter.print(status.to_dict()) 

645 _print_eks_endpoint_drift(formatter, config, str(stack_name), str(region)) 

646 else: 

647 formatter.print_error(f"Stack {stack_name} not found in {region}") 

648 sys.exit(1) 

649 

650 except Exception as e: 

651 formatter.print_error(f"Failed to get stack status: {e}") 

652 sys.exit(1) 

653 

654 

655@stacks.command("outputs") 

656@click.argument("stack_name") 

657@click.option("--region", "-r", required=True, help="AWS region") 

658@pass_config 

659def stack_outputs(config: Any, stack_name: Any, region: Any) -> None: 

660 """Get outputs from a deployed stack.""" 

661 from ..stacks import get_stack_manager 

662 

663 formatter = get_output_formatter(config) 

664 

665 try: 

666 manager = get_stack_manager(config) 

667 outputs = manager.get_outputs(stack_name, region) 

668 

669 if outputs: 

670 formatter.print(outputs) 

671 else: 

672 formatter.print_warning(f"No outputs found for {stack_name}") 

673 

674 except Exception as e: 

675 formatter.print_error(f"Failed to get outputs: {e}") 

676 sys.exit(1) 

677 

678 

679@stacks.command("access") 

680@click.option("--cluster", "-c", help="Cluster name (default: <project_name>-<region>)") 

681@click.option("--region", "-r", help="AWS region (default: first deployment region)") 

682@pass_config 

683def setup_access(config: Any, cluster: Any, region: Any) -> None: 

684 """Configure kubectl access to a GCO EKS cluster. 

685 

686 Updates kubeconfig, creates an EKS access entry for your IAM principal, 

687 and associates the cluster admin policy. Handles assumed roles automatically. 

688 

689 Examples: 

690 gco stacks access 

691 gco stacks access -r us-west-2 

692 gco stacks access -c my-cluster -r eu-west-1 

693 """ 

694 import subprocess 

695 

696 from .._image_uri import aws_partition 

697 from ..config import _load_cdk_json 

698 

699 formatter = get_output_formatter(config) 

700 

701 # Determine region 

702 if not region: 

703 cdk_regions = _load_cdk_json() 

704 if cdk_regions and "regional" in cdk_regions: 

705 region = cdk_regions["regional"][0] 

706 else: 

707 region = config.default_region or "us-east-1" 

708 

709 partition = aws_partition(str(region)) 

710 

711 # Determine cluster name 

712 if not cluster: 

713 cluster = f"{config.project_name}-{region}" 

714 

715 formatter.print_info(f"Setting up access to cluster: {cluster} in region: {region}") 

716 

717 # Cluster endpoint access mode — warn early if the API server is 

718 # private-only, since every kubectl call from outside the VPC will 

719 # fail. We still try every step so the access entry + policy 

720 # association land (those use the EKS control plane via boto3, 

721 # which doesn't go through the cluster endpoint), but the verify 

722 # step at the end will hit a connection timeout from the laptop. 

723 private_endpoint_only = False 

724 public_cidrs: list[str] = [] 

725 try: 

726 endpoint_check = subprocess.run( 

727 [ 

728 "aws", 

729 "eks", 

730 "describe-cluster", 

731 "--name", 

732 cluster, 

733 "--region", 

734 region, 

735 "--query", 

736 # Explicit ``+`` rather than implicit string concatenation 

737 # so static analysers don't flag the multi-line literal as 

738 # a possibly-missing comma between two list elements. The 

739 # value is one JMESPath expression passed as a single 

740 # ``--query`` argument. 

741 "cluster.resourcesVpcConfig.{public:endpointPublicAccess," 

742 + "private:endpointPrivateAccess,publicCidrs:publicAccessCidrs}", 

743 "--output", 

744 "json", 

745 ], 

746 check=True, 

747 capture_output=True, 

748 text=True, 

749 ) 

750 import json 

751 

752 endpoint_cfg = json.loads(endpoint_check.stdout or "{}") 

753 is_public = bool(endpoint_cfg.get("public")) 

754 public_cidrs = endpoint_cfg.get("publicCidrs") or [] 

755 if not is_public: 

756 private_endpoint_only = True 

757 formatter.print_warning( 

758 f"Cluster {cluster!r} has endpointPublicAccess=false — kubectl from " 

759 "outside the VPC will not be able to reach the API server. The access " 

760 "entry and policy association below still apply, but the verify step " 

761 "at the end will time out from this host." 

762 ) 

763 formatter.print_warning( 

764 "To enable kubectl from your laptop or CI runner, set " 

765 '``eks_cluster.endpoint_access`` to ``"PUBLIC_AND_PRIVATE"`` in ' 

766 "``cdk.json`` and redeploy the regional stack: ``gco stacks deploy " 

767 f"{config.project_name}-{region} -y``." 

768 ) 

769 elif public_cidrs: 

770 # Public access is on but restricted to a CIDR allowlist — the 

771 # caller's IP may or may not be in it. 

772 formatter.print_info( 

773 "Cluster API endpoint is public+private with a CIDR allowlist; " 

774 f"verify your egress IP is covered by one of: {', '.join(public_cidrs)}" 

775 ) 

776 except (subprocess.CalledProcessError, FileNotFoundError) as exc: 

777 # Don't block setup if describe-cluster fails — the access steps 

778 # below may still succeed (e.g. for a brand new cluster the caller 

779 # already has permission to update). 

780 formatter.print_info(f"Could not determine endpoint access mode: {exc}") 

781 

782 try: 

783 # Step 1: Update kubeconfig 

784 formatter.print_info("Updating kubeconfig...") 

785 subprocess.run( 

786 ["aws", "eks", "update-kubeconfig", "--name", cluster, "--region", region], 

787 check=True, 

788 capture_output=True, 

789 text=True, 

790 ) 

791 

792 # Step 2: Get IAM principal 

793 formatter.print_info("Getting your IAM principal...") 

794 result = subprocess.run( 

795 ["aws", "sts", "get-caller-identity", "--query", "Arn", "--output", "text"], 

796 check=True, 

797 capture_output=True, 

798 text=True, 

799 ) 

800 principal_arn = result.stdout.strip() 

801 formatter.print_info(f"Principal: {principal_arn}") 

802 

803 # Handle assumed roles — extract the role ARN from the assumed-role ARN 

804 if ":assumed-role/" in principal_arn: 

805 import re 

806 

807 role_name = re.search(r":assumed-role/([^/]+)/", principal_arn) 

808 if role_name: 

809 account_result = subprocess.run( 

810 [ 

811 "aws", 

812 "sts", 

813 "get-caller-identity", 

814 "--query", 

815 "Account", 

816 "--output", 

817 "text", 

818 ], 

819 check=True, 

820 capture_output=True, 

821 text=True, 

822 ) 

823 account_id = account_result.stdout.strip() 

824 principal_arn = f"arn:{partition}:iam::{account_id}:role/{role_name.group(1)}" 

825 formatter.print_info(f"Using role ARN: {principal_arn}") 

826 

827 # Step 3: Create access entry 

828 formatter.print_info("Creating EKS access entry...") 

829 try: 

830 subprocess.run( 

831 [ 

832 "aws", 

833 "eks", 

834 "create-access-entry", 

835 "--cluster-name", 

836 cluster, 

837 "--region", 

838 region, 

839 "--principal-arn", 

840 principal_arn, 

841 ], 

842 check=True, 

843 capture_output=True, 

844 text=True, 

845 ) 

846 except subprocess.CalledProcessError: 

847 formatter.print_info("Access entry may already exist") 

848 

849 # Step 4: Associate admin policy 

850 formatter.print_info("Associating cluster admin policy...") 

851 try: 

852 subprocess.run( 

853 [ 

854 "aws", 

855 "eks", 

856 "associate-access-policy", 

857 "--cluster-name", 

858 cluster, 

859 "--region", 

860 region, 

861 "--principal-arn", 

862 principal_arn, 

863 "--policy-arn", 

864 f"arn:{partition}:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy", 

865 "--access-scope", 

866 "type=cluster", 

867 ], 

868 check=True, 

869 capture_output=True, 

870 text=True, 

871 ) 

872 except subprocess.CalledProcessError: 

873 formatter.print_info("Policy may already be associated") 

874 

875 # Step 5: Verify access 

876 formatter.print_info("Waiting for permissions to propagate...") 

877 import time 

878 

879 time.sleep(10) 

880 

881 result = subprocess.run( 

882 ["kubectl", "get", "nodes", "--request-timeout=10s"], 

883 capture_output=True, 

884 text=True, 

885 ) 

886 if result.returncode == 0: 

887 node_count = len( 

888 [line for line in result.stdout.strip().split("\n")[1:] if line.strip()] 

889 ) 

890 print(result.stdout) 

891 formatter.print_info(f"Access configured successfully. {node_count} node(s) ready.") 

892 elif private_endpoint_only: 

893 # Don't double-warn — we already explained this above. Just 

894 # restate the fix so the operator doesn't have to scroll up. 

895 formatter.print_warning( 

896 "kubectl could not reach the API server, as expected for a " 

897 "private-only cluster from outside the VPC. The IAM access entry " 

898 "and admin policy association above did succeed, so kubectl will " 

899 "work from inside the VPC (e.g. SSM Session Manager into a node) " 

900 "or after redeploying with endpoint_access=PUBLIC_AND_PRIVATE." 

901 ) 

902 else: 

903 stderr = (result.stderr or "").strip() 

904 # When the laptop's egress IP isn't in the CIDR allowlist, AWS 

905 # returns the API server endpoint but kubectl times out at the 

906 # TLS handshake. Surface the same actionable hint as the 

907 # private-only case. 

908 looks_like_network_block = ( 

909 "i/o timeout" in stderr 

910 or "no route to host" in stderr 

911 or "connection refused" in stderr 

912 or "dial tcp" in stderr 

913 ) 

914 if looks_like_network_block: 

915 formatter.print_warning( 

916 "kubectl could not reach the API server. If the cluster's " 

917 "endpoint_access is restricted to a CIDR allowlist, confirm " 

918 "your egress IP is covered, or set endpoint_access to " 

919 '"PUBLIC_AND_PRIVATE" in cdk.json and run: gco stacks deploy ' 

920 f"{config.project_name}-{region} -y" 

921 ) 

922 else: 

923 formatter.print_warning( 

924 "kubectl connected but no nodes found (cluster may be scaling to zero)" 

925 ) 

926 

927 except subprocess.CalledProcessError as e: 

928 formatter.print_error(f"Command failed: {e.stderr or e.stdout or str(e)}") 

929 sys.exit(1) 

930 except FileNotFoundError as e: 

931 formatter.print_error(f"Required tool not found: {e}") 

932 sys.exit(1) 

933 except Exception as e: 

934 formatter.print_error(f"Failed to set up access: {e}") 

935 sys.exit(1) 

936 

937 

938# ============================================================================= 

939# EKS access configuration commands 

940# ============================================================================= 

941 

942 

943_CIDR_RE = re.compile(r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2})$") 

944 

945 

946def _valid_cidr(value: str) -> bool: 

947 """True for a syntactically valid IPv4 CIDR (octets 0-255, prefix 0-32).""" 

948 match = _CIDR_RE.match(value) 

949 if not match: 

950 return False 

951 octets = [int(part) for part in match.groups()[:4]] 

952 prefix = int(match.group(5)) 

953 return all(octet <= 255 for octet in octets) and prefix <= 32 

954 

955 

956@stacks.group("eks") 

957@pass_config 

958def eks_cmd(config: Any) -> None: 

959 """EKS cluster access configuration (cdk.json, synth-time only).""" 

960 

961 

962@eks_cmd.group("endpoint") 

963@pass_config 

964def eks_endpoint_cmd(config: Any) -> None: 

965 """EKS API endpoint access mode and CIDR allowlist.""" 

966 

967 

968@eks_endpoint_cmd.command("set") 

969@click.argument("mode", type=click.Choice(["PRIVATE", "PUBLIC_AND_PRIVATE"], case_sensitive=False)) 

970@click.option( 

971 "--cidr", 

972 "cidrs", 

973 multiple=True, 

974 metavar="CIDR", 

975 help=( 

976 "Public-endpoint CIDR allowlist entry (repeatable). Required for " 

977 "PUBLIC_AND_PRIVATE — widening access without an explicit allowlist is refused." 

978 ), 

979) 

980@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

981@pass_config 

982def eks_endpoint_set(config: Any, mode: str, cidrs: tuple[str, ...], yes: bool) -> None: 

983 """Set the EKS API endpoint access mode in cdk.json (audited, config only). 

984 

985 Synth-time only: nothing changes on AWS until 'gco stacks deploy'. Setting 

986 PUBLIC_AND_PRIVATE requires at least one --cidr — opening the control 

987 plane to 0.0.0.0/0 must be spelled out explicitly (--cidr 0.0.0.0/0), never 

988 implied. The configured value appears in 'gco stacks status' as config 

989 drift until the deploy converges the live endpoint. 

990 

991 Examples: 

992 gco stacks eks endpoint set PUBLIC_AND_PRIVATE --cidr 203.0.113.7/32 

993 gco stacks eks endpoint set PRIVATE -y 

994 """ 

995 formatter = get_output_formatter(config) 

996 normalized_mode = mode.upper() 

997 

998 if normalized_mode == "PUBLIC_AND_PRIVATE" and not cidrs: 

999 formatter.print_error( 

1000 "Refusing to widen the EKS API endpoint without an explicit CIDR " 

1001 "allowlist. Pass at least one --cidr (e.g. --cidr 203.0.113.7/32); " 

1002 "an internet-open endpoint must be spelled out as --cidr 0.0.0.0/0." 

1003 ) 

1004 sys.exit(1) 

1005 

1006 invalid = [cidr for cidr in cidrs if not _valid_cidr(cidr)] 

1007 if invalid: 

1008 formatter.print_error( 

1009 f"Invalid CIDR(s): {', '.join(invalid)} (expected e.g. 203.0.113.0/24)" 

1010 ) 

1011 sys.exit(1) 

1012 

1013 summary = f"eks_cluster.endpoint_access -> {normalized_mode}" 

1014 if cidrs: 

1015 summary += f", public_access_cidrs -> [{', '.join(cidrs)}]" 

1016 if normalized_mode == "PRIVATE" and cidrs: 

1017 formatter.print_info( 

1018 "Note: public_access_cidrs only takes effect while endpoint_access is " 

1019 "PUBLIC_AND_PRIVATE; storing the allowlist for a later flip." 

1020 ) 

1021 if not yes: 

1022 confirm(f"Update cdk.json: {summary}?", abort=True) 

1023 

1024 from ..stacks import update_eks_cluster_config 

1025 

1026 settings: dict[str, Any] = {"endpoint_access": normalized_mode} 

1027 if cidrs: 

1028 settings["public_access_cidrs"] = list(cidrs) 

1029 try: 

1030 update_eks_cluster_config(settings) 

1031 except RuntimeError as exc: 

1032 formatter.print_error(str(exc)) 

1033 sys.exit(1) 

1034 

1035 formatter.print_success(summary) 

1036 formatter.print_info( 

1037 "Config only — no stacks were deployed. Run 'gco stacks deploy " 

1038 f"{config.project_name}-<region> -y' per regional stack to apply, then " 

1039 "'gco stacks access' / 'gco cluster doctor' to verify access." 

1040 ) 

1041 

1042 

1043# ============================================================================= 

1044# Deployment-region commands (managed-config engine veneers) 

1045# ============================================================================= 

1046 

1047 

1048@stacks.group("regions") 

1049@pass_config 

1050def regions_cmd(config: Any) -> None: 

1051 """Manage workload deployment Regions in cdk.json. 

1052 

1053 These commands edit context.deployment_regions.regional through the 

1054 managed-config engine: validated against the same rules CDK synth 

1055 enforces, atomic, idempotent, and audited. They never deploy — run 

1056 'gco stacks deploy' afterwards to apply the change. 

1057 """ 

1058 pass 

1059 

1060 

1061@regions_cmd.command("list") 

1062@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1063@pass_config 

1064def regions_list(config: Any, config_path: Any) -> None: 

1065 """Show the configured deployment-region topology. 

1066 

1067 Reports the global/api_gateway/monitoring Regions, the workload Region 

1068 list, the resolved AWS partition, and the cdk.json path backing the 

1069 answer. On a broken configuration, partition_error explains what CDK 

1070 synth would reject. 

1071 """ 

1072 from ..managed_config import ManagedConfigError, get_deployment_regions_status 

1073 

1074 formatter = get_output_formatter(config) 

1075 

1076 try: 

1077 status = get_deployment_regions_status(config_path=config_path) 

1078 except ManagedConfigError as e: 

1079 formatter.print_error(str(e)) 

1080 sys.exit(1) 

1081 if config.output_format == "table": 

1082 # The table cell renderer collapses lists to "[N items]"; join for 

1083 # humans. JSON/YAML (the MCP path) keep the real list. 

1084 status["regional"] = ", ".join(status["regional"]) 

1085 formatter.print(status) 

1086 

1087 

1088@regions_cmd.command("add") 

1089@click.argument("region") 

1090@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1091@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1092@pass_config 

1093def regions_add(config: Any, region: Any, config_path: Any, yes: Any) -> None: 

1094 """Add a workload Region to deployment_regions.regional. 

1095 

1096 The Region must expose CloudFormation in the AWS SDK's endpoint data and 

1097 belong to the same AWS partition as the already-configured Regions. 

1098 Re-adding a present Region is a reported no-op. 

1099 

1100 Examples: 

1101 gco stacks regions add us-west-2 

1102 gco stacks regions add eu-west-1 -y 

1103 """ 

1104 from ..managed_config import ManagedConfigError, add_deployment_region 

1105 

1106 formatter = get_output_formatter(config) 

1107 

1108 if not yes: 

1109 confirm(f"Add {region} to deployment_regions.regional in cdk.json?", abort=True) 

1110 

1111 try: 

1112 report = add_deployment_region(region, config_path=config_path) 

1113 except ManagedConfigError as e: 

1114 formatter.print_error(str(e)) 

1115 sys.exit(1) 

1116 

1117 if report.changed: 

1118 formatter.print_success(report.summary()) 

1119 formatter.print_info( 

1120 "Config only — no stacks were deployed. " 

1121 f"Run 'gco stacks deploy {config.project_name}-{region}' (or 'gco stacks deploy-all') to apply" 

1122 ) 

1123 else: 

1124 formatter.print_info(report.summary()) 

1125 

1126 

1127@regions_cmd.command("remove") 

1128@click.argument("region") 

1129@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1130@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1131@pass_config 

1132def regions_remove(config: Any, region: Any, config_path: Any, yes: Any) -> None: 

1133 """Remove a workload Region from deployment_regions.regional. 

1134 

1135 The resulting list must stay valid (at least one Region). Removing an 

1136 absent Region is a reported no-op. Removing an unknown/typo'd entry from 

1137 a hand-edited config is allowed — validation applies to the result, so 

1138 this is also the repair path. 

1139 

1140 Examples: 

1141 gco stacks regions remove us-west-2 

1142 gco stacks regions remove xx-typo-1 -y 

1143 """ 

1144 from ..managed_config import ManagedConfigError, remove_deployment_region 

1145 

1146 formatter = get_output_formatter(config) 

1147 

1148 if not yes: 

1149 formatter.print_warning( 

1150 f"This only edits cdk.json — a deployed {config.project_name}-{region} " 

1151 "stack is NOT destroyed by this change." 

1152 ) 

1153 confirm(f"Remove {region} from deployment_regions.regional in cdk.json?", abort=True) 

1154 

1155 try: 

1156 report = remove_deployment_region(region, config_path=config_path) 

1157 except ManagedConfigError as e: 

1158 formatter.print_error(str(e)) 

1159 sys.exit(1) 

1160 

1161 if report.changed: 

1162 formatter.print_success(report.summary()) 

1163 formatter.print_info( 

1164 f"Config only — if {config.project_name}-{region} is deployed, destroy it " 

1165 f"explicitly with 'gco stacks destroy {config.project_name}-{region}'" 

1166 ) 

1167 else: 

1168 formatter.print_info(report.summary()) 

1169 

1170 

1171@regions_cmd.command("set") 

1172@click.argument("role", type=click.Choice(["global", "api_gateway", "monitoring"])) 

1173@click.argument("region") 

1174@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1175@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1176@pass_config 

1177def regions_set(config: Any, role: Any, region: Any, config_path: Any, yes: Any) -> None: 

1178 """Set a control-plane Region scalar (global/api_gateway/monitoring). 

1179 

1180 The Region must be SDK-known and keep the whole topology (all three 

1181 scalars plus the workload list) in one AWS partition. Setting the 

1182 current value is a reported no-op. 

1183 

1184 Examples: 

1185 gco stacks regions set monitoring us-west-2 

1186 gco stacks regions set global us-east-2 -y 

1187 """ 

1188 from ..managed_config import ManagedConfigError, set_deployment_region_role 

1189 

1190 formatter = get_output_formatter(config) 

1191 

1192 if not yes: 

1193 formatter.print_warning( 

1194 "This only edits cdk.json — already-deployed stacks are not moved " 

1195 "or destroyed; the next deploy creates the stack in the new Region." 

1196 ) 

1197 confirm(f"Set deployment_regions.{role} to {region} in cdk.json?", abort=True) 

1198 

1199 try: 

1200 report = set_deployment_region_role(role, region, config_path=config_path) 

1201 except ManagedConfigError as e: 

1202 formatter.print_error(str(e)) 

1203 sys.exit(1) 

1204 

1205 if report.changed: 

1206 formatter.print_success(report.summary()) 

1207 formatter.print_info( 

1208 "Config only — no stacks were deployed. Run 'gco stacks deploy-all' to apply, " 

1209 "and clean up the stack in the previous Region yourself if it was deployed" 

1210 ) 

1211 else: 

1212 formatter.print_info(report.summary()) 

1213 

1214 

1215# ============================================================================= 

1216# Bedrock model default (managed-config engine veneer) 

1217# ============================================================================= 

1218 

1219 

1220@stacks.group("bedrock") 

1221@pass_config 

1222def bedrock_cmd(config: Any) -> None: 

1223 """Manage Bedrock model and reasoning defaults in cdk.json. 

1224 

1225 Four independent model keys serve Mission sampling, the capacity advisor, 

1226 Claude Code, and Codex. Codex also owns a reviewed reasoning-effort sibling. 

1227 Every edit uses the shared managed-config engine: validated, atomic, 

1228 idempotent, and audited. 

1229 """ 

1230 pass 

1231 

1232 

1233@bedrock_cmd.command("show") 

1234@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1235@pass_config 

1236def bedrock_show(config: Any, config_path: Any) -> None: 

1237 """Show every managed Bedrock model/reasoning default and its path.""" 

1238 from ..managed_config import ManagedConfigError, get_bedrock_model_status 

1239 

1240 formatter = get_output_formatter(config) 

1241 

1242 try: 

1243 status = get_bedrock_model_status(config_path=config_path) 

1244 except ManagedConfigError as e: 

1245 formatter.print_error(str(e)) 

1246 sys.exit(1) 

1247 formatter.print(status) 

1248 

1249 

1250@bedrock_cmd.command("set-mission-model") 

1251@click.argument("model_id") 

1252@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1253@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1254@pass_config 

1255def bedrock_set_mission_model(config: Any, model_id: Any, config_path: Any, yes: Any) -> None: 

1256 """Set context.bedrock.mission_default_model_id (Mission sampling). 

1257 

1258 This is the default Mission sampling uses; the capacity advisor and 

1259 `gco autopilot` have their own keys (see set-capacity-advisor-model and 

1260 set-claude-code-model). Model and inference-profile IDs are free-form 

1261 (custom profiles, marketplace models), so validation mirrors the runtime 

1262 reader: a non-empty string without surrounding whitespace. Sibling 

1263 settings (bedrock.generation_reasoning, the other model keys) are preserved. 

1264 

1265 Examples: 

1266 gco stacks bedrock set-mission-model us.amazon.nova-pro-v1:0 

1267 gco stacks bedrock set-mission-model us.amazon.nova-2-lite-v1:0 -y 

1268 """ 

1269 from ..managed_config import ManagedConfigError, set_mission_default_model 

1270 

1271 formatter = get_output_formatter(config) 

1272 

1273 if not yes: 

1274 confirm(f"Set bedrock.mission_default_model_id to {model_id} in cdk.json?", abort=True) 

1275 

1276 try: 

1277 report = set_mission_default_model(model_id, config_path=config_path) 

1278 except ManagedConfigError as e: 

1279 formatter.print_error(str(e)) 

1280 sys.exit(1) 

1281 

1282 if report.changed: 

1283 formatter.print_success(report.summary()) 

1284 formatter.print_info( 

1285 "Mission sampling picks this up on its next run; explicit " 

1286 "--bedrock-model-id/env overrides still take precedence" 

1287 ) 

1288 else: 

1289 formatter.print_info(report.summary()) 

1290 

1291 

1292@bedrock_cmd.command("set-capacity-advisor-model") 

1293@click.argument("model_id") 

1294@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1295@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1296@pass_config 

1297def bedrock_set_capacity_advisor_model( 

1298 config: Any, model_id: Any, config_path: Any, yes: Any 

1299) -> None: 

1300 """Set context.bedrock.capacity_advisor_default_model_id. 

1301 

1302 This is the default `gco capacity advise` (and its historical variant) 

1303 uses; Mission sampling and `gco autopilot` have their own keys (see 

1304 set-mission-model and set-claude-code-model). Model and inference-profile 

1305 IDs are free-form (custom profiles, marketplace models), so validation 

1306 mirrors the runtime reader: a non-empty string without surrounding 

1307 whitespace. Sibling settings (bedrock.generation_reasoning, the other model keys) 

1308 are preserved. 

1309 

1310 Examples: 

1311 gco stacks bedrock set-capacity-advisor-model us.amazon.nova-pro-v1:0 

1312 gco stacks bedrock set-capacity-advisor-model us.amazon.nova-2-lite-v1:0 -y 

1313 """ 

1314 from ..managed_config import ManagedConfigError, set_capacity_advisor_default_model 

1315 

1316 formatter = get_output_formatter(config) 

1317 

1318 if not yes: 

1319 confirm( 

1320 f"Set bedrock.capacity_advisor_default_model_id to {model_id} in cdk.json?", 

1321 abort=True, 

1322 ) 

1323 

1324 try: 

1325 report = set_capacity_advisor_default_model(model_id, config_path=config_path) 

1326 except ManagedConfigError as e: 

1327 formatter.print_error(str(e)) 

1328 sys.exit(1) 

1329 

1330 if report.changed: 

1331 formatter.print_success(report.summary()) 

1332 formatter.print_info( 

1333 "The capacity advisor picks this up on its next run; explicit " 

1334 "--model overrides still take precedence" 

1335 ) 

1336 else: 

1337 formatter.print_info(report.summary()) 

1338 

1339 

1340@bedrock_cmd.command("set-claude-code-model") 

1341@click.argument("model_id") 

1342@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1343@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1344@pass_config 

1345def bedrock_set_claude_code_model(config: Any, model_id: Any, config_path: Any, yes: Any) -> None: 

1346 """Set context.bedrock.claude_code_default_model_id. 

1347 

1348 This is the session model `gco autopilot` hands to Claude Code, kept 

1349 separate from the generation defaults (see set-mission-model and 

1350 set-capacity-advisor-model) so repointing the interactive agent never 

1351 repoints Mission sampling or the capacity advisor. Validation mirrors the runtime reader: a non-empty string 

1352 without surrounding whitespace. Sibling settings are preserved. 

1353 

1354 Examples: 

1355 gco stacks bedrock set-claude-code-model us.anthropic.claude-sonnet-4-6 

1356 gco stacks bedrock set-claude-code-model us.anthropic.claude-opus-4-7 -y 

1357 """ 

1358 from ..managed_config import ManagedConfigError, set_claude_code_default_model 

1359 

1360 formatter = get_output_formatter(config) 

1361 

1362 if not yes: 

1363 confirm( 

1364 f"Set bedrock.claude_code_default_model_id to {model_id} in cdk.json?", 

1365 abort=True, 

1366 ) 

1367 

1368 try: 

1369 report = set_claude_code_default_model(model_id, config_path=config_path) 

1370 except ManagedConfigError as e: 

1371 formatter.print_error(str(e)) 

1372 sys.exit(1) 

1373 

1374 if report.changed: 

1375 formatter.print_success(report.summary()) 

1376 formatter.print_info( 

1377 "New autopilot sessions pick this up at launch; explicit " 

1378 "--model/GCO_AUTOPILOT_MODEL overrides still take precedence" 

1379 ) 

1380 else: 

1381 formatter.print_info(report.summary()) 

1382 

1383 

1384@bedrock_cmd.command("set-codex-model") 

1385@click.argument("model_id") 

1386@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1387@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1388@pass_config 

1389def bedrock_set_codex_model(config: Any, model_id: Any, config_path: Any, yes: Any) -> None: 

1390 """Set context.bedrock.codex_default_model_id. 

1391 

1392 This is the canonical model for Codex Autopilot sessions. The reviewed 

1393 context.bedrock.codex.reasoning_effort remains independent and is preserved; 

1394 review that pair together when changing model families. Explicit --model, 

1395 GCO_AUTOPILOT_CODEX_MODEL, and GCO_AUTOPILOT_MODEL overrides still win. 

1396 

1397 Examples: 

1398 gco stacks bedrock set-codex-model global.openai.<model-id> 

1399 gco stacks bedrock set-codex-model global.openai.<model-id> -y 

1400 """ 

1401 from ..managed_config import ManagedConfigError, set_codex_default_model 

1402 

1403 formatter = get_output_formatter(config) 

1404 

1405 if not yes: 

1406 confirm( 

1407 f"Set bedrock.codex_default_model_id to {model_id} in cdk.json?", 

1408 abort=True, 

1409 ) 

1410 

1411 try: 

1412 report = set_codex_default_model(model_id, config_path=config_path) 

1413 except ManagedConfigError as e: 

1414 formatter.print_error(str(e)) 

1415 sys.exit(1) 

1416 

1417 if report.changed: 

1418 formatter.print_success(report.summary()) 

1419 formatter.print_info( 

1420 "Canonical Codex sessions pick this up at launch; explicit " 

1421 "--model/GCO_AUTOPILOT_CODEX_MODEL/GCO_AUTOPILOT_MODEL overrides " 

1422 "still take precedence" 

1423 ) 

1424 else: 

1425 formatter.print_info(report.summary()) 

1426 

1427 

1428@bedrock_cmd.command("set-codex-reasoning-effort") 

1429@click.argument( 

1430 "reasoning_effort", 

1431 type=click.Choice(["minimal", "low", "medium", "high", "xhigh"], case_sensitive=True), 

1432) 

1433@click.option("--config-path", help="Explicit cdk.json to use (default: nearest in cwd/parents)") 

1434@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1435@pass_config 

1436def bedrock_set_codex_reasoning_effort( 

1437 config: Any, reasoning_effort: Any, config_path: Any, yes: Any 

1438) -> None: 

1439 """Set context.bedrock.codex.reasoning_effort. 

1440 

1441 The effort applies only when Codex uses the canonical default model; any 

1442 explicit model override omits it. Allowed values are minimal, low, medium, 

1443 high, and xhigh. 

1444 

1445 Examples: 

1446 gco stacks bedrock set-codex-reasoning-effort high 

1447 gco stacks bedrock set-codex-reasoning-effort xhigh -y 

1448 """ 

1449 from ..managed_config import ManagedConfigError, set_codex_reasoning_effort 

1450 

1451 formatter = get_output_formatter(config) 

1452 

1453 if not yes: 

1454 confirm( 

1455 f"Set bedrock.codex.reasoning_effort to {reasoning_effort} in cdk.json?", 

1456 abort=True, 

1457 ) 

1458 

1459 try: 

1460 report = set_codex_reasoning_effort(reasoning_effort, config_path=config_path) 

1461 except ManagedConfigError as e: 

1462 formatter.print_error(str(e)) 

1463 sys.exit(1) 

1464 

1465 if report.changed: 

1466 formatter.print_success(report.summary()) 

1467 formatter.print_info( 

1468 "Canonical Codex sessions pick this up at launch; explicit model " 

1469 "overrides intentionally omit canonical reasoning" 

1470 ) 

1471 else: 

1472 formatter.print_info(report.summary()) 

1473 

1474 

1475# ============================================================================= 

1476# FSx commands 

1477# ============================================================================= 

1478 

1479 

1480@stacks.group("fsx") 

1481@pass_config 

1482def fsx_cmd(config: Any) -> None: 

1483 """Manage FSx for Lustre configuration.""" 

1484 pass 

1485 

1486 

1487@fsx_cmd.command("status") 

1488@click.option("--region", "-r", help="Show config for specific region") 

1489@pass_config 

1490def fsx_status(config: Any, region: Any) -> None: 

1491 """Show current FSx for Lustre configuration status.""" 

1492 from ..stacks import get_fsx_config 

1493 

1494 formatter = get_output_formatter(config) 

1495 

1496 try: 

1497 fsx_config = get_fsx_config(region) 

1498 if region: 

1499 formatter.print_info(f"FSx config for region: {region}") 

1500 else: 

1501 formatter.print_info("Global FSx config:") 

1502 formatter.print(fsx_config) 

1503 except Exception as e: 

1504 formatter.print_error(f"Failed to get FSx config: {e}") 

1505 sys.exit(1) 

1506 

1507 

1508@fsx_cmd.command("enable") 

1509@click.option("--region", "-r", help="Enable FSx for specific region only") 

1510@click.option("--storage-capacity", "-s", default=1200, help="Storage capacity in GiB (min 1200)") 

1511@click.option( 

1512 "--deployment-type", 

1513 "-d", 

1514 type=click.Choice(["SCRATCH_1", "SCRATCH_2", "PERSISTENT_1", "PERSISTENT_2"]), 

1515 default="SCRATCH_2", 

1516 help="FSx deployment type", 

1517) 

1518@click.option("--throughput", "-t", default=200, help="Per-unit storage throughput (MB/s)") 

1519@click.option("--compression", "-c", type=click.Choice(["LZ4", "NONE"]), default="LZ4") 

1520@click.option("--import-path", help="S3 path for data import (s3://bucket/prefix)") 

1521@click.option("--export-path", help="S3 path for data export (s3://bucket/prefix)") 

1522@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1523@pass_config 

1524def fsx_enable( 

1525 config: Any, 

1526 region: Any, 

1527 storage_capacity: Any, 

1528 deployment_type: Any, 

1529 throughput: Any, 

1530 compression: Any, 

1531 import_path: Any, 

1532 export_path: Any, 

1533 yes: Any, 

1534) -> None: 

1535 """Enable FSx for Lustre in the stack configuration. 

1536 

1537 FSx for Lustre provides high-performance parallel file system storage 

1538 ideal for ML training workloads requiring high throughput and low latency. 

1539 

1540 Examples: 

1541 gco stacks fsx enable 

1542 gco stacks fsx enable --region us-east-1 

1543 gco stacks fsx enable --storage-capacity 2400 --deployment-type PERSISTENT_2 

1544 gco stacks fsx enable -r us-west-2 --import-path s3://my-bucket/training-data 

1545 """ 

1546 from ..stacks import update_fsx_config 

1547 

1548 formatter = get_output_formatter(config) 

1549 

1550 if storage_capacity < 1200: 

1551 formatter.print_error("Storage capacity must be at least 1200 GiB") 

1552 sys.exit(1) 

1553 

1554 scope = f"region {region}" if region else "all regions (global)" 

1555 

1556 if not yes: 

1557 formatter.print_info(f"FSx for Lustre configuration for {scope}:") 

1558 formatter.print_info(f" Storage Capacity: {storage_capacity} GiB") 

1559 formatter.print_info(f" Deployment Type: {deployment_type}") 

1560 formatter.print_info(f" Throughput: {throughput} MB/s per TiB") 

1561 formatter.print_info(f" Compression: {compression}") 

1562 if import_path: 

1563 formatter.print_info(f" Import Path: {import_path}") 

1564 if export_path: 

1565 formatter.print_info(f" Export Path: {export_path}") 

1566 confirm(f"\nEnable FSx for Lustre for {scope}?", abort=True) 

1567 

1568 try: 

1569 fsx_settings = { 

1570 "enabled": True, 

1571 "storage_capacity_gib": storage_capacity, 

1572 "deployment_type": deployment_type, 

1573 "per_unit_storage_throughput": throughput, 

1574 "data_compression_type": compression, 

1575 "import_path": import_path, 

1576 "export_path": export_path, 

1577 "auto_import_policy": "NEW_CHANGED_DELETED" if import_path else None, 

1578 } 

1579 

1580 update_fsx_config(fsx_settings, region) 

1581 formatter.print_success(f"FSx for Lustre enabled in cdk.json for {scope}") 

1582 if region: 

1583 formatter.print_info( 

1584 f"Run 'gco stacks deploy {config.project_name}-{region}' to apply changes" 

1585 ) 

1586 else: 

1587 formatter.print_info("Run 'gco stacks deploy' to apply changes") 

1588 

1589 except Exception as e: 

1590 formatter.print_error(f"Failed to enable FSx: {e}") 

1591 sys.exit(1) 

1592 

1593 

1594@fsx_cmd.command("disable") 

1595@click.option("--region", "-r", help="Disable FSx for specific region only") 

1596@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1597@pass_config 

1598def fsx_disable(config: Any, region: Any, yes: Any) -> None: 

1599 """Disable FSx for Lustre in the stack configuration. 

1600 

1601 Note: This only updates the configuration. Run 'gco stacks deploy' 

1602 to apply changes. Existing FSx file systems will be deleted. 

1603 

1604 Examples: 

1605 gco stacks fsx disable 

1606 gco stacks fsx disable --region us-east-1 

1607 """ 

1608 from ..stacks import update_fsx_config 

1609 

1610 formatter = get_output_formatter(config) 

1611 

1612 scope = f"region {region}" if region else "all regions (global)" 

1613 

1614 if not yes: 

1615 formatter.print_warning(f"This will disable FSx for Lustre for {scope}.") 

1616 formatter.print_warning("Existing FSx file systems will be deleted on next deploy.") 

1617 confirm("Are you sure?", abort=True) 

1618 

1619 try: 

1620 update_fsx_config({"enabled": False}, region) 

1621 formatter.print_success(f"FSx for Lustre disabled in cdk.json for {scope}") 

1622 if region: 

1623 formatter.print_info( 

1624 f"Run 'gco stacks deploy {config.project_name}-{region}' to apply changes" 

1625 ) 

1626 else: 

1627 formatter.print_info("Run 'gco stacks deploy' to apply changes") 

1628 

1629 except Exception as e: 

1630 formatter.print_error(f"Failed to disable FSx: {e}") 

1631 sys.exit(1) 

1632 

1633 

1634# ============================================================================= 

1635# Valkey commands 

1636# ============================================================================= 

1637 

1638 

1639@stacks.group("valkey") 

1640@pass_config 

1641def valkey_cmd(config: Any) -> None: 

1642 """Manage Valkey Serverless cache configuration.""" 

1643 pass 

1644 

1645 

1646@valkey_cmd.command("status") 

1647@pass_config 

1648def valkey_status(config: Any) -> None: 

1649 """Show current Valkey Serverless configuration status.""" 

1650 from ..stacks import get_valkey_config 

1651 

1652 formatter = get_output_formatter(config) 

1653 

1654 try: 

1655 valkey_config = get_valkey_config() 

1656 formatter.print_info("Valkey config:") 

1657 formatter.print(valkey_config) 

1658 except Exception as e: 

1659 formatter.print_error(f"Failed to get Valkey config: {e}") 

1660 sys.exit(1) 

1661 

1662 

1663@valkey_cmd.command("enable") 

1664@click.option("--max-storage", default=5, help="Max data storage in GB (default: 5)") 

1665@click.option("--max-ecpu", default=5000, help="Max eCPU per second (default: 5000)") 

1666@click.option("--snapshot-retention", default=1, help="Snapshot retention in days (default: 1)") 

1667@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1668@pass_config 

1669def valkey_enable( 

1670 config: Any, 

1671 max_storage: Any, 

1672 max_ecpu: Any, 

1673 snapshot_retention: Any, 

1674 yes: Any, 

1675) -> None: 

1676 """Enable Valkey Serverless cache in the stack configuration. 

1677 

1678 Valkey provides a serverless key-value cache for prompt caching, 

1679 feature stores, session state, and low-latency data access. 

1680 

1681 Examples: 

1682 gco stacks valkey enable 

1683 gco stacks valkey enable --max-storage 10 --max-ecpu 10000 

1684 """ 

1685 from ..stacks import update_valkey_config 

1686 

1687 formatter = get_output_formatter(config) 

1688 

1689 if not yes: 

1690 formatter.print_info("Valkey Serverless configuration:") 

1691 formatter.print_info(f" Max Data Storage: {max_storage} GB") 

1692 formatter.print_info(f" Max eCPU/second: {max_ecpu}") 

1693 formatter.print_info(f" Snapshot Retention: {snapshot_retention} days") 

1694 confirm("\nEnable Valkey Serverless?", abort=True) 

1695 

1696 try: 

1697 valkey_settings = { 

1698 "enabled": True, 

1699 "max_data_storage_gb": max_storage, 

1700 "max_ecpu_per_second": max_ecpu, 

1701 "snapshot_retention_limit": snapshot_retention, 

1702 } 

1703 

1704 update_valkey_config(valkey_settings) 

1705 formatter.print_success("Valkey Serverless enabled in cdk.json") 

1706 formatter.print_info("Run 'gco stacks deploy-all -y' to apply changes") 

1707 

1708 except Exception as e: 

1709 formatter.print_error(f"Failed to enable Valkey: {e}") 

1710 sys.exit(1) 

1711 

1712 

1713@valkey_cmd.command("disable") 

1714@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1715@pass_config 

1716def valkey_disable(config: Any, yes: Any) -> None: 

1717 """Disable Valkey Serverless cache in the stack configuration. 

1718 

1719 Note: This only updates the configuration. Run 'gco stacks deploy-all -y' 

1720 to apply changes. Existing Valkey caches will be deleted. 

1721 

1722 Examples: 

1723 gco stacks valkey disable 

1724 """ 

1725 from ..stacks import update_valkey_config 

1726 

1727 formatter = get_output_formatter(config) 

1728 

1729 if not yes: 

1730 formatter.print_warning("This will disable Valkey Serverless.") 

1731 formatter.print_warning("Existing Valkey caches will be deleted on next deploy.") 

1732 confirm("Are you sure?", abort=True) 

1733 

1734 try: 

1735 update_valkey_config({"enabled": False}) 

1736 formatter.print_success("Valkey Serverless disabled in cdk.json") 

1737 formatter.print_info("Run 'gco stacks deploy-all -y' to apply changes") 

1738 

1739 except Exception as e: 

1740 formatter.print_error(f"Failed to disable Valkey: {e}") 

1741 sys.exit(1) 

1742 

1743 

1744# ============================================================================= 

1745# Aurora pgvector commands 

1746# ============================================================================= 

1747 

1748 

1749@stacks.group("aurora") 

1750@pass_config 

1751def aurora_cmd(config: Any) -> None: 

1752 """Manage Aurora PostgreSQL (pgvector) configuration.""" 

1753 pass 

1754 

1755 

1756@aurora_cmd.command("status") 

1757@pass_config 

1758def aurora_status(config: Any) -> None: 

1759 """Show current Aurora PostgreSQL (pgvector) configuration status.""" 

1760 from ..stacks import get_aurora_config 

1761 

1762 formatter = get_output_formatter(config) 

1763 

1764 try: 

1765 aurora_config = get_aurora_config() 

1766 formatter.print_info("Aurora pgvector config:") 

1767 formatter.print(aurora_config) 

1768 except Exception as e: 

1769 formatter.print_error(f"Failed to get Aurora config: {e}") 

1770 sys.exit(1) 

1771 

1772 

1773@aurora_cmd.command("enable") 

1774@click.option("--min-acu", default=0, help="Minimum ACU (0 = scale to zero, default: 0)") 

1775@click.option("--max-acu", default=16, help="Maximum ACU (default: 16)") 

1776@click.option("--backup-retention", default=7, help="Backup retention in days (default: 7)") 

1777@click.option( 

1778 "--deletion-protection/--no-deletion-protection", 

1779 default=False, 

1780 help="Enable deletion protection", 

1781) 

1782@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1783@pass_config 

1784def aurora_enable( 

1785 config: Any, 

1786 min_acu: Any, 

1787 max_acu: Any, 

1788 backup_retention: Any, 

1789 deletion_protection: Any, 

1790 yes: Any, 

1791) -> None: 

1792 """Enable Aurora PostgreSQL with pgvector in the stack configuration. 

1793 

1794 Aurora Serverless v2 with pgvector provides vector similarity search 

1795 for RAG applications, semantic search, and embedding storage. 

1796 

1797 Examples: 

1798 gco stacks aurora enable 

1799 gco stacks aurora enable --min-acu 2 --max-acu 32 --deletion-protection 

1800 """ 

1801 from ..stacks import update_aurora_config 

1802 

1803 formatter = get_output_formatter(config) 

1804 

1805 if min_acu < 0: 

1806 formatter.print_error("Minimum ACU must be >= 0") 

1807 sys.exit(1) 

1808 if max_acu < 1: 

1809 formatter.print_error("Maximum ACU must be >= 1") 

1810 sys.exit(1) 

1811 if max_acu < min_acu: 

1812 formatter.print_error("Maximum ACU must be >= minimum ACU") 

1813 sys.exit(1) 

1814 

1815 if not yes: 

1816 formatter.print_info("Aurora pgvector configuration:") 

1817 formatter.print_info(f" Min ACU: {min_acu} {'(scale to zero)' if min_acu == 0 else ''}") 

1818 formatter.print_info(f" Max ACU: {max_acu}") 

1819 formatter.print_info(f" Backup Retention: {backup_retention} days") 

1820 formatter.print_info(f" Deletion Protection: {deletion_protection}") 

1821 confirm("\nEnable Aurora pgvector?", abort=True) 

1822 

1823 try: 

1824 aurora_settings = { 

1825 "enabled": True, 

1826 "min_acu": min_acu, 

1827 "max_acu": max_acu, 

1828 "backup_retention_days": backup_retention, 

1829 "deletion_protection": deletion_protection, 

1830 } 

1831 

1832 update_aurora_config(aurora_settings) 

1833 formatter.print_success("Aurora pgvector enabled in cdk.json") 

1834 formatter.print_info("Run 'gco stacks deploy-all -y' to apply changes") 

1835 

1836 except Exception as e: 

1837 formatter.print_error(f"Failed to enable Aurora: {e}") 

1838 sys.exit(1) 

1839 

1840 

1841@aurora_cmd.command("disable") 

1842@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

1843@pass_config 

1844def aurora_disable(config: Any, yes: Any) -> None: 

1845 """Disable Aurora PostgreSQL (pgvector) in the stack configuration. 

1846 

1847 Note: This only updates the configuration. Run 'gco stacks deploy-all -y' 

1848 to apply changes. Existing Aurora clusters will be deleted unless 

1849 deletion protection is enabled. 

1850 

1851 Examples: 

1852 gco stacks aurora disable 

1853 """ 

1854 from ..stacks import update_aurora_config 

1855 

1856 formatter = get_output_formatter(config) 

1857 

1858 if not yes: 

1859 formatter.print_warning("This will disable Aurora pgvector.") 

1860 formatter.print_warning( 

1861 "Existing Aurora clusters will be deleted on next deploy " 

1862 "(unless deletion protection is enabled)." 

1863 ) 

1864 confirm("Are you sure?", abort=True) 

1865 

1866 try: 

1867 update_aurora_config({"enabled": False}) 

1868 formatter.print_success("Aurora pgvector disabled in cdk.json") 

1869 formatter.print_info("Run 'gco stacks deploy-all -y' to apply changes") 

1870 

1871 except Exception as e: 

1872 formatter.print_error(f"Failed to disable Aurora: {e}") 

1873 sys.exit(1) 

1874 

1875 

1876def _project_name() -> str: 

1877 """Read project_name from cdk.json context (default 'gco').""" 

1878 import json 

1879 from pathlib import Path 

1880 

1881 try: 

1882 with open(Path.cwd() / "cdk.json", encoding="utf-8") as f: 

1883 document = json.load(f) 

1884 if not isinstance(document, dict): 

1885 return "gco" 

1886 ctx = document.get("context") 

1887 if not isinstance(ctx, dict): 

1888 return "gco" 

1889 return str(ctx.get("project_name") or "gco") 

1890 except OSError, ValueError: 

1891 return "gco" 

1892 

1893 

1894def _target_regions(config: Any, region: Any, all_regions: bool) -> list[str]: 

1895 """Resolve which regions a command acts on. 

1896 

1897 ``--all-regions`` returns every configured regional deployment region; 

1898 otherwise an explicit ``--region``, else the first regional region, else 

1899 the configured default. 

1900 """ 

1901 cdk_regions = _load_cdk_json() 

1902 regional = ( 

1903 list(cdk_regions["regional"]) if (cdk_regions and cdk_regions.get("regional")) else [] 

1904 ) 

1905 

1906 if all_regions: 

1907 return regional 

1908 if region: 

1909 return [str(region)] 

1910 if regional: 

1911 return [str(regional[0])] 

1912 return [str(config.default_region or "us-east-1")] 

1913 

1914 

1915@stacks.group("addons") 

1916@pass_config 

1917def addons_cmd(config: Any) -> None: 

1918 """Inspect and re-converge cluster add-ons (Helm charts). 

1919 

1920 Add-on installation is decoupled from the CloudFormation rollback path: a 

1921 chart that fails to install never rolls back the cluster. Use these commands 

1922 to see per-chart status and re-run the installer without a full redeploy. 

1923 """ 

1924 pass 

1925 

1926 

1927@addons_cmd.command("status") 

1928@click.option("--region", "-r", help="AWS region (default: first deployment region)") 

1929@click.option("--all-regions", "-A", is_flag=True, help="Show status across all deployment regions") 

1930@pass_config 

1931def addons_status(config: Any, region: Any, all_regions: bool) -> None: 

1932 """Show per-chart add-on install status (from SSM). 

1933 

1934 Examples: 

1935 gco stacks addons status 

1936 gco stacks addons status -r us-west-2 

1937 gco stacks addons status --all-regions 

1938 """ 

1939 formatter = get_output_formatter(config) 

1940 project = _project_name() 

1941 for target in _target_regions(config, region, all_regions): 

1942 _addons_status_one(formatter, project, target) 

1943 

1944 

1945def _addons_status_one(formatter: Any, project: str, region: str) -> None: 

1946 """Print the add-on status table for a single region.""" 

1947 import json 

1948 

1949 import boto3 

1950 

1951 prefix = f"/{project}/addons/{region}/" 

1952 

1953 try: 

1954 ssm = boto3.client("ssm", region_name=region) 

1955 params: list[dict[str, Any]] = [] 

1956 paginator = ssm.get_paginator("get_parameters_by_path") 

1957 for page in paginator.paginate(Path=prefix, Recursive=False): 

1958 params.extend(page.get("Parameters", [])) 

1959 except Exception as e: 

1960 formatter.print_error(f"[{region}] Failed to read add-on status from SSM: {e}") 

1961 return 

1962 

1963 rows = [] 

1964 for p in params: 

1965 name = p["Name"].rsplit("/", 1)[-1] 

1966 if name == "_input": 

1967 continue 

1968 try: 

1969 data = json.loads(p["Value"]) 

1970 except ValueError: 

1971 data = {"status": "unknown", "message": p.get("Value", "")} 

1972 if not isinstance(data, dict): 

1973 data = {"status": "unknown", "message": p.get("Value", "")} 

1974 status = str(data.get("status", "unknown")) 

1975 message = str(data.get("message", "")) 

1976 rows.append((name, status, message[:80])) 

1977 

1978 if not rows: 

1979 formatter.print_info( 

1980 f"[{region}] No add-on status recorded under {prefix} yet. " 

1981 "The installer writes status as charts are processed." 

1982 ) 

1983 return 

1984 

1985 rows.sort() 

1986 formatter.print_info(f"Add-on status for {project} in {region}:") 

1987 for name, status, message in rows: 

1988 line = f" {name:<28} {status:<12} {message}" 

1989 if status in ("installed", "uninstalled", "absent", "applied"): 

1990 formatter.print_success(line) 

1991 else: 

1992 formatter.print_error(line) 

1993 

1994 

1995@addons_cmd.command("install") 

1996@click.option("--region", "-r", help="AWS region (default: first deployment region)") 

1997@click.option( 

1998 "--all-regions", "-A", is_flag=True, help="Re-converge add-ons in all deployment regions" 

1999) 

2000@pass_config 

2001def addons_install(config: Any, region: Any, all_regions: bool) -> None: 

2002 """Re-run the Helm add-on installer (idempotent; never rolls back the cluster). 

2003 

2004 Replays the last execution input persisted by the deploy, so chart config 

2005 and IAM role wiring stay in one place. Use this to re-converge after a 

2006 transient failure instead of a full stack redeploy. 

2007 

2008 Examples: 

2009 gco stacks addons install 

2010 gco stacks addons install -r us-west-2 

2011 gco stacks addons install --all-regions 

2012 """ 

2013 formatter = get_output_formatter(config) 

2014 project = _project_name() 

2015 failures = 0 

2016 for target in _target_regions(config, region, all_regions): 

2017 if not _addons_install_one(formatter, project, target): 

2018 failures += 1 

2019 if failures: 

2020 sys.exit(1) 

2021 

2022 

2023def _decode_addon_replay_input(stored_value: str) -> str: 

2024 """Reverse the helm orchestrator's zlib+base64 replay-input encoding. 

2025 

2026 The orchestrator stores the execution input encoded because SSM rejects 

2027 raw ``{{PLACEHOLDER}}`` tokens (see lambda/helm-orchestrator/handler.py). 

2028 A leading ``{`` means a raw legacy JSON value; pass it through unchanged. 

2029 """ 

2030 import base64 

2031 import zlib 

2032 

2033 if stored_value.lstrip().startswith("{"): 

2034 return stored_value 

2035 compressed = base64.b64decode(stored_value.encode("ascii"), validate=True) 

2036 return zlib.decompress(compressed).decode("utf-8") 

2037 

2038 

2039def _addons_install_one(formatter: Any, project: str, region: str) -> bool: 

2040 """Start an add-on install for a single region. Returns True on success.""" 

2041 import boto3 

2042 from botocore.exceptions import ClientError 

2043 

2044 input_param = f"/{project}/addons/{region}/_input" 

2045 fence_param = f"/{project}/addons/{region}/_teardown" 

2046 

2047 try: 

2048 ssm = boto3.client("ssm", region_name=region) 

2049 try: 

2050 ssm.get_parameter(Name=fence_param) 

2051 except ClientError as exc: 

2052 if exc.response.get("Error", {}).get("Code") != "ParameterNotFound": 

2053 raise 

2054 else: 

2055 formatter.print_error( 

2056 f"[{region}] Add-on teardown is active ({fence_param}); refusing to start." 

2057 ) 

2058 return False 

2059 stored_input = ssm.get_parameter(Name=input_param)["Parameter"]["Value"] 

2060 execution_input = _decode_addon_replay_input(stored_input) 

2061 except Exception as e: 

2062 formatter.print_error( 

2063 f"[{region}] Could not read {input_param}: {e}. " 

2064 f"Deploy the regional stack at least once first (gco stacks deploy {project}-{region} -y)." 

2065 ) 

2066 return False 

2067 

2068 try: 

2069 sfn = boto3.client("stepfunctions", region_name=region) 

2070 machines = sfn.list_state_machines(maxResults=1000)["stateMachines"] 

2071 arn = next( 

2072 (m["stateMachineArn"] for m in machines if "HelmInstall" in m["name"]), 

2073 None, 

2074 ) 

2075 if not arn: 

2076 formatter.print_error(f"[{region}] No HelmInstall state machine found.") 

2077 return False 

2078 resp = sfn.start_execution(stateMachineArn=arn, input=execution_input) 

2079 except Exception as e: 

2080 formatter.print_error(f"[{region}] Failed to start add-on install: {e}") 

2081 return False 

2082 

2083 formatter.print_success(f"[{region}] Started add-on install (idempotent re-converge).") 

2084 formatter.print_info(f" execution: {resp['executionArn']}") 

2085 formatter.print_info(f" track status with: gco stacks addons status -r {region}") 

2086 return True