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

1017 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +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 

65 

66@stacks.command("list") 

67@click.option( 

68 "--refresh", 

69 is_flag=True, 

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

71) 

72@pass_config 

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

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

75 from ..stacks import get_stack_manager 

76 

77 formatter = get_output_formatter(config) 

78 

79 try: 

80 manager = get_stack_manager(config) 

81 if refresh: 

82 formatter.print_info( 

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

84 "for compatibility." 

85 ) 

86 local_stacks = manager.list_stacks() 

87 

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

89 for stack in local_stacks: 

90 print(f" - {stack}") 

91 

92 except Exception as e: 

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

94 sys.exit(1) 

95 

96 

97@stacks.command("synth") 

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

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

100@pass_config 

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

102 """Synthesize CloudFormation templates.""" 

103 from ..stacks import get_stack_manager 

104 

105 formatter = get_output_formatter(config) 

106 

107 try: 

108 manager = get_stack_manager(config) 

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

110 if output: 

111 print(output) 

112 formatter.print_success("CDK synthesis completed") 

113 except Exception as e: 

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

115 sys.exit(1) 

116 

117 

118@stacks.command("diff") 

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

120@pass_config 

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

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

123 from ..stacks import get_stack_manager 

124 

125 formatter = get_output_formatter(config) 

126 

127 try: 

128 manager = get_stack_manager(config) 

129 diff_output = manager.diff(stack_name) 

130 if diff_output: 

131 print(diff_output) 

132 else: 

133 formatter.print_success("No differences found") 

134 except Exception as e: 

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

136 sys.exit(1) 

137 

138 

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

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

141 

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

143 endpoint reachability, and neither is discoverable from the deploy 

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

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

146 EKS cluster). 

147 """ 

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

149 if not stack_name.startswith(prefix): 

150 return 

151 suffix = stack_name[len(prefix) :] 

152 if ( 

153 not suffix 

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

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

156 ): 

157 return 

158 formatter.print_info( 

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

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

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

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

163 ) 

164 

165 

166@stacks.command("deploy") 

167@click.argument("stack_name") 

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

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

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

171@click.option( 

172 "--enable", 

173 "enable", 

174 multiple=True, 

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

176 callback=_validate_enable, 

177 help=_ENABLE_OPTION_HELP, 

178) 

179@pass_config 

180def deploy_stack( 

181 config: Any, 

182 stack_name: Any, 

183 yes: Any, 

184 outputs_file: Any, 

185 tag: Any, 

186 enable: Mapping[str, str], 

187) -> None: 

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

189 

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

191 

192 Examples: 

193 gco stacks deploy gco-us-east-1 

194 gco stacks deploy gco-global -y 

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

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

197 

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

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

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

201 therefore deploys a partially wired feature without complaining. Prefer 

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

203 """ 

204 from ..stacks import get_stack_manager 

205 

206 formatter = get_output_formatter(config) 

207 

208 # Parse tags 

209 tags = {} 

210 for t in tag: 

211 if "=" in t: 

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

213 tags[k] = v 

214 

215 try: 

216 manager = get_stack_manager(config) 

217 _apply_enable_overrides(formatter, manager, enable) 

218 

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

220 

221 success = manager.deploy( 

222 stack_name=stack_name, 

223 require_approval=not yes, 

224 outputs_file=outputs_file, 

225 tags=tags if tags else None, 

226 ) 

227 

228 if success: 

229 formatter.print_success("Deployment completed successfully") 

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

231 else: 

232 formatter.print_error("Deployment failed") 

233 sys.exit(1) 

234 

235 except Exception as e: 

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

237 sys.exit(1) 

238 

239 

240@stacks.command("destroy") 

241@click.argument("stack_name") 

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

243@click.option( 

244 "--retain-volumes", 

245 is_flag=True, 

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

247) 

248@click.option( 

249 "--enable", 

250 "enable", 

251 multiple=True, 

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

253 callback=_validate_enable, 

254 help=_ENABLE_OPTION_HELP, 

255) 

256@pass_config 

257def destroy_stack( 

258 config: Any, 

259 stack_name: Any, 

260 yes: Any, 

261 retain_volumes: Any, 

262 enable: Mapping[str, str], 

263) -> None: 

264 """Destroy a single CDK stack. 

265 

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

267 

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

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

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

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

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

273 

274 Examples: 

275 gco stacks destroy gco-us-east-1 

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

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

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

279 """ 

280 from ..stacks import get_stack_manager 

281 

282 formatter = get_output_formatter(config) 

283 

284 if not yes: 

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

286 

287 try: 

288 manager = get_stack_manager(config) 

289 _apply_enable_overrides(formatter, manager, enable) 

290 

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

292 

293 success = manager.destroy( 

294 stack_name=stack_name, 

295 force=yes, 

296 ) 

297 

298 if success: 

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

300 else: 

301 formatter.print_error("Destroy failed") 

302 sys.exit(1) 

303 

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

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

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

307 # absent before touching anything (#268). 

308 manager.cleanup_cluster_volumes(stack_name, retain=retain_volumes) 

309 

310 except Exception as e: 

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

312 sys.exit(1) 

313 

314 

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

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

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

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

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

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

321@click.option( 

322 "--enable", 

323 "enable", 

324 multiple=True, 

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

326 callback=_validate_enable, 

327 help=_ENABLE_OPTION_HELP, 

328) 

329@pass_config 

330def deploy_all_orchestrated( 

331 config: Any, 

332 yes: Any, 

333 outputs_file: Any, 

334 tag: Any, 

335 parallel: Any, 

336 max_workers: Any, 

337 enable: Mapping[str, str], 

338) -> None: 

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

340 

341 Deploys in four phases: 

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

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

344 3. Regional API bridges (gco-regional-api-us-east-1, etc.) - depend on 

345 their regional stacks 

346 4. Monitoring stack (gco-monitoring) - depends on regional stacks 

347 

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

349 significantly reduce total deployment time when deploying to 

350 multiple regions. 

351 

352 Examples: 

353 gco stacks deploy-all -y 

354 gco stacks deploy-all -y --parallel 

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

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

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

