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

643 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

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

2 

3import sys 

4from typing import Any 

5 

6import click 

7 

8from ..config import GCOConfig, _load_cdk_json 

9from ..output import get_output_formatter 

10 

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

12 

13 

14@click.group() 

15@pass_config 

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

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

18 pass 

19 

20 

21@stacks.command("list") 

22@click.option( 

23 "--refresh", 

24 is_flag=True, 

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

26) 

27@pass_config 

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

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

30 from ..stacks import get_stack_manager 

31 

32 formatter = get_output_formatter(config) 

33 

34 try: 

35 manager = get_stack_manager(config) 

36 if refresh: 

37 formatter.print_info( 

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

39 "for compatibility." 

40 ) 

41 local_stacks = manager.list_stacks() 

42 

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

44 for stack in local_stacks: 

45 print(f" - {stack}") 

46 

47 except Exception as e: 

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

49 sys.exit(1) 

50 

51 

52@stacks.command("synth") 

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

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

55@pass_config 

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

57 """Synthesize CloudFormation templates.""" 

58 from ..stacks import get_stack_manager 

59 

60 formatter = get_output_formatter(config) 

61 

62 try: 

63 manager = get_stack_manager(config) 

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

65 if output: 

66 print(output) 

67 formatter.print_success("CDK synthesis completed") 

68 except Exception as e: 

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

70 sys.exit(1) 

71 

72 

73@stacks.command("diff") 

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

75@pass_config 

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

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

78 from ..stacks import get_stack_manager 

79 

80 formatter = get_output_formatter(config) 

81 

82 try: 

83 manager = get_stack_manager(config) 

84 diff_output = manager.diff(stack_name) 

85 if diff_output: 

86 print(diff_output) 

87 else: 

88 formatter.print_success("No differences found") 

89 except Exception as e: 

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

91 sys.exit(1) 

92 

93 

94@stacks.command("deploy") 

95@click.argument("stack_name") 

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

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

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

99@pass_config 

100def deploy_stack(config: Any, stack_name: Any, yes: Any, outputs_file: Any, tag: Any) -> None: 

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

102 

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

104 

105 Examples: 

106 gco stacks deploy gco-us-east-1 

107 gco stacks deploy gco-global -y 

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

109 """ 

110 from ..stacks import get_stack_manager 

111 

112 formatter = get_output_formatter(config) 

113 

114 # Parse tags 

115 tags = {} 

116 for t in tag: 

117 if "=" in t: 

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

119 tags[k] = v 

120 

121 try: 

122 manager = get_stack_manager(config) 

123 

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

125 

126 success = manager.deploy( 

127 stack_name=stack_name, 

128 require_approval=not yes, 

129 outputs_file=outputs_file, 

130 tags=tags if tags else None, 

131 ) 

132 

133 if success: 

134 formatter.print_success("Deployment completed successfully") 

135 else: 

136 formatter.print_error("Deployment failed") 

137 sys.exit(1) 

138 

139 except Exception as e: 

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

141 sys.exit(1) 

142 

143 

144@stacks.command("destroy") 

145@click.argument("stack_name") 

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

147@pass_config 

148def destroy_stack(config: Any, stack_name: Any, yes: Any) -> None: 

149 """Destroy a single CDK stack. 

150 

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

152 

153 Examples: 

154 gco stacks destroy gco-us-east-1 

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

156 """ 

157 from ..stacks import get_stack_manager 

158 

159 formatter = get_output_formatter(config) 

160 

161 if not yes: 

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

163 

164 try: 

165 manager = get_stack_manager(config) 

166 

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

168 

169 success = manager.destroy( 

170 stack_name=stack_name, 

171 force=yes, 

172 ) 

173 

174 if success: 

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

176 else: 

177 formatter.print_error("Destroy failed") 

178 sys.exit(1) 

179 

180 except Exception as e: 

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

182 sys.exit(1) 

183 

184 

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

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

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

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

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

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

191@pass_config 

192def deploy_all_orchestrated( 

193 config: Any, yes: Any, outputs_file: Any, tag: Any, parallel: Any, max_workers: Any 

194) -> None: 

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

196 

197 Deploys in three phases: 

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

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

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

201 

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

203 significantly reduce total deployment time when deploying to 

204 multiple regions. 

205 

206 Examples: 

207 gco stacks deploy-all -y 

208 gco stacks deploy-all -y --parallel 

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

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

211 """ 

