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
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""Stack deployment and management commands."""
3import sys
4from typing import Any
6import click
8from ..config import GCOConfig, _load_cdk_json
9from ..output import get_output_formatter
11pass_config = click.make_pass_decorator(GCOConfig, ensure=True)
14@click.group()
15@pass_config
16def stacks(config: Any) -> None:
17 """Deploy and manage GCO CDK stacks."""
18 pass
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
32 formatter = get_output_formatter(config)
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()
43 formatter.print_info("Available CDK stacks:")
44 for stack in local_stacks:
45 print(f" - {stack}")
47 except Exception as e:
48 formatter.print_error(f"Failed to list stacks: {e}")
49 sys.exit(1)
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
60 formatter = get_output_formatter(config)
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)
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
80 formatter = get_output_formatter(config)
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)
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.
103 For deploying all stacks in the correct order, use 'deploy-all'.
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
112 formatter = get_output_formatter(config)
114 # Parse tags
115 tags = {}
116 for t in tag:
117 if "=" in t:
118 k, v = t.split("=", 1)
119 tags[k] = v
121 try:
122 manager = get_stack_manager(config)
124 formatter.print_info(f"Deploying {stack_name}...")
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 )
133 if success:
134 formatter.print_success("Deployment completed successfully")
135 else:
136 formatter.print_error("Deployment failed")
137 sys.exit(1)
139 except Exception as e:
140 formatter.print_error(f"Deployment failed: {e}")
141 sys.exit(1)
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.
151 For destroying all stacks in the correct order, use 'destroy-all'.
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
159 formatter = get_output_formatter(config)
161 if not yes:
162 click.confirm(f"Are you sure you want to destroy {stack_name}?", abort=True)
164 try:
165 manager = get_stack_manager(config)
167 formatter.print_info(f"Destroying {stack_name}...")
169 success = manager.destroy(
170 stack_name=stack_name,
171 force=yes,
172 )
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)
180 except Exception as e:
181 formatter.print_error(f"Destroy failed: {e}")
182 sys.exit(1)
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.
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
202 Use --parallel to deploy regional stacks concurrently, which can
203 significantly reduce total deployment time when deploying to
204 multiple regions.
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
214 formatter = get_output_formatter(config)
216 # Parse tags
217 tags = {}
218 for t in tag:
219 if "=" in t:
220 k, v = t.split("=", 1)
221 tags[k] = v
223 try:
224 manager = get_stack_manager(config)
225 stacks = manager.list_stacks()
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})")
231 def on_start(stack_name: str) -> None:
232 formatter.print_info(f"Deploying {stack_name}...")
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")
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 )
250 formatter.print_info("")
251 formatter.print_info(f"Deployed: {len(successful)}/{len(stacks)} stacks")
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)
259 except Exception as e:
260 formatter.print_error(f"Deployment failed: {e}")
261 sys.exit(1)
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.
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)
278 Automatically retries up to 3 times (with 30s waits) if any stacks fail,
279 which handles transient issues like orphaned resources during teardown.
281 Use --parallel to destroy regional stacks concurrently, which can
282 significantly reduce total teardown time when destroying multiple
283 regional stacks.
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
292 from ..stacks import get_stack_destroy_order, get_stack_manager
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
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 )
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)
315 total_stacks = len(stacks)
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)
335 formatter.print_info(f"Destroying {len(stacks)} stacks...")
336 if parallel:
337 formatter.print_info(f"Parallel mode enabled (max workers: {max_workers})")
339 def on_start(stack_name: str) -> None:
340 formatter.print_info(f"Destroying {stack_name}...")
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")
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 )
356 if success:
357 break
359 if attempt < max_attempts:
360 formatter.print_warning(f"{len(failed)} stack(s) failed: {', '.join(failed)}")
362 formatter.print_info("")
363 formatter.print_info(f"Destroyed: {total_stacks - len(failed)}/{total_stacks} stacks")
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)
371 except Exception as e:
372 formatter.print_error(f"Destroy failed: {e}")
373 sys.exit(1)
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.
383 This is required before deploying stacks to a new account/region.
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
391 formatter = get_output_formatter(config)
393 try:
394 manager = get_stack_manager(config)
395 formatter.print_info(f"Bootstrapping CDK in {region}...")
397 success = manager.bootstrap(account=account, region=region)
399 if success:
400 formatter.print_success(f"CDK bootstrapped in {region}")
401 else:
402 formatter.print_error("Bootstrap failed")
403 sys.exit(1)
405 except Exception as e:
406 formatter.print_error(f"Bootstrap failed: {e}")
407 sys.exit(1)
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
418 formatter = get_output_formatter(config)
420 try:
421 manager = get_stack_manager(config)
422 status = manager.get_stack_status(stack_name, region)
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)
430 except Exception as e:
431 formatter.print_error(f"Failed to get stack status: {e}")
432 sys.exit(1)
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
443 formatter = get_output_formatter(config)
445 try:
446 manager = get_stack_manager(config)
447 outputs = manager.get_outputs(stack_name, region)
449 if outputs:
450 formatter.print(outputs)
451 else:
452 formatter.print_warning(f"No outputs found for {stack_name}")
454 except Exception as e:
455 formatter.print_error(f"Failed to get outputs: {e}")
456 sys.exit(1)
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.
466 Updates kubeconfig, creates an EKS access entry for your IAM principal,
467 and associates the cluster admin policy. Handles assumed roles automatically.
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
476 from .._image_uri import aws_partition
477 from ..config import _load_cdk_json
479 formatter = get_output_formatter(config)
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"
489 partition = aws_partition(str(region))
491 # Determine cluster name
492 if not cluster:
493 cluster = f"{config.project_name}-{region}"
495 formatter.print_info(f"Setting up access to cluster: {cluster} in region: {region}")
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
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}")
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 )
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}")
583 # Handle assumed roles — extract the role ARN from the assumed-role ARN
584 if ":assumed-role/" in principal_arn:
585 import re
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}")
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")
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")
655 # Step 5: Verify access
656 formatter.print_info("Waiting for permissions to propagate...")
657 import time
659 time.sleep(10)
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 )
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)
718@stacks.group("fsx")
719@pass_config
720def fsx_cmd(config: Any) -> None:
721 """Manage FSx for Lustre configuration."""
722 pass
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
732 formatter = get_output_formatter(config)
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)
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.
775 FSx for Lustre provides high-performance parallel file system storage
776 ideal for ML training workloads requiring high throughput and low latency.
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
786 formatter = get_output_formatter(config)
788 if storage_capacity < 1200:
789 formatter.print_error("Storage capacity must be at least 1200 GiB")
790 sys.exit(1)
792 scope = f"region {region}" if region else "all regions (global)"
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)
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 }
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")
827 except Exception as e:
828 formatter.print_error(f"Failed to enable FSx: {e}")
829 sys.exit(1)
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.
839 Note: This only updates the configuration. Run 'gco stacks deploy'
840 to apply changes. Existing FSx file systems will be deleted.
842 Examples:
843 gco stacks fsx disable
844 gco stacks fsx disable --region us-east-1
845 """
846 from ..stacks import update_fsx_config
848 formatter = get_output_formatter(config)
850 scope = f"region {region}" if region else "all regions (global)"
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)
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")
867 except Exception as e:
868 formatter.print_error(f"Failed to disable FSx: {e}")
869 sys.exit(1)
872# =============================================================================
873# Valkey commands
874# =============================================================================
877@stacks.group("valkey")
878@pass_config
879def valkey_cmd(config: Any) -> None:
880 """Manage Valkey Serverless cache configuration."""
881 pass
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
890 formatter = get_output_formatter(config)
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)
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.
916 Valkey provides a serverless key-value cache for prompt caching,
917 feature stores, session state, and low-latency data access.
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
925 formatter = get_output_formatter(config)
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)
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 }
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")
946 except Exception as e:
947 formatter.print_error(f"Failed to enable Valkey: {e}")
948 sys.exit(1)
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.
957 Note: This only updates the configuration. Run 'gco stacks deploy-all -y'
958 to apply changes. Existing Valkey caches will be deleted.
960 Examples:
961 gco stacks valkey disable
962 """
963 from ..stacks import update_valkey_config
965 formatter = get_output_formatter(config)
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)
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")
977 except Exception as e:
978 formatter.print_error(f"Failed to disable Valkey: {e}")
979 sys.exit(1)
982# =============================================================================
983# Aurora pgvector commands
984# =============================================================================
987@stacks.group("aurora")
988@pass_config
989def aurora_cmd(config: Any) -> None:
990 """Manage Aurora PostgreSQL (pgvector) configuration."""
991 pass
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
1000 formatter = get_output_formatter(config)
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)
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.
1032 Aurora Serverless v2 with pgvector provides vector similarity search
1033 for RAG applications, semantic search, and embedding storage.
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
1041 formatter = get_output_formatter(config)
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)
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)
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 }
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")
1074 except Exception as e:
1075 formatter.print_error(f"Failed to enable Aurora: {e}")
1076 sys.exit(1)
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.
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.
1089 Examples:
1090 gco stacks aurora disable
1091 """
1092 from ..stacks import update_aurora_config
1094 formatter = get_output_formatter(config)
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)
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")
1109 except Exception as e:
1110 formatter.print_error(f"Failed to disable Aurora: {e}")
1111 sys.exit(1)
1114def _project_name() -> str:
1115 """Read project_name from cdk.json context (default 'gco')."""
1116 import json
1117 from pathlib import Path
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"
1127def _target_regions(config: Any, region: Any, all_regions: bool) -> list[str]:
1128 """Resolve which regions a command acts on.
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 )
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")]
1148@stacks.group("addons")
1149@pass_config
1150def addons_cmd(config: Any) -> None:
1151 """Inspect and re-converge cluster add-ons (Helm charts).
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
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).
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)
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
1182 import boto3
1184 prefix = f"/{project}/addons/{region}/"
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
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]))
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
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)
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).
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.
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)
1252def _decode_addon_replay_input(stored_value: str) -> str:
1253 """Reverse the helm orchestrator's zlib+base64 replay-input encoding.
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
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")
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
1273 input_param = f"/{project}/addons/{region}/_input"
1274 fence_param = f"/{project}/addons/{region}/_teardown"
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
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
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