358 """ 

359 from ..stacks import get_stack_manager 

360 

361 formatter = get_output_formatter(config) 

362 

363 # Parse tags 

364 tags = {} 

365 for t in tag: 

366 if "=" in t: 

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

368 tags[k] = v 

369 

370 try: 

371 manager = get_stack_manager(config) 

372 _apply_enable_overrides(formatter, manager, enable) 

373 stacks = manager.list_stacks() 

374 

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

376 if parallel: 

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

378 

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

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

381 

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

383 if success: 

384 formatter.print_success(f" ✓ {stack_name} deployed") 

385 else: 

386 formatter.print_error(f" ✗ {stack_name} failed") 

387 

388 success, successful, failed = manager.deploy_orchestrated( 

389 require_approval=not yes, 

390 outputs_file=outputs_file, 

391 tags=tags if tags else None, 

392 on_stack_start=on_start, 

393 on_stack_complete=on_complete, 

394 parallel=parallel, 

395 max_workers=max_workers, 

396 ) 

397 

398 formatter.print_info("") 

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

400 

401 if success: 

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

403 else: 

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

405 sys.exit(1) 

406 

407 except Exception as e: 

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

409 sys.exit(1) 

410 

411 

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

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

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

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

416@click.option( 

417 "--retain-volumes", 

418 is_flag=True, 

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

420) 

421@click.option( 

422 "--keep-control-plane", 

423 is_flag=True, 

424 help=( 

425 "Destroy only the regional API bridges and regional stacks; leave the global, " 

426 "API Gateway and monitoring stacks standing (scale to zero workload Regions)" 

427 ), 

428) 

429@click.option( 

430 "--enable", 

431 "enable", 

432 multiple=True, 

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

434 callback=_validate_enable, 

435 help=_ENABLE_OPTION_HELP, 

436) 

437@pass_config 

438def destroy_all_orchestrated( 

439 config: Any, 

440 yes: Any, 

441 parallel: Any, 

442 max_workers: Any, 

443 retain_volumes: Any, 

444 keep_control_plane: Any, 

445 enable: Mapping[str, str], 

446) -> None: 

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

448 

449 Destroys in four dependency phases: 

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

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

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

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

454 

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

456 which handles transient issues like orphaned resources during teardown. 

457 

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

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

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

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

462 

463 After a fully successful teardown this also purges the runtime 

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

465 overrides), which are written outside CloudFormation. 

466 

467 --keep-control-plane scales the deployment to zero workload Regions 

468 instead of removing it: only phases 2 and 3 run, after the monitoring 

469 stack is updated in place so it stops referencing the regional stacks. 

470 The global, API Gateway and monitoring stacks — and the shared state they 

471 own (DynamoDB tables, the model and cluster-shared buckets, the image 

472 registry, EFS recovery points, cost reports) — stay. Data inside the 

473 regional stacks (EFS and FSx file systems, Valkey, Aurora, in-cluster 

474 volumes) is still deleted, so back it up to the cluster-shared bucket 

475 first. Remove the Regions from cdk.json afterwards ('gco stacks regions 

476 remove') if the scale-down is meant to last; 'gco upgrade' uses this 

477 same teardown before recreating the regional stacks. 

478 

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

480 significantly reduce total teardown time when destroying multiple 

481 regional stacks. 

482 

483 Examples: 

484 gco stacks destroy-all -y 

485 gco stacks destroy-all -y --parallel 

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

487 gco stacks destroy-all -y --keep-control-plane 

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

489 """ 

490 import time 

491 

492 from ..stacks import get_stack_destroy_order, get_stack_manager 

493 

494 formatter = get_output_formatter(config) 

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

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

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

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

499 max_attempts = 3 

500 

501 try: 

502 manager = get_stack_manager(config) 

503 _apply_enable_overrides(formatter, manager, enable) 

504 stacks = manager.list_stacks() 

505 ordered = get_stack_destroy_order( 

506 stacks, 

507 project_name=config.project_name, 

508 keep_control_plane=keep_control_plane, 

509 ) 

510 if keep_control_plane: 

511 # Only the workload tier goes; the control-plane stacks are updated 

512 # in place (monitoring) or left alone (global, API Gateway). 

513 stacks = list(ordered) 

514 

515 if not yes: 

516 if keep_control_plane: 

517 formatter.print_warning( 

518 "This will destroy the workload tier (regional API bridges and regional " 

519 "stacks) and keep the control plane:" 

520 ) 

521 else: 

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

523 for stack in ordered: 

524 if config.output_format == "table": 

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

526 else: 

527 interactive_echo(f" - {stack}") 

528 if keep_control_plane: 

529 formatter.print_warning( 

530 "Data inside these stacks (EFS/FSx file systems, Valkey, Aurora, " 

531 "in-cluster volumes) is deleted with them." 

532 ) 

533 confirm("\nAre you sure you want to destroy the workload tier?", abort=True) 

534 else: 

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

536 

537 total_stacks = len(stacks) 

538 

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

540 if attempt > 1: 

541 # Inspect each regional VPC for resources that block teardown 

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

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

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

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

546 # what the 30s wait is for. 

547 formatter.print_info( 

548 "Inspecting VPCs for resources that can block teardown " 

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

550 ) 

551 manager.cleanup_orphaned_network_interfaces() 

552 formatter.print_warning( 

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

554 ) 

555 time.sleep(30) 

556 

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

558 if parallel: 

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

560 

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

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

563 

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

565 if success: 

566 formatter.print_success(f" ✓ {stack_name} destroyed") 

567 else: 

568 formatter.print_error(f" ✗ {stack_name} failed") 

569 

570 success, _successful, failed = manager.destroy_orchestrated( 

571 force=True, 

572 on_stack_start=on_start, 

573 on_stack_complete=on_complete, 

574 parallel=parallel, 

575 max_workers=max_workers, 

576 retain_volumes=retain_volumes, 

577 keep_control_plane=keep_control_plane, 

578 ) 

579 

580 if success: 

581 break 

582 

583 if attempt < max_attempts: 

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

585 

586 formatter.print_info("") 

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

588 

589 if success and keep_control_plane: 

590 formatter.print_success( 

591 "Workload tier destroyed; the global, API Gateway and monitoring stacks are " 

592 "standing. Run 'gco stacks regions remove <region>' to make the scale-down " 

593 "permanent, or 'gco stacks deploy-all' to recreate the regional stacks." 

594 ) 

595 elif success: 

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

597 else: 

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

599 sys.exit(1) 

600 

601 except Exception as e: 

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

603 sys.exit(1) 

604 

605 

606@stacks.command("bootstrap") 

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

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

609@pass_config 

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

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

612 

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

614 

615 Example: 

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

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

618 """ 

619 from ..stacks import get_stack_manager 

620 

621 formatter = get_output_formatter(config) 

622 

623 try: 

624 manager = get_stack_manager(config) 

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

626 

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

628 

629 if success: 

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

631 else: 

632 formatter.print_error("Bootstrap failed") 

633 sys.exit(1) 

634 

635 except Exception as e: 

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

637 sys.exit(1) 

638 

639 

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

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

642 

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

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

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

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

647 skips the drift report, never the status output. 

648 """ 

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

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