212 from ..stacks import get_stack_manager 

213 

214 formatter = get_output_formatter(config) 

215 

216 # Parse tags 

217 tags = {} 

218 for t in tag: 

219 if "=" in t: 

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

221 tags[k] = v 

222 

223 try: 

224 manager = get_stack_manager(config) 

225 stacks = manager.list_stacks() 

226 

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

228 if parallel: 

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

230 

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

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

233 

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

235 if success: 

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

237 else: 

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

239 

240 success, successful, failed = manager.deploy_orchestrated( 

241 require_approval=not yes, 

242 outputs_file=outputs_file, 

243 tags=tags if tags else None, 

244 on_stack_start=on_start, 

245 on_stack_complete=on_complete, 

246 parallel=parallel, 

247 max_workers=max_workers, 

248 ) 

249 

250 formatter.print_info("") 

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

252 

253 if success: 

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

255 else: 

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

257 sys.exit(1) 

258 

259 except Exception as e: 

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

261 sys.exit(1) 

262 

263 

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

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

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

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

268@pass_config 

269def destroy_all_orchestrated(config: Any, yes: Any, parallel: Any, max_workers: Any) -> None: 

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

271 

272 Destroys in four dependency phases: 

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

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

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

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

277 

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

279 which handles transient issues like orphaned resources during teardown. 

280 

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

282 significantly reduce total teardown time when destroying multiple 

283 regional stacks. 

284 

285 Examples: 

286 gco stacks destroy-all -y 

287 gco stacks destroy-all -y --parallel 

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

289 """ 

290 import time 

291 

292 from ..stacks import get_stack_destroy_order, get_stack_manager 

293 

294 formatter = get_output_formatter(config) 

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

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

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

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

299 max_attempts = 3 

300 

301 try: 

302 manager = get_stack_manager(config) 

303 stacks = manager.list_stacks() 

304 ordered = get_stack_destroy_order( 

305 stacks, 

306 project_name=config.project_name, 

307 ) 

308 

309 if not yes: 

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

311 for stack in ordered: 

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

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

314 

315 total_stacks = len(stacks) 

316 

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

318 if attempt > 1: 

319 # Inspect each regional VPC for resources that block teardown 

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

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

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

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

324 # what the 30s wait is for. 

325 formatter.print_info( 

326 "Inspecting VPCs for resources that can block teardown " 

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

328 ) 

329 manager.cleanup_orphaned_network_interfaces() 

330 formatter.print_warning( 

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

332 ) 

333 time.sleep(30) 

334 

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

336 if parallel: 

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

338 

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

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

341 

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

343 if success: 

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

345 else: 

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

347 

348 success, successful, failed = manager.destroy_orchestrated( 

349 force=True, 

350 on_stack_start=on_start, 

351 on_stack_complete=on_complete, 

352 parallel=parallel, 

353 max_workers=max_workers, 

354 ) 

355 

356 if success: 

357 break 

358 

359 if attempt < max_attempts: 

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

361 

362 formatter.print_info("") 

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

364 

365 if success: 

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

367 else: 

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

369 sys.exit(1) 

370 

371 except Exception as e: 

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

373 sys.exit(1) 

374 

375 

376@stacks.command("bootstrap") 

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

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

379@pass_config 

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

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

382 

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

384 

385 Example: 

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

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

388 """ 

389 from ..stacks import get_stack_manager 

390 

391 formatter = get_output_formatter(config) 

392 

393 try: 

394 manager = get_stack_manager(config) 

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

396 

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

398 

399 if success: 

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

401 else: 

402 formatter.print_error("Bootstrap failed") 

403 sys.exit(1) 

404 

405 except Exception as e: 

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

407 sys.exit(1) 

408 

409 

410@stacks.command("status") 

411@click.argument("stack_name") 

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

413@pass_config 

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

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

416 from ..stacks import get_stack_manager 

417 

418 formatter = get_output_formatter(config) 

419 

420 try: 

421 manager = get_stack_manager(config) 

422 status = manager.get_stack_status(stack_name, region) 

423 

424 if status: 

425 formatter.print(status.to_dict()) 

426 else: 

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

428 sys.exit(1) 

429 

430 except Exception as e: 

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

432 sys.exit(1) 

433 

434 

435@stacks.command("outputs") 

436@click.argument("stack_name") 

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

438@pass_config 

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

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

441 from ..stacks import get_stack_manager 

