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
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Stack deployment and management commands."""
3import re
4import sys
5from collections.abc import Mapping
6from typing import Any
8import click
10from ..config import GCOConfig, _load_cdk_json
11from ..output import confirm, get_output_formatter, interactive_echo
13pass_config = click.make_pass_decorator(GCOConfig, ensure=True)
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)
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.
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
33 try:
34 return route_enablement_overrides(value)
35 except EnablementOverrideError as exc:
36 raise click.BadParameter(str(exc)) from exc
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.
42 The context rides every CDK invocation of the command so ``list``, ``synth``,
43 and the lifecycle call all evaluate the same app.
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}")
60@click.group()
61@pass_config
62def stacks(config: Any) -> None:
63 """Deploy and manage GCO CDK stacks."""
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
77 formatter = get_output_formatter(config)
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()
88 formatter.print_info("Available CDK stacks:")
89 for stack in local_stacks:
90 print(f" - {stack}")
92 except Exception as e:
93 formatter.print_error(f"Failed to list stacks: {e}")
94 sys.exit(1)
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
105 formatter = get_output_formatter(config)
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)
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
125 formatter = get_output_formatter(config)
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)
139def _print_cluster_access_hint(formatter: Any, config: Any, stack_name: str) -> None:
140 """Point at the cluster-access commands after a regional deploy.
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 )
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.
190 For deploying all stacks in the correct order, use 'deploy-all'.
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
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
206 formatter = get_output_formatter(config)
208 # Parse tags
209 tags = {}
210 for t in tag:
211 if "=" in t:
212 k, v = t.split("=", 1)
213 tags[k] = v
215 try:
216 manager = get_stack_manager(config)
217 _apply_enable_overrides(formatter, manager, enable)
219 formatter.print_info(f"Deploying {stack_name}...")
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 )
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)
235 except Exception as e:
236 formatter.print_error(f"Deployment failed: {e}")
237 sys.exit(1)
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.
266 For destroying all stacks in the correct order, use 'destroy-all'.
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.
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
282 formatter = get_output_formatter(config)
284 if not yes:
285 confirm(f"Are you sure you want to destroy {stack_name}?", abort=True)
287 try:
288 manager = get_stack_manager(config)
289 _apply_enable_overrides(formatter, manager, enable)
291 formatter.print_info(f"Destroying {stack_name}...")
293 success = manager.destroy(
294 stack_name=stack_name,
295 force=yes,
296 )
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)
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)
310 except Exception as e:
311 formatter.print_error(f"Destroy failed: {e}")
312 sys.exit(1)
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.
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
348 Use --parallel to deploy regional stacks concurrently, which can
349 significantly reduce total deployment time when deploying to
350 multiple regions.
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
361 formatter = get_output_formatter(config)
363 # Parse tags
364 tags = {}
365 for t in tag:
366 if "=" in t:
367 k, v = t.split("=", 1)
368 tags[k] = v
370 try:
371 manager = get_stack_manager(config)
372 _apply_enable_overrides(formatter, manager, enable)
373 stacks = manager.list_stacks()
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})")
379 def on_start(stack_name: str) -> None:
380 formatter.print_info(f"Deploying {stack_name}...")
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")
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 )
398 formatter.print_info("")
399 formatter.print_info(f"Deployed: {len(successful)}/{len(stacks)} stacks")
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)
407 except Exception as e:
408 formatter.print_error(f"Deployment failed: {e}")
409 sys.exit(1)
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.
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)
455 Automatically retries up to 3 times (with 30s waits) if any stacks fail,
456 which handles transient issues like orphaned resources during teardown.
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.
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.
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.
479 Use --parallel to destroy regional stacks concurrently, which can
480 significantly reduce total teardown time when destroying multiple
481 regional stacks.
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
492 from ..stacks import get_stack_destroy_order, get_stack_manager
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
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)
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)
537 total_stacks = len(stacks)
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)
557 formatter.print_info(f"Destroying {len(stacks)} stacks...")
558 if parallel:
559 formatter.print_info(f"Parallel mode enabled (max workers: {max_workers})")
561 def on_start(stack_name: str) -> None:
562 formatter.print_info(f"Destroying {stack_name}...")
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")
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 )
580 if success:
581 break
583 if attempt < max_attempts:
584 formatter.print_warning(f"{len(failed)} stack(s) failed: {', '.join(failed)}")
586 formatter.print_info("")
587 formatter.print_info(f"Destroyed: {total_stacks - len(failed)}/{total_stacks} stacks")
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)
601 except Exception as e:
602 formatter.print_error(f"Destroy failed: {e}")
603 sys.exit(1)
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.
613 This is required before deploying stacks to a new account/region.
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
621 formatter = get_output_formatter(config)
623 try:
624 manager = get_stack_manager(config)
625 formatter.print_info(f"Bootstrapping CDK in {region}...")
627 success = manager.bootstrap(account=account, region=region)
629 if success:
630 formatter.print_success(f"CDK bootstrapped in {region}")
631 else:
632 formatter.print_error("Bootstrap failed")
633 sys.exit(1)
635 except Exception as e:
636 formatter.print_error(f"Bootstrap failed: {e}")
637 sys.exit(1)
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.
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
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 )
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.
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
685 formatter = get_output_formatter(config)
687 try:
688 manager = get_stack_manager(config)
689 status = manager.get_stack_status(stack_name, region)
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)
698 except Exception as e:
699 formatter.print_error(f"Failed to get stack status: {e}")
700 sys.exit(1)
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
711 formatter = get_output_formatter(config)
713 try:
714 manager = get_stack_manager(config)
715 outputs = manager.get_outputs(stack_name, region)
717 if outputs:
718 formatter.print(outputs)
719 else:
720 formatter.print_warning(f"No outputs found for {stack_name}")
722 except Exception as e:
723 formatter.print_error(f"Failed to get outputs: {e}")
724 sys.exit(1)
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.
734 Updates kubeconfig, creates an EKS access entry for your IAM principal,
735 and associates the cluster admin policy. Handles assumed roles automatically.
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
744 from .._image_uri import aws_partition
745 from ..config import _load_cdk_json
747 formatter = get_output_formatter(config)
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"
766 partition = aws_partition(str(region))
768 # Determine cluster name
769 if not cluster:
770 cluster = f"{config.project_name}-{region}"
772 formatter.print_info(f"Setting up access to cluster: {cluster} in region: {region}")
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
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}")
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 )
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}")
860 # Handle assumed roles — extract the role ARN from the assumed-role ARN
861 if ":assumed-role/" in principal_arn:
862 import re
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}")
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")
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")
932 # Step 5: Verify access
933 formatter.print_info("Waiting for permissions to propagate...")
934 import time
936 time.sleep(10)
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 )
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)
996# =============================================================================
997# EKS access configuration commands
998# =============================================================================
1001_CIDR_RE = re.compile(r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2})$")
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
1014@stacks.group("eks")
1015@pass_config
1016def eks_cmd(config: Any) -> None:
1017 """EKS cluster access configuration (cdk.json, synth-time only)."""
1020@eks_cmd.group("endpoint")
1021@pass_config
1022def eks_endpoint_cmd(config: Any) -> None:
1023 """EKS API endpoint access mode and CIDR allowlist."""
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).
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.
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()
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)
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)
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)
1082 from ..stacks import update_eks_cluster_config
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)
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 )
1101# =============================================================================
1102# EKS Capabilities (AWS-managed ACK / kro) — read-only surface
1103# =============================================================================
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.
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 """
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.
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).
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
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)
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)
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.
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 )
1199# =============================================================================
1200# Deployment-region commands (managed-config engine veneers)
1201# =============================================================================
1204@stacks.group("regions")
1205@pass_config
1206def regions_cmd(config: Any) -> None:
1207 """Manage workload deployment Regions in cdk.json.
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 """
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.
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
1229 formatter = get_output_formatter(config)
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)
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.
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.
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
1261 formatter = get_output_formatter(config)
1263 if not yes:
1264 confirm(f"Add {region} to deployment_regions.regional in cdk.json?", abort=True)
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)
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())
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.
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.
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
1305 formatter = get_output_formatter(config)
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)
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)
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())
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).
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.
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
1360 formatter = get_output_formatter(config)
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)
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)
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())
1385# =============================================================================
1386# Bedrock model default (managed-config engine veneer)
1387# =============================================================================
1390@stacks.group("bedrock")
1391@pass_config
1392def bedrock_cmd(config: Any) -> None:
1393 """Manage Bedrock model and reasoning defaults in cdk.json.
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 """
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
1409 formatter = get_output_formatter(config)
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)
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).
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.
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
1440 formatter = get_output_formatter(config)
1442 if not yes:
1443 confirm(f"Set bedrock.mission_default_model_id to {model_id} in cdk.json?", abort=True)
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)
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())
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.
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.
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
1485 formatter = get_output_formatter(config)
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 )
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)
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())
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.
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.
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
1529 formatter = get_output_formatter(config)
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 )
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)
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())
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.
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.
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
1572 formatter = get_output_formatter(config)
1574 if not yes:
1575 confirm(
1576 f"Set bedrock.codex_default_model_id to {model_id} in cdk.json?",
1577 abort=True,
1578 )
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)
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())
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.
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.
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
1620 formatter = get_output_formatter(config)
1622 if not yes:
1623 confirm(
1624 f"Set bedrock.codex.reasoning_effort to {reasoning_effort} in cdk.json?",
1625 abort=True,
1626 )
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)
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())
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.
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.
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
1663 formatter = get_output_formatter(config)
1665 if not yes:
1666 confirm(
1667 f"Set bedrock.opencode_default_model_id to {model_id} in cdk.json?",
1668 abort=True,
1669 )
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)
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())
1688# =============================================================================
1689# FSx commands
1690# =============================================================================
1693@stacks.group("fsx")
1694@pass_config
1695def fsx_cmd(config: Any) -> None:
1696 """Manage FSx for Lustre configuration."""
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
1706 formatter = get_output_formatter(config)
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)
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.
1749 FSx for Lustre provides high-performance parallel file system storage
1750 ideal for ML training workloads requiring high throughput and low latency.
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
1760 formatter = get_output_formatter(config)
1762 if storage_capacity < 1200:
1763 formatter.print_error("Storage capacity must be at least 1200 GiB")
1764 sys.exit(1)
1766 scope = f"region {region}" if region else "all regions (global)"
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)
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 }
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")
1801 except Exception as e:
1802 formatter.print_error(f"Failed to enable FSx: {e}")
1803 sys.exit(1)
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.
1813 Note: This only updates the configuration. Run 'gco stacks deploy'
1814 to apply changes. Existing FSx file systems will be deleted.
1816 Examples:
1817 gco stacks fsx disable
1818 gco stacks fsx disable --region us-east-1
1819 """
1820 from ..stacks import update_fsx_config
1822 formatter = get_output_formatter(config)
1824 scope = f"region {region}" if region else "all regions (global)"
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)
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")
1841 except Exception as e:
1842 formatter.print_error(f"Failed to disable FSx: {e}")
1843 sys.exit(1)
1846# =============================================================================
1847# Valkey commands
1848# =============================================================================
1851@stacks.group("valkey")
1852@pass_config
1853def valkey_cmd(config: Any) -> None:
1854 """Manage Valkey Serverless cache configuration."""
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
1863 formatter = get_output_formatter(config)
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)
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.
1889 Valkey provides a serverless key-value cache for prompt caching,
1890 feature stores, session state, and low-latency data access.
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
1898 formatter = get_output_formatter(config)
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)
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 }
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")
1919 except Exception as e:
1920 formatter.print_error(f"Failed to enable Valkey: {e}")
1921 sys.exit(1)
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.
1930 Note: This only updates the configuration. Run 'gco stacks deploy-all -y'
1931 to apply changes. Existing Valkey caches will be deleted.
1933 Examples:
1934 gco stacks valkey disable
1935 """
1936 from ..stacks import update_valkey_config
1938 formatter = get_output_formatter(config)
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)
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")
1950 except Exception as e:
1951 formatter.print_error(f"Failed to disable Valkey: {e}")
1952 sys.exit(1)
1955# =============================================================================
1956# Aurora pgvector commands
1957# =============================================================================
1960@stacks.group("aurora")
1961@pass_config
1962def aurora_cmd(config: Any) -> None:
1963 """Manage Aurora PostgreSQL (pgvector) configuration."""
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
1972 formatter = get_output_formatter(config)
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)
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.
2004 Aurora Serverless v2 with pgvector provides vector similarity search
2005 for RAG applications, semantic search, and embedding storage.
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
2013 formatter = get_output_formatter(config)
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)
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)
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 }
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")
2046 except Exception as e:
2047 formatter.print_error(f"Failed to enable Aurora: {e}")
2048 sys.exit(1)
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.
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.
2061 Examples:
2062 gco stacks aurora disable
2063 """
2064 from ..stacks import update_aurora_config
2066 formatter = get_output_formatter(config)
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)
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")
2081 except Exception as e:
2082 formatter.print_error(f"Failed to disable Aurora: {e}")
2083 sys.exit(1)
2086def _project_name() -> str:
2087 """Read project_name from cdk.json context (default 'gco')."""
2088 import json
2089 from pathlib import Path
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"
2104def _target_regions(config: Any, region: Any, all_regions: bool) -> list[str]:
2105 """Resolve which regions a command acts on.
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 )
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")]
2125@stacks.group("addons")
2126@pass_config
2127def addons_cmd(config: Any) -> None:
2128 """Inspect and re-converge cluster add-ons (Helm charts).
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 """
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).
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)
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"})
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
2165 import boto3
2167 prefix = f"/{project}/addons/{region}/"
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
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))
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
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)
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).
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.
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)
2245def _decode_addon_replay_input(stored_value: str) -> str:
2246 """Reverse the helm orchestrator's zlib+base64 replay-input encoding.
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
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")
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
2266 input_param = f"/{project}/addons/{region}/_input"
2267 fence_param = f"/{project}/addons/{region}/_teardown"
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
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
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