651 try: 

652 from ..cluster_doctor import endpoint_drift 

653 from ..kubectl_helpers import describe_cluster_access 

654 from ..stacks import get_eks_cluster_config 

655 

656 eks_config = get_eks_cluster_config() 

657 live = describe_cluster_access(stack_name, region) 

658 drift = endpoint_drift( 

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

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

661 live, 

662 ) 

663 except Exception: 

664 return 

665 if drift: 

666 formatter.print_warning( 

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

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

669 ) 

670 

671 

672@stacks.command("status") 

673@click.argument("stack_name") 

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

675@pass_config 

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

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

678 

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

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

681 drift. 

682 """ 

683 from ..stacks import get_stack_manager 

684 

685 formatter = get_output_formatter(config) 

686 

687 try: 

688 manager = get_stack_manager(config) 

689 status = manager.get_stack_status(stack_name, region) 

690 

691 if status: 

692 formatter.print(status.to_dict()) 

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

694 else: 

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

696 sys.exit(1) 

697 

698 except Exception as e: 

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

700 sys.exit(1) 

701 

702 

703@stacks.command("outputs") 

704@click.argument("stack_name") 

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

706@pass_config 

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

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

709 from ..stacks import get_stack_manager 

710 

711 formatter = get_output_formatter(config) 

712 

713 try: 

714 manager = get_stack_manager(config) 

715 outputs = manager.get_outputs(stack_name, region) 

716 

717 if outputs: 

718 formatter.print(outputs) 

719 else: 

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

721 

722 except Exception as e: 

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

724 sys.exit(1) 

725 

726 

727@stacks.command("access") 

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

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

730@pass_config 

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

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

733 

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

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

736 

737 Examples: 

738 gco stacks access 

739 gco stacks access -r us-west-2 

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

741 """ 

742 import subprocess 

743 

744 from .._image_uri import aws_partition 

745 from ..config import _load_cdk_json 

746 

747 formatter = get_output_formatter(config) 

748 

749 # Determine region. An explicitly empty workload list (a control-plane-only 

750 # deployment) has no cluster to reach, so say so instead of guessing. 

751 if not region: 

752 cdk_regions = _load_cdk_json() 

753 if cdk_regions and cdk_regions.get("regional"): 

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

755 elif cdk_regions and cdk_regions.get("regional") == []: 

756 formatter.print_error( 

757 "No workload Regions are configured (deployment_regions.regional is empty), " 

758 "so there is no EKS cluster to set up access for. Add one with " 

759 "'gco stacks regions add <region>' and deploy it, or pass --region " 

760 "for a cluster that is still deployed." 

761 ) 

762 sys.exit(1) 

763 else: 

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

765 

766 partition = aws_partition(str(region)) 

767 

768 # Determine cluster name 

769 if not cluster: 

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

771 

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

773 

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

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

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

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

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

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

780 private_endpoint_only = False 

781 public_cidrs: list[str] = [] 

782 try: 

783 endpoint_check = subprocess.run( 

784 [ 

785 "aws", 

786 "eks", 

787 "describe-cluster", 

788 "--name", 

789 cluster, 

790 "--region", 

791 region, 

792 "--query", 

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

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

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

796 # value is one JMESPath expression passed as a single 

797 # ``--query`` argument. 

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

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

800 "--output", 

801 "json", 

802 ], 

803 check=True, 

804 capture_output=True, 

805 text=True, 

806 ) 

807 import json 

808 

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

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

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

812 if not is_public: 

813 private_endpoint_only = True 

814 formatter.print_warning( 

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

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

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

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

819 ) 

820 formatter.print_warning( 

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

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

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

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

825 ) 

826 elif public_cidrs: 

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

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

829 formatter.print_info( 

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

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

832 ) 

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

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

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

836 # already has permission to update). 

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

838 

839 try: 

840 # Step 1: Update kubeconfig 

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

842 subprocess.run( 

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

844 check=True, 

845 capture_output=True, 

846 text=True, 

847 ) 

848 

849 # Step 2: Get IAM principal 

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

851 result = subprocess.run( 

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

853 check=True, 

854 capture_output=True, 

855 text=True, 

856 ) 

857 principal_arn = result.stdout.strip() 

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

859 

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

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

862 import re 

863 

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

865 if role_name: 

866 account_result = subprocess.run( 

867 [ 

868 "aws", 

869 "sts", 

870 "get-caller-identity", 

871 "--query", 

872 "Account", 

873 "--output", 

874 "text", 

875 ], 

876 check=True, 

877 capture_output=True, 

878 text=True, 

879 ) 

880 account_id = account_result.stdout.strip() 

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

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

883 

884 # Step 3: Create access entry 

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

886 try: 

887 subprocess.run( 

888 [ 

889 "aws", 

890 "eks", 

891 "create-access-entry", 

892 "--cluster-name", 

893 cluster, 

894 "--region", 

895 region, 

896 "--principal-arn", 

897 principal_arn, 

898 ], 

899 check=True, 

900 capture_output=True, 

901 text=True, 

902 ) 

903 except subprocess.CalledProcessError: 

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

905 

906 # Step 4: Associate admin policy 

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

908 try: 

909 subprocess.run( 

910 [ 

911 "aws", 

912 "eks", 

913 "associate-access-policy", 

914 "--cluster-name", 

915 cluster, 

916 "--region", 

917 region, 

918 "--principal-arn", 

919 principal_arn, 

920 "--policy-arn", 

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

922 "--access-scope", 

923 "type=cluster", 

924 ], 

925 check=True, 

926 capture_output=True, 

927 text=True, 

928 ) 

929 except subprocess.CalledProcessError: 

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

931 

932 # Step 5: Verify access 

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

934 import time 

935 

936 time.sleep(10) 

937 

938 result = subprocess.run( 

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

940 capture_output=True, 

941 text=True, 

942 check=False, 

943 ) 

944 if result.returncode == 0: 

945 node_count = len( 

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

947 ) 

948 print(result.stdout) 

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

950 elif private_endpoint_only: 

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

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

953 formatter.print_warning( 

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

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

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

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

958 "or after redeploying with endpoint_access=PUBLIC_AND_PRIVATE." 

959 ) 

960 else: 

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

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

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

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

965 # private-only case. 