442 

443 formatter = get_output_formatter(config) 

444 

445 try: 

446 manager = get_stack_manager(config) 

447 outputs = manager.get_outputs(stack_name, region) 

448 

449 if outputs: 

450 formatter.print(outputs) 

451 else: 

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

453 

454 except Exception as e: 

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

456 sys.exit(1) 

457 

458 

459@stacks.command("access") 

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

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

462@pass_config 

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

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

465 

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

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

468 

469 Examples: 

470 gco stacks access 

471 gco stacks access -r us-west-2 

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

473 """ 

474 import subprocess 

475 

476 from .._image_uri import aws_partition 

477 from ..config import _load_cdk_json 

478 

479 formatter = get_output_formatter(config) 

480 

481 # Determine region 

482 if not region: 

483 cdk_regions = _load_cdk_json() 

484 if cdk_regions and "regional" in cdk_regions: 484 ↛ 487line 484 didn't jump to line 487 because the condition on line 484 was always true

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

486 else: 

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

488 

489 partition = aws_partition(str(region)) 

490 

491 # Determine cluster name 

492 if not cluster: 

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

494 

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

496 

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

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

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

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

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

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

503 private_endpoint_only = False 

504 public_cidrs: list[str] = [] 

505 try: 

506 endpoint_check = subprocess.run( 

507 [ 

508 "aws", 

509 "eks", 

510 "describe-cluster", 

511 "--name", 

512 cluster, 

513 "--region", 

514 region, 

515 "--query", 

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

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

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

519 # value is one JMESPath expression passed as a single 

520 # ``--query`` argument. 

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

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

523 "--output", 

524 "json", 

525 ], 

526 check=True, 

527 capture_output=True, 

528 text=True, 

529 ) 

530 import json 

531 

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

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

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

535 if not is_public: 

536 private_endpoint_only = True 

537 formatter.print_warning( 

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

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

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

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

542 ) 

543 formatter.print_warning( 

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

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

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

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

548 ) 

549 elif public_cidrs: 

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

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

552 formatter.print_info( 

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

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

555 ) 

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

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

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

559 # already has permission to update). 

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

561 

562 try: 

563 # Step 1: Update kubeconfig 

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

565 subprocess.run( 

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

567 check=True, 

568 capture_output=True, 

569 text=True, 

570 ) 

571 

572 # Step 2: Get IAM principal 

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

574 result = subprocess.run( 

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

576 check=True, 

577 capture_output=True, 

578 text=True, 

579 ) 

580 principal_arn = result.stdout.strip() 

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

582 

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

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

585 import re 

586 

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

588 if role_name: 588 ↛ 608line 588 didn't jump to line 608 because the condition on line 588 was always true

589 account_result = subprocess.run( 

590 [ 

591 "aws", 

592 "sts", 

593 "get-caller-identity", 

594 "--query", 

595 "Account", 

596 "--output", 

597 "text", 

598 ], 

599 check=True, 

600 capture_output=True, 

601 text=True, 

602 ) 

603 account_id = account_result.stdout.strip() 

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

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

606 

607 # Step 3: Create access entry 

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

609 try: 

610 subprocess.run( 

611 [ 

612 "aws", 

613 "eks", 

614 "create-access-entry", 

615 "--cluster-name", 

616 cluster, 

617 "--region", 

618 region, 

619 "--principal-arn", 

620 principal_arn, 

621 ], 

622 check=True, 

623 capture_output=True, 

624 text=True, 

625 ) 

626 except subprocess.CalledProcessError: 

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

628 

629 # Step 4: Associate admin policy 

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

631 try: 

632 subprocess.run( 

633 [ 

634 "aws", 

635 "eks", 

636 "associate-access-policy", 

637 "--cluster-name", 

638 cluster, 

639 "--region", 

640 region, 

641 "--principal-arn", 

642 principal_arn, 

643 "--policy-arn", 

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

645 "--access-scope", 

646 "type=cluster", 

647 ], 

648 check=True, 

649 capture_output=True, 

650 text=True, 

651 ) 

652 except subprocess.CalledProcessError: 

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

654 

655 # Step 5: Verify access 

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

657 import time 

658 

659 time.sleep(10) 

660 

661 result = subprocess.run( 

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

663 capture_output=True, 

664 text=True, 

665 ) 

666 if result.returncode == 0: 

667 node_count = len( 

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

669 ) 

670 print(result.stdout) 

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

672 elif private_endpoint_only: 

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

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

675 formatter.print_warning( 

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

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

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

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

680 "or after redeploying with endpoint_access=PUBLIC_AND_PRIVATE." 

681 ) 

682 else: 

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

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

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

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

687 # private-only case. 

688 looks_like_network_block = ( 

689 "i/o timeout" in stderr 

690 or "no route to host" in stderr 

691 or "connection refused" in stderr 

692 or "dial tcp" in stderr 

693 ) 

694 if looks_like_network_block: 

695 formatter.print_warning( 

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

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

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

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

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

701 ) 

702 else: 

703 formatter.print_warning( 

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

705 ) 

706 

707 except subprocess.CalledProcessError as e: 

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

709 sys.exit(1) 

710 except FileNotFoundError as e: 

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

712 sys.exit(1) 

713 except Exception as e: 

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

715 sys.exit(1) 

716 

717 

718@stacks.group("fsx") 

719@pass_config 

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

721 """Manage FSx for Lustre configuration.""" 

722 pass 

723 

724 

725@fsx_cmd.command("status") 

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

727@pass_config 

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

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

730 from ..stacks import get_fsx_config 

731 

732 formatter = get_output_formatter(config) 

733 

734 try: 

735 fsx_config = get_fsx_config(region) 

736 if region: 

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

738 else: 

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

740 formatter.print(fsx_config) 

741 except Exception as e: 

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

743 sys.exit(1) 

744 

745 

746@fsx_cmd.command("enable") 

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

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

749@click.option( 

750 "--deployment-type", 

751 "-d", 

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

753 default="SCRATCH_2", 

754 help="FSx deployment type", 

755) 

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

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

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

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

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

761@pass_config 

762def fsx_enable( 

763 config: Any, 

764 region: Any, 

765 storage_capacity: Any, 

766 deployment_type: Any, 

767 throughput: Any, 

768 compression: Any, 

769 import_path: Any, 

770 export_path: Any, 

771 yes: Any, 

772) -> None: 

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

774 

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

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

777 

778 Examples: 

779 gco stacks fsx enable 

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

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

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

783 """ 