966 looks_like_network_block = ( 

967 "i/o timeout" in stderr 

968 or "no route to host" in stderr 

969 or "connection refused" in stderr 

970 or "dial tcp" in stderr 

971 ) 

972 if looks_like_network_block: 

973 formatter.print_warning( 

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

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

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

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

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

979 ) 

980 else: 

981 formatter.print_warning( 

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

983 ) 

984 

985 except subprocess.CalledProcessError as e: 

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

987 sys.exit(1) 

988 except FileNotFoundError as e: 

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

990 sys.exit(1) 

991 except Exception as e: 

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

993 sys.exit(1) 

994 

995 

996# ============================================================================= 

997# EKS access configuration commands 

998# ============================================================================= 

999 

1000 

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

1002 

1003 

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

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

1006 match = _CIDR_RE.match(value) 

1007 if not match: 

1008 return False 

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

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

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

1012 

1013 

1014@stacks.group("eks") 

1015@pass_config 

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

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

1018 

1019 

1020@eks_cmd.group("endpoint") 

1021@pass_config 

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

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

1024 

1025 

1026@eks_endpoint_cmd.command("set") 

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

1028@click.option( 

1029 "--cidr", 

1030 "cidrs", 

1031 multiple=True, 

1032 metavar="CIDR", 

1033 help=( 

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

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

1036 ), 

1037) 

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

1039@pass_config 

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

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

1042 

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

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

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

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

1047 drift until the deploy converges the live endpoint. 

1048 

1049 Examples: 

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

1051 gco stacks eks endpoint set PRIVATE -y 

1052 """ 

1053 formatter = get_output_formatter(config) 

1054 normalized_mode = mode.upper() 

1055 

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

1057 formatter.print_error( 

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

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

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

1061 ) 

1062 sys.exit(1) 

1063 

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

1065 if invalid: 

1066 formatter.print_error( 

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

1068 ) 

1069 sys.exit(1) 

1070 

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

1072 if cidrs: 

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

1074 if normalized_mode == "PRIVATE" and cidrs: 

1075 formatter.print_info( 

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

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

1078 ) 

1079 if not yes: 

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

1081 

1082 from ..stacks import update_eks_cluster_config 

1083 

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

1085 if cidrs: 

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

1087 try: 

1088 update_eks_cluster_config(settings) 

1089 except RuntimeError as exc: 

1090 formatter.print_error(str(exc)) 

1091 sys.exit(1) 

1092 

1093 formatter.print_success(summary) 

1094 formatter.print_info( 

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

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

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

1098 ) 

1099 

1100 

1101# ============================================================================= 

1102# EKS Capabilities (AWS-managed ACK / kro) — read-only surface 

1103# ============================================================================= 

1104 

1105 

1106@stacks.group("capabilities") 

1107@pass_config 

1108def capabilities_cmd(config: Any) -> None: 

1109 """EKS Capabilities (AWS-managed ACK and kro) attached to GCO clusters. 

1110 

1111 The capabilities themselves are declared in cdk.json (eks_capabilities, 

1112 all off by default) and created by 'gco stacks deploy'; 'status' reads 

1113 what is configured and what is live. Argo CD is not a capability here: 

1114 GCO runs it in the cluster ('gco gitops'). See docs/EKS_CAPABILITIES.md. 

1115 """ 

1116 

1117 

1118@capabilities_cmd.command("status") 

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

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

1121@pass_config 

1122def capabilities_status(config: Any, region: Any, all_regions: bool) -> None: 

1123 """Show configured versus live EKS Capabilities for a regional cluster. 

1124 

1125 Reads cdk.json eks_capabilities and the EKS API (ListCapabilities / 

1126 DescribeCapability) and reports, per capability type, whether it is 

1127 configured for the region, whether it is attached, its status, and any 

1128 drift between the two. Exits nonzero when a region drifts (a configured 

1129 capability missing, an attached one disabled in cdk.json, or a status 

1130 other than ACTIVE). 

1131 

1132 Examples: 

1133 gco stacks capabilities status 

1134 gco stacks capabilities status -r us-west-2 

1135 gco stacks capabilities status --all-regions --output json 

1136 """ 

1137 from ..eks_capabilities import capabilities_status as _capabilities_status 

1138 from ..eks_capabilities import load_eks_capabilities_config 

1139 

1140 formatter = get_output_formatter(config) 

1141 project = _project_name() 

1142 try: 

1143 capabilities_config = load_eks_capabilities_config() 

1144 except (RuntimeError, ValueError) as exc: 

1145 formatter.print_error(f"Failed to read eks_capabilities from cdk.json: {exc}") 

1146 sys.exit(1) 

1147 

1148 failures = 0 

1149 documents: list[dict[str, Any]] = [] 

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

1151 try: 

1152 status = _capabilities_status(target, project, config=capabilities_config) 

1153 except Exception as exc: 

1154 formatter.print_error(f"[{target}] Failed to describe EKS Capabilities: {exc}") 

1155 failures += 1 

1156 continue 

1157 documents.append(status) 

1158 _print_capabilities_status(formatter, status, structured=config.output_format != "table") 

1159 if not status["healthy"]: 

1160 failures += 1 

1161 if documents and config.output_format != "table": 

1162 # One machine-readable document per invocation: a list under 

1163 # --all-regions, the single region's document otherwise. 

1164 formatter.print(documents if all_regions else documents[0]) 

1165 if failures: 

1166 sys.exit(1) 

1167 

1168 

1169def _print_capabilities_status( 

1170 formatter: Any, status: Mapping[str, Any], *, structured: bool = False 

1171) -> None: 

1172 """Render one region's status: the table plus human hints (stderr) about drift. 

1173 

1174 In structured (json/yaml) mode the caller prints the documents once at the 

1175 end, so only the stderr hints are emitted here. 

1176 """ 

1177 region = status["region"] 

1178 if not structured: 

1179 formatter.print_info(f"EKS Capabilities for {status['cluster_name']} in {region}:") 

1180 formatter.print( 

1181 status["capabilities"], 

1182 columns=["type", "configured", "deployed", "status", "version", "drift"], 

1183 ) 

1184 if not status["cluster_found"]: 

1185 formatter.print_warning( 

1186 f"[{region}] Cluster {status['cluster_name']} is not deployed; only the " 

1187 "configured intent is shown." 

1188 ) 

1189 for row in status["capabilities"]: 

1190 if row["drift"]: 

1191 formatter.print_warning(f"[{region}] {row['type']}: {row['drift']}") 

1192 for item in status["unmanaged"]: 

1193 formatter.print_warning( 

1194 f"[{region}] capability {item['capability_name']} ({item['type']}, " 

1195 f"{item['status']}) is attached but not managed by GCO" 

1196 ) 

1197 

1198 

1199# ============================================================================= 

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

1201# ============================================================================= 

1202 

1203 

1204@stacks.group("regions") 

1205@pass_config 

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

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

1208 

1209 These commands edit context.deployment_regions.regional through the 

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

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

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

1213 """ 