784 from ..stacks import update_fsx_config 

785 

786 formatter = get_output_formatter(config) 

787 

788 if storage_capacity < 1200: 

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

790 sys.exit(1) 

791 

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

793 

794 if not yes: 

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

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

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

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

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

800 if import_path: 800 ↛ 802line 800 didn't jump to line 802 because the condition on line 800 was always true

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

802 if export_path: 802 ↛ 804line 802 didn't jump to line 804 because the condition on line 802 was always true

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

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

805 

806 try: 

807 fsx_settings = { 

808 "enabled": True, 

809 "storage_capacity_gib": storage_capacity, 

810 "deployment_type": deployment_type, 

811 "per_unit_storage_throughput": throughput, 

812 "data_compression_type": compression, 

813 "import_path": import_path, 

814 "export_path": export_path, 

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

816 } 

817 

818 update_fsx_config(fsx_settings, region) 

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

820 if region: 

821 formatter.print_info( 

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

823 ) 

824 else: 

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

826 

827 except Exception as e: 

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

829 sys.exit(1) 

830 

831 

832@fsx_cmd.command("disable") 

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

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

835@pass_config 

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

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

838 

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

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

841 

842 Examples: 

843 gco stacks fsx disable 

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

845 """ 

846 from ..stacks import update_fsx_config 

847 

848 formatter = get_output_formatter(config) 

849 

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

851 

852 if not yes: 

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

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

855 click.confirm("Are you sure?", abort=True) 

856 

857 try: 

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

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

860 if region: 

861 formatter.print_info( 

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

863 ) 

864 else: 

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

866 

867 except Exception as e: 

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

869 sys.exit(1) 

870 

871 

872# ============================================================================= 

873# Valkey commands 

874# ============================================================================= 

875 

876 

877@stacks.group("valkey") 

878@pass_config 

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

880 """Manage Valkey Serverless cache configuration.""" 

881 pass 

882 

883 

884@valkey_cmd.command("status") 

885@pass_config 

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

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

888 from ..stacks import get_valkey_config 

889 

890 formatter = get_output_formatter(config) 

891 

892 try: 

893 valkey_config = get_valkey_config() 

894 formatter.print_info("Valkey config:") 

895 formatter.print(valkey_config) 

896 except Exception as e: 

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

898 sys.exit(1) 

899 

900 

901@valkey_cmd.command("enable") 

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

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

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

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

906@pass_config 

907def valkey_enable( 

908 config: Any, 

909 max_storage: Any, 

910 max_ecpu: Any, 

911 snapshot_retention: Any, 

912 yes: Any, 

913) -> None: 

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

915 

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

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

918 

919 Examples: 

920 gco stacks valkey enable 

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

922 """ 