1214 

1215 

1216@regions_cmd.command("list") 

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

1218@pass_config 

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

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

1221 

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

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

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

1225 synth would reject. 

1226 """ 

1227 from ..managed_config import ManagedConfigError, get_deployment_regions_status 

1228 

1229 formatter = get_output_formatter(config) 

1230 

1231 try: 

1232 status = get_deployment_regions_status(config_path=config_path) 

1233 except ManagedConfigError as e: 

1234 formatter.print_error(str(e)) 

1235 sys.exit(1) 

1236 if config.output_format == "table": 

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

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

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

1240 formatter.print(status) 

1241 

1242 

1243@regions_cmd.command("add") 

1244@click.argument("region") 

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

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

1247@pass_config 

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

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

1250 

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

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

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

1254 

1255 Examples: 

1256 gco stacks regions add us-west-2 

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

1258 """ 

1259 from ..managed_config import ManagedConfigError, add_deployment_region 

1260 

1261 formatter = get_output_formatter(config) 

1262 

1263 if not yes: 

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

1265 

1266 try: 

1267 report = add_deployment_region(region, config_path=config_path) 

1268 except ManagedConfigError as e: 

1269 formatter.print_error(str(e)) 

1270 sys.exit(1) 

1271 

1272 if report.changed: 

1273 formatter.print_success(report.summary()) 

1274 formatter.print_info( 

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

1276 f"deploying only '{config.project_name}-{region}' skips the Region's API bridge " 

1277 "and the monitoring update" 

1278 ) 

1279 else: 

1280 formatter.print_info(report.summary()) 

1281 

1282 

1283@regions_cmd.command("remove") 

1284@click.argument("region") 

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

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

1287@pass_config 

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

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

1290 

1291 The resulting list must stay valid (SDK-known, unique Regions in one AWS 

1292 partition). Removing the last Region is allowed: an empty list is a 

1293 control-plane-only topology, where 'gco stacks deploy-all' stands up only 

1294 the global, API Gateway, and monitoring stacks until a Region is added 

1295 back. Removing an absent Region is a reported no-op. Removing an 

1296 unknown/typo'd entry from a hand-edited config is allowed — validation 

1297 applies to the result, so this is also the repair path. 

1298 

1299 Examples: 

1300 gco stacks regions remove us-west-2 

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

1302 """ 

1303 from ..managed_config import ManagedConfigError, remove_deployment_region 

1304 

1305 formatter = get_output_formatter(config) 

1306 

1307 if not yes: 

1308 formatter.print_warning( 

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

1310 "stack is NOT destroyed by this change, and once the Region leaves cdk.json " 

1311 "it is no longer part of the CDK app. Retire a deployed Region first: see " 

1312 "'Removing a Region' in docs/CUSTOMIZATION.md." 

1313 ) 

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

1315 

1316 try: 

1317 report = remove_deployment_region(region, config_path=config_path) 

1318 except ManagedConfigError as e: 

1319 formatter.print_error(str(e)) 

1320 sys.exit(1) 

1321 

1322 if report.changed: 

1323 formatter.print_success(report.summary()) 

1324 formatter.print_info( 

1325 f"Config only — {config.project_name}-{region} was not destroyed. If it is still " 

1326 "deployed it is now outside the CDK app, where 'gco stacks destroy' cannot reach " 

1327 f"it: add the Region back with 'gco stacks regions add {region} -y' and follow " 

1328 "'Removing a Region' in docs/CUSTOMIZATION.md" 

1329 ) 

1330 if not report.new: 

1331 formatter.print_warning( 

1332 "deployment_regions.regional is now empty: this is a control-plane-only " 

1333 "topology. 'gco stacks deploy-all' will deploy only the global, API Gateway, " 

1334 "and monitoring stacks until you add a Region back with " 

1335 "'gco stacks regions add <region>'." 

1336 ) 

1337 else: 

1338 formatter.print_info(report.summary()) 

1339 

1340 

1341@regions_cmd.command("set") 

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

1343@click.argument("region") 

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

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

1346@pass_config 

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

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

1349 

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

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

1352 current value is a reported no-op. 

1353 

1354 Examples: 

1355 gco stacks regions set monitoring us-west-2 

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

1357 """ 

1358 from ..managed_config import ManagedConfigError, set_deployment_region_role 

1359 

1360 formatter = get_output_formatter(config) 

1361 

1362 if not yes: 

1363 formatter.print_warning( 

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

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

1366 ) 

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

1368 

1369 try: 

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

1371 except ManagedConfigError as e: 

1372 formatter.print_error(str(e)) 

1373 sys.exit(1) 

1374 

1375 if report.changed: 

1376 formatter.print_success(report.summary()) 

1377 formatter.print_info( 

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

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

1380 ) 

1381 else: 

1382 formatter.print_info(report.summary()) 

1383 

1384 

1385# ============================================================================= 

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

1387# ============================================================================= 

1388 

1389 

1390@stacks.group("bedrock") 

1391@pass_config 

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

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

1394 

1395 Five independent model keys serve Mission sampling, the capacity advisor, 

1396 Claude Code, Codex, and OpenCode. Codex also owns a reviewed 

1397 reasoning-effort sibling. Every edit uses the shared managed-config 

1398 engine: validated, atomic, idempotent, and audited. 

1399 """ 

1400 

1401 

1402@bedrock_cmd.command("show") 

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

1404@pass_config 

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

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

1407 from ..managed_config import ManagedConfigError, get_bedrock_model_status 

1408 

1409 formatter = get_output_formatter(config) 

1410 

1411 try: 

1412 status = get_bedrock_model_status(config_path=config_path) 

1413 except ManagedConfigError as e: 

1414 formatter.print_error(str(e)) 

1415 sys.exit(1) 

1416 formatter.print(status) 

1417 

1418 

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

1420@click.argument("model_id") 

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

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

1423@pass_config 

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

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

1426 

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

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

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

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

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

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

1433 

1434 Examples: 

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

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

1437 """ 

1438 from ..managed_config import ManagedConfigError, set_mission_default_model 

1439 

1440 formatter = get_output_formatter(config) 

1441 

1442 if not yes: 

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

1444 

1445 try: 

1446 report = set_mission_default_model(model_id, config_path=config_path) 

1447 except ManagedConfigError as e: 

1448 formatter.print_error(str(e)) 

1449 sys.exit(1) 

1450 

1451 if report.changed: 

1452 formatter.print_success(report.summary()) 

1453 formatter.print_info( 

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

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

1456 ) 

1457 else: 

1458 formatter.print_info(report.summary()) 

1459 

1460 

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

1462@click.argument("model_id") 

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

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

1465@pass_config 

1466def bedrock_set_capacity_advisor_model( 

1467 config: Any, model_id: Any, config_path: Any, yes: Any 

1468) -> None: 

1469 """Set context.bedrock.capacity_advisor_default_model_id. 

1470 

1471 This is the default `gco capacity ai-recommend` and `gco capacity predict` 

1472 use; Mission sampling and `gco autopilot` have their own keys (see 

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

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

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

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

1477 are preserved. 

1478 

1479 Examples: 

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

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

1482 """ 

1483 from ..managed_config import ManagedConfigError, set_capacity_advisor_default_model 

1484 

1485 formatter = get_output_formatter(config) 

1486 

1487 if not yes: 

1488 confirm( 

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

1490 abort=True, 

1491 ) 

1492 

1493 try: 

1494 report = set_capacity_advisor_default_model(model_id, config_path=config_path) 

1495 except ManagedConfigError as e: 

1496 formatter.print_error(str(e)) 

1497 sys.exit(1) 

1498 

1499 if report.changed: 

1500 formatter.print_success(report.summary()) 

1501 formatter.print_info( 

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

1503 "--model overrides still take precedence" 

1504 ) 

1505 else: 

1506 formatter.print_info(report.summary()) 

1507 

1508 

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

1510@click.argument("model_id") 

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

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

1513@pass_config 

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

1515 """Set context.bedrock.claude_code_default_model_id. 

1516 

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

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

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

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

1521 without surrounding whitespace. Sibling settings are preserved. 

1522 

1523 Examples: 

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

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

1526 """ 

1527 from ..managed_config import ManagedConfigError, set_claude_code_default_model 

1528 

1529 formatter = get_output_formatter(config) 

1530 

1531 if not yes: 

1532 confirm( 

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

1534 abort=True, 

1535 ) 

1536 

1537 try: 

1538 report = set_claude_code_default_model(model_id, config_path=config_path) 

1539 except ManagedConfigError as e: 

1540 formatter.print_error(str(e)) 

1541 sys.exit(1) 

1542 

1543 if report.changed: 

1544 formatter.print_success(report.summary()) 

1545 formatter.print_info( 

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

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

1548 ) 

1549 else: 

1550 formatter.print_info(report.summary()) 

1551 

1552 

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

1554@click.argument("model_id") 

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

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

1557@pass_config 

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

1559 """Set context.bedrock.codex_default_model_id. 

1560 

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

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

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

1564 GCO_AUTOPILOT_CODEX_MODEL, and GCO_AUTOPILOT_MODEL overrides still win. 

1565 

1566 Examples: 

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

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

1569 """ 

1570 from ..managed_config import ManagedConfigError, set_codex_default_model 

1571 

1572 formatter = get_output_formatter(config) 

1573 

1574 if not yes: 

1575 confirm( 

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

1577 abort=True, 

1578 ) 

1579 

1580 try: 

1581 report = set_codex_default_model(model_id, config_path=config_path) 

1582 except ManagedConfigError as e: 

1583 formatter.print_error(str(e)) 

1584 sys.exit(1) 

1585 

1586 if report.changed: 

1587 formatter.print_success(report.summary()) 

1588 formatter.print_info( 

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

1590 "--model/GCO_AUTOPILOT_CODEX_MODEL/GCO_AUTOPILOT_MODEL overrides " 

1591 "still take precedence" 

1592 ) 

1593 else: 

1594 formatter.print_info(report.summary()) 

1595 

1596 

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

1598@click.argument( 

1599 "reasoning_effort", 

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

1601) 

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

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

1604@pass_config 

1605def bedrock_set_codex_reasoning_effort( 

1606 config: Any, reasoning_effort: Any, config_path: Any, yes: Any 

1607) -> None: 

1608 """Set context.bedrock.codex.reasoning_effort. 

1609 

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

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

1612 high, and xhigh. 

1613 

1614 Examples: 

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

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

1617 """ 

1618 from ..managed_config import ManagedConfigError, set_codex_reasoning_effort 

1619 

1620 formatter = get_output_formatter(config) 

1621 

1622 if not yes: 

1623 confirm( 

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

1625 abort=True, 

1626 ) 

1627 

1628 try: 

1629 report = set_codex_reasoning_effort(reasoning_effort, config_path=config_path) 

1630 except ManagedConfigError as e: 

1631 formatter.print_error(str(e)) 

1632 sys.exit(1) 

1633 

1634 if report.changed: 

1635 formatter.print_success(report.summary()) 

1636 formatter.print_info( 

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

1638 "overrides intentionally omit canonical reasoning" 

1639 ) 

1640 else: 

1641 formatter.print_info(report.summary()) 

1642 

1643 

1644@bedrock_cmd.command("set-opencode-model") 

1645@click.argument("model_id") 

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

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

1648@pass_config 

1649def bedrock_set_opencode_model(config: Any, model_id: Any, config_path: Any, yes: Any) -> None: 

1650 """Set context.bedrock.opencode_default_model_id. 

1651 

1652 This is the canonical model for OpenCode Autopilot sessions. OpenCode has 

1653 no engine-owned reasoning sibling, so this single key is the whole 

1654 default. Explicit --model, GCO_AUTOPILOT_OPENCODE_MODEL, and 

1655 GCO_AUTOPILOT_MODEL overrides still win. 

1656 

1657 Examples: 

1658 gco stacks bedrock set-opencode-model global.moonshotai.<model-id> 

1659 gco stacks bedrock set-opencode-model us.moonshotai.<model-id> -y 

1660 """ 

1661 from ..managed_config import ManagedConfigError, set_opencode_default_model 

1662 

1663 formatter = get_output_formatter(config) 

1664 

1665 if not yes: 

1666 confirm( 

1667 f"Set bedrock.opencode_default_model_id to {model_id} in cdk.json?", 

1668 abort=True, 

1669 ) 

1670 

1671 try: 

1672 report = set_opencode_default_model(model_id, config_path=config_path) 

1673 except ManagedConfigError as e: 

1674 formatter.print_error(str(e)) 

1675 sys.exit(1) 