923 from ..stacks import update_valkey_config 

924 

925 formatter = get_output_formatter(config) 

926 

927 if not yes: 

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

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

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

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

932 click.confirm("\nEnable Valkey Serverless?", abort=True) 

933 

934 try: 

935 valkey_settings = { 

936 "enabled": True, 

937 "max_data_storage_gb": max_storage, 

938 "max_ecpu_per_second": max_ecpu, 

939 "snapshot_retention_limit": snapshot_retention, 

940 } 

941 

942 update_valkey_config(valkey_settings) 

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

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

945 

946 except Exception as e: 

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

948 sys.exit(1) 

949 

950 

951@valkey_cmd.command("disable") 

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

953@pass_config 

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

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

956 

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

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

959 

960 Examples: 

961 gco stacks valkey disable 

962 """ 

963 from ..stacks import update_valkey_config 

964 

965 formatter = get_output_formatter(config) 

966 

967 if not yes: 

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

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

970 click.confirm("Are you sure?", abort=True) 

971 

972 try: 

973 update_valkey_config({"enabled": False}) 

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

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

976 

977 except Exception as e: 

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

979 sys.exit(1) 

980 

981 

982# ============================================================================= 

983# Aurora pgvector commands 

984# ============================================================================= 

985 

986 

987@stacks.group("aurora") 

988@pass_config 

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

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

991 pass 

992 

993 

994@aurora_cmd.command("status") 

995@pass_config 

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

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

998 from ..stacks import get_aurora_config 

999 

1000 formatter = get_output_formatter(config) 

1001 

1002 try: 

1003 aurora_config = get_aurora_config() 

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

1005 formatter.print(aurora_config) 

1006 except Exception as e: 

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

1008 sys.exit(1) 

1009 

1010 

1011@aurora_cmd.command("enable") 

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

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

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

1015@click.option( 

1016 "--deletion-protection/--no-deletion-protection", 

1017 default=False, 

1018 help="Enable deletion protection", 

1019) 

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

1021@pass_config 

1022def aurora_enable( 

1023 config: Any, 

1024 min_acu: Any, 

1025 max_acu: Any, 

1026 backup_retention: Any, 

1027 deletion_protection: Any, 

1028 yes: Any, 

1029) -> None: 

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

1031 

1032 Aurora Serverless v2 with pgvector provides vector similarity search 

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

1034 

1035 Examples: 

1036 gco stacks aurora enable 

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

1038 """ 

1039 from ..stacks import update_aurora_config 

1040 

1041 formatter = get_output_formatter(config) 

1042 

1043 if min_acu < 0: 

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

1045 sys.exit(1) 

1046 if max_acu < 1: 

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

1048 sys.exit(1) 

1049 if max_acu < min_acu: 

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

1051 sys.exit(1) 

1052 

1053 if not yes: 

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

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

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

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

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

1059 click.confirm("\nEnable Aurora pgvector?", abort=True) 

1060 

1061 try: 

1062 aurora_settings = { 

1063 "enabled": True, 

1064 "min_acu": min_acu, 

1065 "max_acu": max_acu, 

1066 "backup_retention_days": backup_retention, 

1067 "deletion_protection": deletion_protection, 

1068 } 

1069 

1070 update_aurora_config(aurora_settings) 

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

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

1073 

1074 except Exception as e: 

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

1076 sys.exit(1) 

1077 

1078 

1079@aurora_cmd.command("disable") 

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

1081@pass_config 

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

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

1084 

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

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

1087 deletion protection is enabled. 

1088 

1089 Examples: 

1090 gco stacks aurora disable 