1676 

1677 if report.changed: 

1678 formatter.print_success(report.summary()) 

1679 formatter.print_info( 

1680 "Canonical OpenCode sessions pick this up at launch; explicit " 

1681 "--model/GCO_AUTOPILOT_OPENCODE_MODEL/GCO_AUTOPILOT_MODEL overrides " 

1682 "still take precedence" 

1683 ) 

1684 else: 

1685 formatter.print_info(report.summary()) 

1686 

1687 

1688# ============================================================================= 

1689# FSx commands 

1690# ============================================================================= 

1691 

1692 

1693@stacks.group("fsx") 

1694@pass_config 

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

1696 """Manage FSx for Lustre configuration.""" 

1697 

1698 

1699@fsx_cmd.command("status") 

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

1701@pass_config 

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

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

1704 from ..stacks import get_fsx_config 

1705 

1706 formatter = get_output_formatter(config) 

1707 

1708 try: 

1709 fsx_config = get_fsx_config(region) 

1710 if region: 

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

1712 else: 

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

1714 formatter.print(fsx_config) 

1715 except Exception as e: 

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

1717 sys.exit(1) 

1718 

1719 

1720@fsx_cmd.command("enable") 

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

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

1723@click.option( 

1724 "--deployment-type", 

1725 "-d", 

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

1727 default="SCRATCH_2", 

1728 help="FSx deployment type", 

1729) 

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

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

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

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

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

1735@pass_config 

1736def fsx_enable( 

1737 config: Any, 

1738 region: Any, 

1739 storage_capacity: Any, 

1740 deployment_type: Any, 

1741 throughput: Any, 

1742 compression: Any, 

1743 import_path: Any, 

1744 export_path: Any, 

1745 yes: Any, 

1746) -> None: 

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

1748 

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

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

1751 

1752 Examples: 

1753 gco stacks fsx enable 

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

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

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

1757 """ 

1758 from ..stacks import update_fsx_config 

1759 

1760 formatter = get_output_formatter(config) 

1761 

1762 if storage_capacity < 1200: 

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

1764 sys.exit(1) 

1765 

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

1767 

1768 if not yes: 

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

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

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

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

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

1774 if import_path: 

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

1776 if export_path: 

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

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

1779 

1780 try: 

1781 fsx_settings = { 

1782 "enabled": True, 

1783 "storage_capacity_gib": storage_capacity, 

1784 "deployment_type": deployment_type, 

1785 "per_unit_storage_throughput": throughput, 

1786 "data_compression_type": compression, 

1787 "import_path": import_path, 

1788 "export_path": export_path, 

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

1790 } 

1791 

1792 update_fsx_config(fsx_settings, region) 

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

1794 if region: 

1795 formatter.print_info( 

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

1797 ) 

1798 else: 

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

1800 

1801 except Exception as e: 

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

1803 sys.exit(1) 

1804 

1805 

1806@fsx_cmd.command("disable") 

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

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

1809@pass_config 

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

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

1812 

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

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

1815 

1816 Examples: 

1817 gco stacks fsx disable 

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

1819 """ 

1820 from ..stacks import update_fsx_config 

1821 

1822 formatter = get_output_formatter(config) 

1823 

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

1825 

1826 if not yes: 

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

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

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

1830 

1831 try: 

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

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

1834 if region: 

1835 formatter.print_info( 

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

1837 ) 

1838 else: 

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

1840 

1841 except Exception as e: 

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

1843 sys.exit(1) 

1844 

1845 

1846# ============================================================================= 

1847# Valkey commands 

1848# ============================================================================= 

1849 

1850 

1851@stacks.group("valkey") 

1852@pass_config 

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

1854 """Manage Valkey Serverless cache configuration.""" 

1855 

1856 

1857@valkey_cmd.command("status") 

1858@pass_config 

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

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

1861 from ..stacks import get_valkey_config 

1862 

1863 formatter = get_output_formatter(config) 

1864 

1865 try: 

1866 valkey_config = get_valkey_config() 

1867 formatter.print_info("Valkey config:") 

1868 formatter.print(valkey_config) 

1869 except Exception as e: 

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

1871 sys.exit(1) 

1872 

1873 

1874@valkey_cmd.command("enable") 

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

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

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

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

1879@pass_config 

1880def valkey_enable( 

1881 config: Any, 

1882 max_storage: Any, 

1883 max_ecpu: Any, 

1884 snapshot_retention: Any, 

1885 yes: Any, 

1886) -> None: 

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

1888 

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

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

1891 

1892 Examples: 

1893 gco stacks valkey enable 

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

1895 """ 

1896 from ..stacks import update_valkey_config 

1897 

1898 formatter = get_output_formatter(config) 

1899 

1900 if not yes: 

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

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

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

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

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

1906 

1907 try: 

1908 valkey_settings = { 

1909 "enabled": True, 

1910 "max_data_storage_gb": max_storage, 

1911 "max_ecpu_per_second": max_ecpu, 

1912 "snapshot_retention_limit": snapshot_retention, 

1913 } 

1914 

1915 update_valkey_config(valkey_settings) 

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

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

1918 

1919 except Exception as e: 

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

1921 sys.exit(1) 

1922 

1923 

1924@valkey_cmd.command("disable") 

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

1926@pass_config 

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

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

1929 

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

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

1932 

1933 Examples: 

1934 gco stacks valkey disable 

1935 """ 

1936 from ..stacks import update_valkey_config 

1937 

1938 formatter = get_output_formatter(config) 

1939 

1940 if not yes: 

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

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

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

1944 

1945 try: 

1946 update_valkey_config({"enabled": False}) 

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

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

1949 

1950 except Exception as e: 

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

1952 sys.exit(1) 

1953 

1954 

1955# ============================================================================= 

1956# Aurora pgvector commands 

1957# ============================================================================= 

1958 

1959 

1960@stacks.group("aurora") 

1961@pass_config 

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

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

1964 

1965 

1966@aurora_cmd.command("status") 

1967@pass_config 

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

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

1970 from ..stacks import get_aurora_config 

1971 

1972 formatter = get_output_formatter(config) 

1973 

1974 try: 

1975 aurora_config = get_aurora_config() 

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

1977 formatter.print(aurora_config) 

1978 except Exception as e: 

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

1980 sys.exit(1) 

1981 

1982 

1983@aurora_cmd.command("enable") 

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

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

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

1987@click.option( 

1988 "--deletion-protection/--no-deletion-protection", 

1989 default=False, 

1990 help="Enable deletion protection", 

1991) 

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