1091 """ 

1092 from ..stacks import update_aurora_config 

1093 

1094 formatter = get_output_formatter(config) 

1095 

1096 if not yes: 

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

1098 formatter.print_warning( 

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

1100 "(unless deletion protection is enabled)." 

1101 ) 

1102 click.confirm("Are you sure?", abort=True) 

1103 

1104 try: 

1105 update_aurora_config({"enabled": False}) 

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

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

1108 

1109 except Exception as e: 

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

1111 sys.exit(1) 

1112 

1113 

1114def _project_name() -> str: 

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

1116 import json 

1117 from pathlib import Path 

1118 

1119 try: 

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

1121 ctx = (json.load(f) or {}).get("context", {}) 

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

1123 except OSError, ValueError: 

1124 return "gco" 

1125 

1126 

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

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

1129 

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

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

1132 the configured default. 

1133 """ 

1134 cdk_regions = _load_cdk_json() 

1135 regional = ( 

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

1137 ) 

1138 

1139 if all_regions: 

1140 return regional 

1141 if region: 

1142 return [str(region)] 

1143 if regional: 1143 ↛ 1145line 1143 didn't jump to line 1145 because the condition on line 1143 was always true

1144 return [str(regional[0])] 

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

1146 

1147 

1148@stacks.group("addons") 

1149@pass_config 

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

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

1152 

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

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

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

1156 """ 

1157 pass 

1158 

1159 

1160@addons_cmd.command("status") 

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

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

1163@pass_config 

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

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

1166 

1167 Examples: 

1168 gco stacks addons status 

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

1170 gco stacks addons status --all-regions 

1171 """ 

1172 formatter = get_output_formatter(config) 

1173 project = _project_name() 

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

1175 _addons_status_one(formatter, project, target) 

1176 

1177 

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

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

1180 import json 

1181 

1182 import boto3 

1183 

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

1185 

1186 try: 

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

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

1189 paginator = ssm.get_paginator("get_parameters_by_path") 

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

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

1192 except Exception as e: 

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

1194 return 

1195 

1196 rows = [] 

1197 for p in params: 

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

1199 if name == "_input": 1199 ↛ 1200line 1199 didn't jump to line 1200 because the condition on line 1199 was never true

1200 continue 

1201 try: 

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

1203 except ValueError: 

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

1205 rows.append((name, data.get("status", "unknown"), data.get("message", "")[:80])) 

1206 

1207 if not rows: 1207 ↛ 1208line 1207 didn't jump to line 1208 because the condition on line 1207 was never true

1208 formatter.print_info( 

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

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

1211 ) 

1212 return 

1213 

1214 rows.sort() 

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

1216 for name, status, message in rows: 

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

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

1219 formatter.print_success(line) 

1220 else: 

1221 formatter.print_error(line) 

1222 

1223 

1224@addons_cmd.command("install") 

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

1226@click.option( 

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

1228) 

1229@pass_config 

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

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

1232 

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

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

1235 transient failure instead of a full stack redeploy. 

1236 

1237 Examples: 

1238 gco stacks addons install 

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

1240 gco stacks addons install --all-regions 

1241 """ 

1242 formatter = get_output_formatter(config) 

1243 project = _project_name() 

1244 failures = 0 

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

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

1247 failures += 1 

1248 if failures: 

1249 sys.exit(1) 

1250 

1251 

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

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

1254 

1255 The orchestrator stores the execution input encoded because SSM rejects 

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

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

1258 """ 

1259 import base64 

1260 import zlib 

1261 

1262 if stored_value.lstrip().startswith("{"): 1262 ↛ 1263line 1262 didn't jump to line 1263 because the condition on line 1262 was never true

1263 return stored_value 

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

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

1266 

1267 

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

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

1270 import boto3 

1271 from botocore.exceptions import ClientError 

1272 

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

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

1275 

1276 try: 

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

1278 try: 

1279 ssm.get_parameter(Name=fence_param) 

1280 except ClientError as exc: 

1281 if exc.response.get("Error", {}).get("Code") != "ParameterNotFound": 1281 ↛ 1282line 1281 didn't jump to line 1282 because the condition on line 1281 was never true

1282 raise 

1283 else: 

1284 formatter.print_error( 

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

1286 ) 

1287 return False 

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

1289 execution_input = _decode_addon_replay_input(stored_input) 

1290 except Exception as e: 

1291 formatter.print_error( 

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

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

1294 ) 

1295 return False 

1296 

1297 try: 

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

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

1300 arn = next( 

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

1302 None, 

1303 ) 

1304 if not arn: 1304 ↛ 1305line 1304 didn't jump to line 1305 because the condition on line 1304 was never true

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

1306 return False 

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

1308 except Exception as e: 

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

1310 return False 

1311 

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

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

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

1315 return True