1993@pass_config 

1994def aurora_enable( 

1995 config: Any, 

1996 min_acu: Any, 

1997 max_acu: Any, 

1998 backup_retention: Any, 

1999 deletion_protection: Any, 

2000 yes: Any, 

2001) -> None: 

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

2003 

2004 Aurora Serverless v2 with pgvector provides vector similarity search 

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

2006 

2007 Examples: 

2008 gco stacks aurora enable 

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

2010 """ 

2011 from ..stacks import update_aurora_config 

2012 

2013 formatter = get_output_formatter(config) 

2014 

2015 if min_acu < 0: 

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

2017 sys.exit(1) 

2018 if max_acu < 1: 

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

2020 sys.exit(1) 

2021 if max_acu < min_acu: 

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

2023 sys.exit(1) 

2024 

2025 if not yes: 

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

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

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

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

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

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

2032 

2033 try: 

2034 aurora_settings = { 

2035 "enabled": True, 

2036 "min_acu": min_acu, 

2037 "max_acu": max_acu, 

2038 "backup_retention_days": backup_retention, 

2039 "deletion_protection": deletion_protection, 

2040 } 

2041 

2042 update_aurora_config(aurora_settings) 

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

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

2045 

2046 except Exception as e: 

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

2048 sys.exit(1) 

2049 

2050 

2051@aurora_cmd.command("disable") 

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

2053@pass_config 

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

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

2056 

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

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

2059 deletion protection is enabled. 

2060 

2061 Examples: 

2062 gco stacks aurora disable 

2063 """ 

2064 from ..stacks import update_aurora_config 

2065 

2066 formatter = get_output_formatter(config) 

2067 

2068 if not yes: 

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

2070 formatter.print_warning( 

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

2072 "(unless deletion protection is enabled)." 

2073 ) 

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

2075 

2076 try: 

2077 update_aurora_config({"enabled": False}) 

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

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

2080 

2081 except Exception as e: 

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

2083 sys.exit(1) 

2084 

2085 

2086def _project_name() -> str: 

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

2088 import json 

2089 from pathlib import Path 

2090 

2091 try: 

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

2093 document = json.load(f) 

2094 if not isinstance(document, dict): 

2095 return "gco" 

2096 ctx = document.get("context") 

2097 if not isinstance(ctx, dict): 

2098 return "gco" 

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

2100 except OSError, ValueError: 

2101 return "gco" 

2102 

2103 

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

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

2106 

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

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

2109 the configured default. 

2110 """ 

2111 cdk_regions = _load_cdk_json() 

2112 regional = ( 

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

2114 ) 

2115 

2116 if all_regions: 

2117 return regional 

2118 if region: 

2119 return [str(region)] 

2120 if regional: 

2121 return [str(regional[0])] 

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

2123 

2124 

2125@stacks.group("addons") 

2126@pass_config 

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

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

2129 

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

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

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

2133 """ 

2134 

2135 

2136@addons_cmd.command("status") 

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

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

2139@pass_config 

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

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

2142 

2143 Examples: 

2144 gco stacks addons status 

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

2146 gco stacks addons status --all-regions 

2147 """ 

2148 formatter = get_output_formatter(config) 

2149 project = _project_name() 

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

2151 _addons_status_one(formatter, project, target) 

2152 

2153 

2154# Terminal states the installer, kubectl applier and validators record when a 

2155# phase succeeded ("validated" is what both release/manifest validators write). 

2156_ADDON_HEALTHY_STATUSES = frozenset({"installed", "uninstalled", "absent", "applied", "validated"}) 

2157# SSM parameters under /<project>/addons/<region>/ that are not status records. 

2158_ADDON_BOOKKEEPING_PARAMS = frozenset({"_input", "_execution"}) 

2159 

2160 

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

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

2163 import json 

2164 

2165 import boto3 

2166 

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

2168 

2169 try: 

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

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

2172 paginator = ssm.get_paginator("get_parameters_by_path") 

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

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

2175 except Exception as e: 

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

2177 return 

2178 

2179 rows = [] 

2180 for p in params: 

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

2182 # _input (replay input) and _execution (orchestrator run metadata) 

2183 # are bookkeeping, not add-on status records. 

2184 if name in _ADDON_BOOKKEEPING_PARAMS: 

2185 continue 

2186 try: 

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

2188 except ValueError: 

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

2190 if not isinstance(data, dict): 

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

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

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

2194 # Healthy rows stay one line; a failure keeps its full message, since 

2195 # the list of failed resources is the part needed to debug it. 

2196 if status in _ADDON_HEALTHY_STATUSES: 

2197 message = message[:80] 

2198 rows.append((name, status, message)) 

2199 

2200 if not rows: 

2201 formatter.print_info( 

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

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

2204 ) 

2205 return 

2206 

2207 rows.sort() 

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

2209 for name, status, message in rows: 

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

2211 if status in _ADDON_HEALTHY_STATUSES: 

2212 formatter.print_success(line) 

2213 else: 

2214 formatter.print_error(line) 

2215 

2216 

2217@addons_cmd.command("install") 

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

2219@click.option( 

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

2221) 

2222@pass_config 

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

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

2225 

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

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

2228 transient failure instead of a full stack redeploy. 

2229 

2230 Examples: 

2231 gco stacks addons install 

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

2233 gco stacks addons install --all-regions 

2234 """ 

2235 formatter = get_output_formatter(config) 

2236 project = _project_name() 

2237 failures = 0 

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

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

2240 failures += 1 

2241 if failures: 

2242 sys.exit(1) 

2243 

2244 

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

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

2247 

2248 The orchestrator stores the execution input encoded because SSM rejects 

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

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

2251 """ 

2252 import base64 

2253 import zlib 

2254 

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

2256 return stored_value 

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

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

2259 

2260 

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

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

2263 import boto3 

2264 from botocore.exceptions import ClientError 

2265 

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

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

2268 

2269 try: 

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

2271 try: 

2272 ssm.get_parameter(Name=fence_param) 

2273 except ClientError as exc: 

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

2275 raise 

2276 else: 

2277 formatter.print_error( 

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

2279 ) 

2280 return False 

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

2282 execution_input = _decode_addon_replay_input(stored_input) 

2283 except Exception as e: 

2284 formatter.print_error( 

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

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

2287 ) 

2288 return False 

2289 

2290 try: 

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

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

2293 arn = next( 

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

2295 None, 

2296 ) 

2297 if not arn: 

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

2299 return False 

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

2301 except Exception as e: 

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

2303 return False 

2304 

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

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

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

2308 return True