Coverage for cli / commands / capacity_cmd.py: 100.00%
825 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"""Capacity checking commands."""
3import sys
4from typing import Any
6import click
7from botocore.exceptions import ClientError
9from gco.bedrock import BEDROCK_FTU_REMEDIATION, is_bedrock_ftu_form_error
11from ..capacity import get_capacity_checker
12from ..capacity.history import METRIC_FIELDS
13from ..config import GCOConfig
14from ..output import confirm, format_capacity_table, get_output_formatter
16pass_config = click.make_pass_decorator(GCOConfig, ensure=True)
19@click.group()
20@pass_config
21def capacity(config: Any) -> None:
22 """Check EC2 capacity availability."""
25@capacity.command("check")
26@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
27@click.option("--region", "-r", required=True, help="AWS region")
28@click.option(
29 "--type",
30 "-t",
31 "capacity_type",
32 type=click.Choice(["spot", "on-demand", "both"]),
33 default="both",
34 help="Capacity type to check",
35)
36@click.option(
37 "--enrich-historical",
38 is_flag=True,
39 help="Append historical capacity context (requires historical.enabled)",
40)
41@pass_config
42def check_capacity(
43 config: Any,
44 instance_type: Any,
45 region: Any,
46 capacity_type: Any,
47 enrich_historical: Any,
48) -> None:
49 """Check capacity availability for an instance type.
51 Provides estimates based on spot price history and availability patterns.
52 """
53 formatter = get_output_formatter(config)
54 checker = get_capacity_checker(config)
56 try:
57 estimates = checker.estimate_capacity(instance_type, region, capacity_type)
59 if config.output_format == "table":
60 print(format_capacity_table(estimates))
61 else:
62 formatter.print(estimates)
64 if enrich_historical:
65 _print_historical_enrichment(formatter, instance_type, region)
67 except Exception as e:
68 formatter.print_error(f"Failed to check capacity: {e}")
69 sys.exit(1)
72@capacity.command("recommend")
73@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
74@click.option("--region", "-r", required=True, help="AWS region")
75@click.option(
76 "--fault-tolerance",
77 "-f",
78 type=click.Choice(["high", "medium", "low"]),
79 default="medium",
80 help="Fault tolerance level",
81)
82@pass_config
83def recommend_capacity(config: Any, instance_type: Any, region: Any, fault_tolerance: Any) -> None:
84 """Get capacity type recommendation for a workload."""
85 formatter = get_output_formatter(config)
86 checker = get_capacity_checker(config)
88 try:
89 capacity_type, explanation = checker.recommend_capacity_type(
90 instance_type, region, fault_tolerance
91 )
93 if config.output_format == "table":
94 formatter.print_info(f"Recommended: {capacity_type.upper()}")
95 formatter.print_info(f"Reason: {explanation}")
96 else:
97 formatter.print(
98 {
99 "capacity_type": capacity_type,
100 "explanation": explanation,
101 }
102 )
104 except Exception as e:
105 formatter.print_error(f"Failed to get recommendation: {e}")
106 sys.exit(1)
109@capacity.command("spot-prices")
110@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
111@click.option("--region", "-r", required=True, help="AWS region")
112@click.option("--days", "-d", default=7, help="Days of history")
113@pass_config
114def spot_prices(config: Any, instance_type: Any, region: Any, days: Any) -> None:
115 """Get spot price history for an instance type."""
116 formatter = get_output_formatter(config)
117 checker = get_capacity_checker(config)
119 try:
120 prices = checker.get_spot_price_history(instance_type, region, days)
122 if not prices:
123 formatter.print_warning(f"No spot price data for {instance_type} in {region}")
124 return
126 formatter.print(
127 prices,
128 columns=[
129 "availability_zone",
130 "current_price",
131 "avg_price_7d",
132 "min_price_7d",
133 "max_price_7d",
134 "price_stability",
135 ],
136 )
138 except Exception as e:
139 formatter.print_error(f"Failed to get spot prices: {e}")
140 sys.exit(1)
143@capacity.command("instance-info")
144@click.argument("instance_type")
145@click.option(
146 "--region",
147 "-r",
148 help="Region to describe the type in (default: the configured default region)",
149)
150@pass_config
151def instance_info(config: Any, instance_type: Any, region: Any) -> None:
152 """Describe an instance type's compute characteristics.
154 Resolved live from EC2 DescribeInstanceTypes every time — there is no
155 checked-in specification table, so a newly launched accelerator family is
156 reported the day it ships.
158 Reports vCPUs/cores/threads, memory, every accelerator class (NVIDIA GPU,
159 AWS Neuron, Inferentia, media, FPGA) with per-model breakdowns, EFA and
160 network limits, local NVMe and EBS characteristics, placement-group support,
161 purchase options (spot / capacity-block), and platform capabilities.
163 DescribeInstanceTypes is region-scoped: a type is only described where it is
164 offered. If it is missing, try another region or `gco capacity
165 recommend-region`.
167 Examples:
168 gco capacity instance-info p5.48xlarge
169 gco capacity instance-info trn2.48xlarge -r us-east-1
170 gco -o json capacity instance-info p5.48xlarge
171 gco -o json capacity instance-info g5.2xlarge | jq '.gpu_devices'
172 """
173 formatter = get_output_formatter(config)
174 checker = get_capacity_checker(config)
176 try:
177 info = checker.get_instance_info(instance_type, region=region)
178 if not info:
179 target = region or config.default_region
180 formatter.print_error(
181 f"Could not describe instance type '{instance_type}' in {target}. "
182 "It may not exist, or may not be offered in that region — try "
183 "--region, or 'gco capacity recommend-region'."
184 )
185 sys.exit(1)
187 if config.output_format != "table":
188 formatter.print(info)
189 return
191 print(f"\n {info.instance_type} ({info.region})")
192 print(" " + "-" * 58)
193 print(f" vCPUs: {info.vcpus}", end="")
194 if info.cores is not None:
195 print(f" ({info.cores} cores x {info.threads_per_core} threads)", end="")
196 print()
197 print(f" Memory: {info.memory_gib} GiB")
198 print(f" Architecture: {', '.join(info.architectures) or info.architecture}")
199 if info.processor_manufacturer:
200 clock = (
201 f" @ {info.sustained_clock_speed_ghz} GHz" if info.sustained_clock_speed_ghz else ""
202 )
203 print(f" Processor: {info.processor_manufacturer}{clock}")
205 if info.gpu_devices:
206 print(f"\n GPUs: {info.gpu_count} total, {info.gpu_memory_gib} GiB total")
207 for device in info.gpu_devices:
208 print(
209 f" {device.get('count')}x {device.get('manufacturer')} "
210 f"{device.get('name')} @ {device.get('memory_gib')} GiB each"
211 )
212 if info.neuron_devices:
213 print(
214 f"\n Neuron: {info.neuron_count} devices, "
215 f"{info.neuron_memory_gib} GiB total"
216 )
217 for device in info.neuron_devices:
218 print(
219 f" {device.get('count')}x {device.get('name')} "
220 f"({device.get('core_count')} cores v{device.get('core_version')})"
221 )
222 if info.inference_accelerators:
223 print(f"\n Inferentia: {info.inference_accelerator_count} accelerators")
225 print("\n NETWORK")
226 print(f" EFA: {'yes' if info.efa_supported else 'no'}", end="")
227 if info.efa_max_interfaces:
228 print(f" (max {info.efa_max_interfaces} interfaces)", end="")
229 print()
230 print(f" Performance: {info.network_performance}")
231 print(f" Max ENIs: {info.maximum_network_interfaces}")
232 if info.maximum_network_cards:
233 print(f" Network cards: {info.maximum_network_cards}")
235 print("\n STORAGE")
236 if info.instance_storage_total_gb:
237 disks = ", ".join(
238 f"{d.get('count')}x {d.get('size_gb')} GB {d.get('type')}"
239 for d in info.instance_storage_disks
240 )
241 print(f" Local: {info.instance_storage_total_gb} GB ({disks})")
242 else:
243 print(" Local: none (EBS only)")
244 print(f" EBS optimized: {info.ebs_optimized_support}")
245 if info.ebs_maximum_iops:
246 print(
247 f" EBS max: {info.ebs_maximum_iops} IOPS, "
248 f"{info.ebs_maximum_throughput_mbps} MB/s"
249 )
251 print("\n PURCHASING")
252 print(f" Usage classes: {', '.join(info.supported_usage_classes)}")
253 print(f" Capacity blocks: {'yes' if info.capacity_block_supported else 'no'}")
254 if info.supported_placement_strategies:
255 print(f" Placement: {', '.join(info.supported_placement_strategies)}")
256 print(f" Current gen: {'yes' if info.current_generation else 'no'}")
257 print()
259 except Exception as e:
260 formatter.print_error(f"Failed to get instance info: {e}")
261 sys.exit(1)
264@capacity.command("status")
265@click.option("--region", "-r", help="Specific region to check")
266@click.option("--all-regions", "-a", is_flag=True, default=True, help="Check all regions (default)")
267@pass_config
268def capacity_status(config: Any, region: Any, all_regions: Any) -> None:
269 """Show comprehensive resource utilization across regions.
271 Displays pending/running workloads, GPU/CPU utilization, queue depth,
272 and active job counts for one or all GCO clusters.
274 Examples:
275 gco capacity status
276 gco capacity status --region us-east-1
277 gco capacity status --all-regions
278 """
279 from ..capacity import get_multi_region_capacity_checker
281 formatter = get_output_formatter(config)
283 try:
284 checker = get_multi_region_capacity_checker(config)
286 if region:
287 capacity = checker.get_region_capacity(region)
288 formatter.print(capacity)
289 else:
290 capacities = checker.get_all_regions_capacity()
292 if not capacities:
293 formatter.print_warning("No GCO stacks found")
294 if config.output_format != "table":
295 formatter.print(capacities)
296 return
298 if config.output_format == "table":
299 print("\n REGION QUEUE RUNNING GPU% CPU% SCORE")
300 print(" " + "-" * 55)
301 for c in sorted(capacities, key=lambda x: x.recommendation_score):
302 print(
303 f" {c.region:<15} {c.queue_depth:>5} {c.running_jobs:>7} "
304 f"{c.gpu_utilization:>4.0f}% {c.cpu_utilization:>4.0f}% "
305 f"{c.recommendation_score:>5.0f}"
306 )
308 print()
309 best = min(capacities, key=lambda x: x.recommendation_score)
310 formatter.print_info(f"Recommended region: {best.region} (lowest score = best)")
311 else:
312 formatter.print(capacities)
314 except Exception as e:
315 formatter.print_error(f"Failed to get capacity status: {e}")
316 sys.exit(1)
319@capacity.command("recommend-region")
320@click.option("--gpu", is_flag=True, help="Job requires GPUs")
321@click.option("--min-gpus", default=0, help="Minimum GPUs required")
322@click.option(
323 "--instance-type", "-i", default=None, help="Specific instance type for workload-aware scoring"
324)
325@click.option("--gpu-count", default=0, help="Number of GPUs required")
326@pass_config
327def recommend_region(
328 config: Any, gpu: Any, min_gpus: Any, instance_type: Any, gpu_count: Any
329) -> None:
330 """Recommend optimal region for job placement.
332 Analyzes capacity across all deployed EKS regions and recommends
333 the best region. When --instance-type is provided, uses weighted
334 multi-signal scoring that factors in spot placement scores, pricing,
335 queue depth, GPU utilization, and running job counts.
337 Without --instance-type, uses a simpler composite score based on
338 queue depth, GPU utilization, and running jobs.
340 Examples:
341 gco capacity recommend-region
342 gco capacity recommend-region --gpu
343 gco capacity recommend-region -i g5.xlarge
344 gco capacity recommend-region -i p4d.24xlarge --gpu-count 8
345 """
346 from ..capacity import get_multi_region_capacity_checker
348 formatter = get_output_formatter(config)
350 try:
351 checker = get_multi_region_capacity_checker(config)
352 recommendation = checker.recommend_region_for_job(
353 gpu_required=gpu,
354 min_gpus=min_gpus,
355 instance_type=instance_type,
356 gpu_count=gpu_count,
357 )
359 if config.output_format == "table":
360 formatter.print_success(f"Recommended region: {recommendation['region']}")
361 formatter.print_info(f"Reason: {recommendation['reason']}")
363 if config.verbose:
364 print("\nAll regions ranked:")
365 for r in recommendation.get("all_regions", []):
366 print(
367 f" {r['region']}: score={r['score']:.4f}, "
368 f"queue={r['queue_depth']}, gpu={r['gpu_utilization']:.0f}%"
369 )
370 else:
371 formatter.print(recommendation)
373 except Exception as e:
374 formatter.print_error(f"Failed to get recommendation: {e}")
375 sys.exit(1)
378@capacity.command("ai-recommend")
379@click.option("--workload", "-w", help="Description of your workload")
380@click.option(
381 "--instance-type",
382 "-i",
383 multiple=True,
384 help="Instance types to consider (can specify multiple)",
385)
386@click.option("--region", "-r", multiple=True, help="Regions to consider (can specify multiple)")
387@click.option("--gpu", is_flag=True, help="Workload requires GPUs")
388@click.option("--min-gpus", default=0, help="Minimum GPUs required")
389@click.option("--min-memory-gb", default=0, help="Minimum memory in GB")
390@click.option(
391 "--fault-tolerance",
392 "-f",
393 type=click.Choice(["high", "medium", "low"]),
394 default="medium",
395 help="Fault tolerance level",
396)
397@click.option("--max-cost", type=float, help="Maximum cost per hour in USD")
398@click.option(
399 "--model",
400 "-m",
401 default=None,
402 help=(
403 "Bedrock model ID to use "
404 "(default: cdk.json context.bedrock.capacity_advisor_default_model_id)."
405 ),
406)
407@click.option("--raw", is_flag=True, help="Show raw AI response")
408@pass_config
409def ai_recommend(
410 config: Any,
411 workload: Any,
412 instance_type: Any,
413 region: Any,
414 gpu: Any,
415 min_gpus: Any,
416 min_memory_gb: Any,
417 fault_tolerance: Any,
418 max_cost: Any,
419 model: Any,
420 raw: Any,
421) -> None:
422 """Get AI-powered capacity recommendation using Amazon Bedrock.
424 This command gathers comprehensive capacity data including:
425 - Spot placement scores, pricing, and 7-day per-AZ price trends across regions
426 - On-demand availability and pricing
427 - Capacity Reservations (ODCRs), Capacity Block offerings, and 26-week
428 block-availability trends
429 - Current cluster utilization (queue depth, GPU/CPU usage)
430 - Running and pending job counts
431 - The algorithmic multi-signal region ranking as advisory context
433 Without --instance-type, one representative type per current GPU
434 generation is scanned (T4, L4, A10G, L40S, RTX PRO 4500/6000 Blackwell,
435 A100, H100, H200, B200, B300).
437 The data is analyzed by an LLM to provide intelligent recommendations
438 for where to place your workload.
440 ⚠️ DISCLAIMER: Recommendations are AI-generated and should be validated
441 before making production decisions. Capacity availability and pricing
442 can change rapidly.
444 REQUIREMENTS:
445 - AWS credentials with bedrock:InvokeModel permission
446 - The specified Bedrock model must be enabled in your account
447 - Default model: cdk.json context.bedrock.capacity_advisor_default_model_id
449 Examples:
450 gco capacity ai-recommend --workload "Training a large language model"
452 gco capacity ai-recommend -w "Inference workload" --gpu --min-gpus 4
454 gco capacity ai-recommend -i g5.xlarge -i g5.2xlarge -r us-east-1 -r us-west-2
456 gco capacity ai-recommend --fault-tolerance high --max-cost 5.00
457 """
458 from ..capacity import get_bedrock_capacity_advisor
460 formatter = get_output_formatter(config)
462 # Print disclaimer
463 print()
464 print(" " + "=" * 70)
465 print(" ⚠️ AI-POWERED RECOMMENDATION DISCLAIMER")
466 print(" " + "-" * 70)
467 print(" This recommendation is generated by an AI model and should be")
468 print(" validated before making production decisions.")
469 print(" ")
470 print(" • Capacity availability can change rapidly")
471 print(" • Spot instances may be interrupted at any time")
472 print(" • Pricing data may not reflect real-time prices")
473 print(" • AI recommendations are not guaranteed to be optimal")
474 print(" " + "=" * 70)
475 print()
477 try:
478 formatter.print_info("Gathering capacity data across regions...")
480 advisor = get_bedrock_capacity_advisor(config, model_id=model)
482 # Build requirements dict
483 requirements = {
484 "gpu_required": gpu,
485 "min_gpus": min_gpus if min_gpus > 0 else None,
486 "min_memory_gb": min_memory_gb if min_memory_gb > 0 else None,
487 "fault_tolerance": fault_tolerance,
488 "max_cost_per_hour": max_cost,
489 }
490 # Remove None values
491 requirements = {k: v for k, v in requirements.items() if v is not None}
493 formatter.print_info(f"Analyzing with {advisor.model_id}...")
495 recommendation = advisor.get_recommendation(
496 workload_description=workload,
497 instance_types=list(instance_type) if instance_type else None,
498 regions=list(region) if region else None,
499 requirements=requirements if requirements else None,
500 )
502 # Display recommendation
503 print()
504 print(" " + "=" * 70)
505 print(" 🤖 AI RECOMMENDATION")
506 print(" " + "=" * 70)
507 print()
508 print(f" Region: {recommendation.recommended_region}")
509 print(f" Instance Type: {recommendation.recommended_instance_type}")
510 print(f" Capacity Type: {recommendation.recommended_capacity_type.upper()}")
511 print(f" Confidence: {recommendation.confidence.upper()}")
512 if recommendation.cost_estimate:
513 print(f" Est. Cost: {recommendation.cost_estimate}")
514 print()
515 print(" REASONING:")
516 print(" " + "-" * 68)
517 # Word wrap the reasoning
518 reasoning_lines = recommendation.reasoning.split(". ")
519 for line in reasoning_lines:
520 if line.strip():
521 print(f" {line.strip()}.")
522 print()
524 # Show alternatives
525 if recommendation.alternative_options:
526 print(" ALTERNATIVE OPTIONS:")
527 print(" " + "-" * 68)
528 for i, alt in enumerate(recommendation.alternative_options[:3], 1):
529 print(
530 f" {i}. {alt.get('region', 'N/A')} / "
531 f"{alt.get('instance_type', 'N/A')} / "
532 f"{alt.get('capacity_type', 'N/A').upper()}"
533 )
534 if alt.get("reason"):
535 print(f" {alt['reason']}")
536 print()
538 # Show warnings
539 if recommendation.warnings:
540 print(" ⚠️ WARNINGS:")
541 print(" " + "-" * 68)
542 for warning in recommendation.warnings:
543 print(f" • {warning}")
544 print()
546 # Show raw response if requested
547 if raw:
548 print(" RAW AI RESPONSE:")
549 print(" " + "-" * 68)
550 print(recommendation.raw_response)
551 print()
553 print(" " + "=" * 70)
554 print()
556 except Exception as e:
557 if is_bedrock_ftu_form_error(e):
558 formatter.print_error(BEDROCK_FTU_REMEDIATION)
559 sys.exit(1)
560 # Advisor errors are already fully worded (and may carry their own
561 # remediation); re-prefixing here used to print the same phrase twice.
562 formatter.print_error(str(e))
563 sys.exit(1)
566@capacity.command("reservations")
567@click.option("--instance-type", "-i", help="Filter by instance type")
568@click.option("--region", "-r", help="Specific region (default: all deployed regions)")
569@pass_config
570def list_reservations(config: Any, instance_type: Any, region: Any) -> None:
571 """List On-Demand Capacity Reservations (ODCRs) across regions.
573 Shows all active capacity reservations with utilization details.
575 Examples:
576 gco capacity reservations
577 gco capacity reservations -i p5.48xlarge
578 gco capacity reservations -r us-east-1
579 """
580 formatter = get_output_formatter(config)
581 checker = get_capacity_checker(config)
583 try:
584 if region:
585 reservations = checker.list_capacity_reservations(region, instance_type=instance_type)
586 result = {
587 "regions_checked": [region],
588 "total_reservations": len(reservations),
589 "total_reserved_instances": sum(r["total_instances"] for r in reservations),
590 "total_available_instances": sum(r["available_instances"] for r in reservations),
591 "reservations": reservations,
592 }
593 else:
594 result = checker.list_all_reservations(instance_type=instance_type)
596 if config.output_format != "table":
597 formatter.print(result)
598 return
600 reservations = result["reservations"]
601 if not reservations:
602 formatter.print_info("No active capacity reservations found")
603 return
605 print(f"\n Capacity Reservations ({len(reservations)} found)")
606 print(" " + "-" * 90)
607 print(
608 f" {'INSTANCE TYPE':<18} {'REGION':<15} {'AZ':<18} "
609 f"{'TOTAL':>5} {'AVAIL':>5} {'USED%':>6} {'MATCH CRITERIA'}"
610 )
611 print(" " + "-" * 90)
612 for r in reservations:
613 print(
614 f" {r['instance_type']:<18} {r['region']:<15} "
615 f"{r['availability_zone']:<18} {r['total_instances']:>5} "
616 f"{r['available_instances']:>5} {r['utilization_pct']:>5.1f}% "
617 f"{r.get('instance_match_criteria', 'open')}"
618 )
620 print()
621 print(
622 f" Total: {result['total_reserved_instances']} reserved, "
623 f"{result['total_available_instances']} available"
624 )
625 print()
627 except Exception as e:
628 formatter.print_error(f"Failed to list reservations: {e}")
629 sys.exit(1)
632@capacity.command("reservation-check")
633@click.option("--instance-type", "-i", required=True, help="Instance type to check")
634@click.option(
635 "--region",
636 "-r",
637 "regions",
638 multiple=True,
639 help="Region(s) to check; repeatable (default: all deployed regions)",
640)
641@click.option("--count", "-c", default=1, help="Minimum instances needed")
642@click.option(
643 "--include-blocks/--no-blocks",
644 default=True,
645 help="Include Capacity Block offerings (default: yes)",
646)
647@click.option(
648 "--block-duration",
649 default=24,
650 type=int,
651 help="Capacity Block duration in hours (default: 24)",
652)
653@click.option(
654 "--block-duration-days",
655 default=None,
656 type=int,
657 help="Capacity Block duration in days (overrides --block-duration)",
658)
659@click.option(
660 "--earliest-start",
661 default=None,
662 help="Earliest block start date (YYYY-MM-DD or ISO datetime)",
663)
664@click.option(
665 "--latest-start",
666 default=None,
667 help="Latest block start date (YYYY-MM-DD or ISO datetime)",
668)
669@pass_config
670def reservation_check(
671 config: Any,
672 instance_type: Any,
673 regions: Any,
674 count: Any,
675 include_blocks: Any,
676 block_duration: Any,
677 block_duration_days: Any,
678 earliest_start: Any,
679 latest_start: Any,
680) -> None:
681 """Check reservation availability and Capacity Block offerings.
683 Checks both existing ODCRs and purchasable Capacity Blocks for ML
684 workloads. Capacity Blocks provide guaranteed GPU capacity for a
685 fixed duration at a known price. Pass --region more than once to check
686 several regions in parallel, and use --earliest-start/--latest-start to
687 bound when the block may begin. For a full duration-range sweep across
688 many regions, use 'gco capacity find-blocks'.
690 Examples:
691 gco capacity reservation-check -i p5.48xlarge
692 gco capacity reservation-check -i p4d.24xlarge -c 2 --block-duration 48
693 gco capacity reservation-check -i g5.48xlarge -r us-east-1 --no-blocks
694 gco capacity reservation-check -i p5.48xlarge -r us-east-1 -r us-west-2 \\
695 --block-duration-days 14 --earliest-start 2026-07-01
696 """
697 formatter = get_output_formatter(config)
698 checker = get_capacity_checker(config)
700 try:
701 formatter.print_info(
702 f"Checking reservations for {instance_type} "
703 f"(min {count} instance{'s' if count > 1 else ''})..."
704 )
706 result = checker.check_reservation_availability(
707 instance_type=instance_type,
708 regions=list(regions) or None,
709 min_count=count,
710 include_capacity_blocks=include_blocks,
711 block_duration_hours=block_duration,
712 block_duration_days=block_duration_days,
713 earliest_start=earliest_start,
714 latest_start=latest_start,
715 )
717 if config.output_format != "table":
718 formatter.print(result)
719 return
721 # ODCR section
722 odcr = result["odcr"]
723 print(f"\n On-Demand Capacity Reservations for {instance_type}")
724 print(" " + "-" * 60)
725 if odcr["reservations"]:
726 for r in odcr["reservations"]:
727 print(
728 f" ✓ {r['availability_zone']}: "
729 f"{r['available_instances']}/{r['total_instances']} available "
730 f"({r['reservation_id']})"
731 )
732 print(
733 f"\n Total: {odcr['total_available_instances']} available "
734 f"of {odcr['total_reserved_instances']} reserved"
735 )
736 else:
737 print(" No active ODCRs found for this instance type")
739 # Capacity Blocks section
740 if include_blocks:
741 block_section = result["capacity_blocks"]
742 duration = block_section.get("duration_hours", block_duration)
743 print(f"\n Capacity Block Offerings ({duration}h)")
744 print(" " + "-" * 60)
745 if block_section["offerings"]:
746 for b in block_section["offerings"]:
747 gpu_hr = b.get("price_per_gpu_hour")
748 gpu_hr_str = f" (${gpu_hr}/GPU-hr)" if gpu_hr is not None else ""
749 start = (b.get("start_date") or "")[:16]
750 print(
751 f" ✓ {b['availability_zone']}: "
752 f"{b['instance_count']}x {b['duration_hours']}h "
753 f"starting {start} — ${b['upfront_fee']}{gpu_hr_str}"
754 )
755 else:
756 print(" No Capacity Block offerings available")
758 # Recommendation
759 print()
760 print(f" 💡 {result['recommendation']}")
761 print()
763 except Exception as e:
764 formatter.print_error(f"Failed to check reservations: {e}")
765 sys.exit(1)
768def _print_find_blocks_report(result: dict[str, Any]) -> None:
769 """Render a consolidated find-blocks report as a readable table block."""
770 itype = result["instance_type"]
771 if result.get("requested_instance_type") and result["requested_instance_type"] != itype:
772 print(f"\n Capacity Block search for {result['requested_instance_type']} -> {itype}")
773 else:
774 print(f"\n Capacity Block search for {itype}")
775 print(" " + "-" * 72)
777 window = result.get("date_window", {})
778 earliest = (window.get("earliest_start") or "any")[:16]
779 latest = (window.get("latest_start") or "any")[:16]
780 days = result.get("durations_probed_days") or []
781 if days:
782 span = f"{min(days):g}-{max(days):g}d" if len(days) > 1 else f"{days[0]:g}d"
783 else:
784 span = "n/a"
785 print(f" Regions: {', '.join(result.get('regions_checked', []))}")
786 print(f" Durations probed: {span} Start window: {earliest} .. {latest}")
788 if not result.get("valid_instance_type", True):
789 print()
790 print(f" ⚠ {result.get('recommendation', 'Invalid instance type.')}")
791 print()
792 return
794 offerings = result.get("offerings", [])
795 if not offerings:
796 print()
797 print(f" {result.get('recommendation', 'No offerings found.')}")
798 print()
799 return
801 print()
802 print(f" {'REGION':<13} {'AZ':<17} {'START':<17} {'DUR':>6} {'UPFRONT':>11} {'$/GPU-hr':>10}")
803 print(" " + "-" * 72)
804 for b in offerings:
805 start = (b.get("start_date") or "")[:16]
806 dur = f"{b.get('duration_days') or '?'}d"
807 fee = b.get("upfront_fee_usd")
808 fee_str = f"${fee:,.0f}" if isinstance(fee, int | float) else "?"
809 gpu_hr = b.get("price_per_gpu_hour")
810 gpu_hr_str = f"${gpu_hr:,.2f}" if isinstance(gpu_hr, int | float) else "-"
811 print(
812 f" {b.get('region') or ''!s:<13} {b.get('availability_zone') or ''!s:<17} "
813 f"{start:<17} {dur:>6} {fee_str:>11} {gpu_hr_str:>10}"
814 )
815 print()
816 print(f" 💡 {result['recommendation']}")
817 print()
820@capacity.command("find-blocks")
821@click.option(
822 "--instance-type", "-i", required=True, help="GPU instance type or alias (e.g. p6-b200)"
823)
824@click.option(
825 "--region",
826 "-r",
827 "regions",
828 multiple=True,
829 help="Region(s) to search; repeatable (default: all deployed regions)",
830)
831@click.option("--count", "-c", default=1, help="Instances per block")
832@click.option("--duration-days", default=None, type=int, help="Single target duration in days")
833@click.option("--duration-hours", default=None, type=int, help="Single target duration in hours")
834@click.option(
835 "--min-duration-days", default=None, type=int, help="Minimum duration (days) for a range search"
836)
837@click.option(
838 "--max-duration-days", default=None, type=int, help="Maximum duration (days) for a range search"
839)
840@click.option("--min-duration-hours", default=None, type=int, help="Minimum duration (hours)")
841@click.option("--max-duration-hours", default=None, type=int, help="Maximum duration (hours)")
842@click.option(
843 "--earliest-start", default=None, help="Earliest block start (YYYY-MM-DD or ISO datetime)"
844)
845@click.option(
846 "--latest-start", default=None, help="Latest block start (YYYY-MM-DD or ISO datetime)"
847)
848@click.option(
849 "--find-longest",
850 is_flag=True,
851 help="Sweep the duration ladder and surface the longest available block",
852)
853@pass_config
854def find_blocks(
855 config: Any,
856 instance_type: Any,
857 regions: Any,
858 count: Any,
859 duration_days: Any,
860 duration_hours: Any,
861 min_duration_days: Any,
862 max_duration_days: Any,
863 min_duration_hours: Any,
864 max_duration_hours: Any,
865 earliest_start: Any,
866 latest_start: Any,
867 find_longest: Any,
868) -> None:
869 """Find Capacity Blocks across regions, durations, and a start-date window.
871 One command sweeps every requested region and every valid Capacity Block
872 duration in the range, in parallel, then returns a single consolidated,
873 de-duplicated, ranked report with per-hour and per-GPU-hour pricing.
875 AWS allows Capacity Block durations in 1-day increments up to 14 days, then
876 7-day increments up to 182 days; a duration range is expanded to those
877 discrete values automatically. Friendly names are normalized (p6-b200 ->
878 p6-b200.48xlarge, p6-b300 -> p6-b300.48xlarge); the Grace-Blackwell GB200/
879 GB300 UltraServer families (P6e-GB200/P6e-GB300) are flagged as not standalone.
881 Examples:
882 gco capacity find-blocks -i p6-b200.48xlarge \\
883 -r us-east-1 -r us-east-2 -r us-west-2 -r eu-west-1 \\
884 --min-duration-days 1 --max-duration-days 63 \\
885 --earliest-start 2026-07-01 --latest-start 2026-07-10
886 gco capacity find-blocks -i p5.48xlarge -r us-east-1 --duration-days 14
887 gco capacity find-blocks -i p5.48xlarge -r us-east-1 --find-longest
888 """
889 formatter = get_output_formatter(config)
890 checker = get_capacity_checker(config)
892 try:
893 result = checker.find_capacity_blocks(
894 instance_type,
895 regions=list(regions) or None,
896 instance_count=count,
897 duration_hours=duration_hours,
898 duration_days=duration_days,
899 min_duration_hours=min_duration_hours,
900 min_duration_days=min_duration_days,
901 max_duration_hours=max_duration_hours,
902 max_duration_days=max_duration_days,
903 earliest_start=earliest_start,
904 latest_start=latest_start,
905 find_longest=find_longest,
906 )
908 if config.output_format != "table":
909 formatter.print(result)
910 return
912 _print_find_blocks_report(result)
914 except Exception as e:
915 formatter.print_error(f"Failed to find capacity blocks: {e}")
916 sys.exit(1)
919@capacity.command("reserve")
920@click.option(
921 "--offering-id",
922 "-o",
923 required=True,
924 help="Capacity Block offering ID (cb-xxx) from reservation-check",
925)
926@click.option("--region", "-r", required=True, help="AWS region where the offering exists")
927@click.option(
928 "--dry-run",
929 is_flag=True,
930 help="Validate the offering without purchasing (no cost incurred)",
931)
932@pass_config
933def reserve_capacity(config: Any, offering_id: Any, region: Any, dry_run: Any) -> None:
934 """Purchase a Capacity Block offering by its ID.
936 Use 'gco capacity reservation-check' first to find available offerings
937 and their IDs, then purchase with this command.
939 ⚠️ WARNING: This command purchases capacity and incurs charges.
940 Use --dry-run to validate first.
942 Examples:
943 # First, find offerings:
944 gco capacity reservation-check -i p4d.24xlarge -r us-east-1
946 # Validate without purchasing:
947 gco capacity reserve -o cb-0123456789abcdef0 -r us-east-1 --dry-run
949 # Purchase:
950 gco capacity reserve -o cb-0123456789abcdef0 -r us-east-1
951 """
952 formatter = get_output_formatter(config)
953 checker = get_capacity_checker(config)
955 try:
956 if dry_run:
957 formatter.print_info(f"Dry run: validating offering {offering_id} in {region}...")
958 else:
959 formatter.print_info(f"Purchasing Capacity Block {offering_id} in {region}...")
961 result = checker.purchase_capacity_block(
962 offering_id=offering_id,
963 region=region,
964 dry_run=dry_run,
965 )
967 if config.output_format != "table":
968 formatter.print(result)
969 return
971 if result["success"]:
972 if dry_run:
973 print()
974 print(f" ✓ Dry run passed — offering {offering_id} is valid and purchasable")
975 print(f" Region: {region}")
976 print()
977 print(" To purchase, run without --dry-run:")
978 print(f" gco capacity reserve -o {offering_id} -r {region}")
979 print()
980 else:
981 print()
982 print(" ✓ Capacity Block purchased successfully")
983 print(f" Reservation ID: {result['reservation_id']}")
984 print(f" Instance Type: {result['instance_type']}")
985 print(f" AZ: {result['availability_zone']}")
986 print(f" Instances: {result['total_instances']}")
987 print(f" Start: {result.get('start_date', 'N/A')}")
988 print(f" End: {result.get('end_date', 'N/A')}")
989 print()
990 print(" To create a NodePool for this Capacity Block:")
991 print(
992 f" gco nodepools create-capacity-block -n my-pool -r {region} "
993 f"-c {result['reservation_id']} -i {result['instance_type']}"
994 )
995 print()
996 else:
997 formatter.print_error(
998 f"Failed: {result.get('error_code', 'Unknown')}: {result.get('error', '')}"
999 )
1000 sys.exit(1)
1002 except Exception as e:
1003 formatter.print_error(f"Failed to reserve capacity: {e}")
1004 sys.exit(1)
1007def _print_find_reservations_report(result: dict[str, Any]) -> None:
1008 """Render a consolidated find-reservations report as a readable table block."""
1009 itype = result.get("instance_type") or "any instance type"
1010 req = result.get("requested_instance_type")
1011 if req and req != itype:
1012 print(f"\n ODCR search for {req} -> {itype}")
1013 else:
1014 print(f"\n ODCR search for {itype}")
1015 print(" " + "-" * 78)
1016 print(f" Regions: {', '.join(result.get('regions_checked', []))}")
1018 if req and not result.get("valid_instance_type", True):
1019 print()
1020 print(f" ⚠ {result.get('recommendation', 'Invalid instance type.')}")
1021 print()
1022 return
1024 reservations = result.get("reservations", [])
1025 if not reservations:
1026 print()
1027 print(f" {result.get('recommendation', 'No reservations found.')}")
1028 print()
1029 return
1031 print()
1032 print(
1033 f" {'INSTANCE TYPE':<18} {'REGION':<13} {'AZ':<17} "
1034 f"{'AVAIL':>6} {'TOTAL':>6} {'$/GPU-hr':>10}"
1035 )
1036 print(" " + "-" * 78)
1037 for r in reservations:
1038 gpu_hr = r.get("price_per_gpu_hour")
1039 gpu_hr_str = f"${gpu_hr:,.2f}" if isinstance(gpu_hr, int | float) else "-"
1040 print(
1041 f" {r.get('instance_type') or ''!s:<18} {r.get('region') or ''!s:<13} "
1042 f"{r.get('availability_zone') or ''!s:<17} "
1043 f"{r.get('available_instances', 0):>6} {r.get('total_instances', 0):>6} "
1044 f"{gpu_hr_str:>10}"
1045 )
1046 print()
1047 print(f" 💡 {result['recommendation']}")
1048 print()
1051@capacity.command("find-reservations")
1052@click.option(
1053 "--instance-type",
1054 "-i",
1055 default=None,
1056 help="Instance type or alias to filter by (e.g. p6-b200); omit for all types",
1057)
1058@click.option(
1059 "--region",
1060 "-r",
1061 "regions",
1062 multiple=True,
1063 help="Region(s) to search; repeatable (default: all deployed regions)",
1064)
1065@click.option(
1066 "--count",
1067 "-c",
1068 default=1,
1069 help="Minimum available instances to consider the search satisfied",
1070)
1071@click.option(
1072 "--state",
1073 default="active",
1074 help="Reservation state filter (default: active; use 'all' for any state)",
1075)
1076@click.option(
1077 "--pricing/--no-pricing",
1078 default=True,
1079 help="Enrich reservations with On-Demand pricing (default: yes)",
1080)
1081@pass_config
1082def find_reservations(
1083 config: Any,
1084 instance_type: Any,
1085 regions: Any,
1086 count: Any,
1087 state: Any,
1088 pricing: Any,
1089) -> None:
1090 """Find existing ODCRs across regions in one parallel, ranked report.
1092 The ODCR counterpart to 'gco capacity find-blocks': it searches every
1093 requested region in parallel, normalizes friendly instance-type aliases
1094 (p6-b200 -> p6-b200.48xlarge), enriches each reservation with On-Demand
1095 pricing, and ranks them most-available-first (then cheapest per-GPU-hour).
1097 Examples:
1098 gco capacity find-reservations -i p5.48xlarge
1099 gco capacity find-reservations -i p6-b200 -r us-east-1 -r us-west-2
1100 gco capacity find-reservations --no-pricing
1101 """
1102 formatter = get_output_formatter(config)
1103 checker = get_capacity_checker(config)
1105 try:
1106 result = checker.find_capacity_reservations(
1107 instance_type=instance_type,
1108 regions=list(regions) or None,
1109 min_count=count,
1110 state=None if str(state).lower() == "all" else state,
1111 include_pricing=pricing,
1112 )
1114 if config.output_format != "table":
1115 formatter.print(result)
1116 return
1118 _print_find_reservations_report(result)
1120 except Exception as e:
1121 formatter.print_error(f"Failed to find reservations: {e}")
1122 sys.exit(1)
1125@capacity.command("create-reservation")
1126@click.option("--instance-type", "-i", required=True, help="EC2 instance type or alias")
1127@click.option("--region", "-r", required=True, help="AWS region")
1128@click.option(
1129 "--availability-zone", "-z", required=True, help="Target Availability Zone (e.g. us-east-1a)"
1130)
1131@click.option("--count", "-c", default=1, help="Number of instances to reserve")
1132@click.option("--platform", default="Linux/UNIX", help="Instance platform/OS (default: Linux/UNIX)")
1133@click.option(
1134 "--tenancy",
1135 type=click.Choice(["default", "dedicated"]),
1136 default="default",
1137 help="Reservation tenancy (default: default)",
1138)
1139@click.option(
1140 "--match-criteria",
1141 type=click.Choice(["open", "targeted"]),
1142 default="open",
1143 help="Instance match criteria (default: open)",
1144)
1145@click.option(
1146 "--end-date",
1147 default=None,
1148 help="Optional end date (YYYY-MM-DD or ISO datetime); omit for an unlimited reservation",
1149)
1150@click.option("--ebs-optimized", is_flag=True, help="Reserve EBS-optimized capacity")
1151@click.option(
1152 "--dry-run",
1153 is_flag=True,
1154 help="Validate the request without creating (no cost incurred)",
1155)
1156@pass_config
1157def create_reservation(
1158 config: Any,
1159 instance_type: Any,
1160 region: Any,
1161 availability_zone: Any,
1162 count: Any,
1163 platform: Any,
1164 tenancy: Any,
1165 match_criteria: Any,
1166 end_date: Any,
1167 ebs_optimized: Any,
1168 dry_run: Any,
1169) -> None:
1170 """Create a new On-Demand Capacity Reservation (ODCR).
1172 The ODCR counterpart to 'gco capacity reserve'. Reserves On-Demand capacity
1173 for an instance type in a specific AZ.
1175 ⚠️ WARNING: creating a reservation incurs On-Demand charges for the reserved
1176 capacity whether or not it is used, until the reservation is cancelled.
1177 Use --dry-run to validate first.
1179 Examples:
1180 gco capacity create-reservation -i p5.48xlarge -r us-east-1 -z us-east-1a -c 2 --dry-run
1181 gco capacity create-reservation -i p6-b200 -r us-east-1 -z us-east-1a -c 1
1182 gco capacity create-reservation -i p4d.24xlarge -r us-west-2 -z us-west-2b \\
1183 --end-date 2026-08-01
1184 """
1185 formatter = get_output_formatter(config)
1186 checker = get_capacity_checker(config)
1188 try:
1189 if dry_run:
1190 formatter.print_info(
1191 f"Dry run: validating reservation for {count}x {instance_type} "
1192 f"in {availability_zone}..."
1193 )
1194 else:
1195 formatter.print_info(
1196 f"Creating reservation for {count}x {instance_type} in {availability_zone}..."
1197 )
1199 result = checker.create_capacity_reservation(
1200 instance_type=instance_type,
1201 region=region,
1202 availability_zone=availability_zone,
1203 instance_count=count,
1204 instance_platform=platform,
1205 tenancy=tenancy,
1206 instance_match_criteria=match_criteria,
1207 end_date=end_date,
1208 ebs_optimized=ebs_optimized,
1209 dry_run=dry_run,
1210 )
1212 if config.output_format != "table":
1213 formatter.print(result)
1214 return
1216 if result["success"]:
1217 if dry_run:
1218 print()
1219 print(" ✓ Dry run passed — reservation parameters are valid")
1220 print(f" Instance Type: {result.get('instance_type')}")
1221 print(f" AZ: {result.get('availability_zone')}")
1222 print(f" Instances: {result.get('instance_count')}")
1223 print()
1224 print(" To create, run without --dry-run:")
1225 print(
1226 f" gco capacity create-reservation -i {result.get('instance_type')} "
1227 f"-r {region} -z {availability_zone} -c {count}"
1228 )
1229 print()
1230 else:
1231 print()
1232 print(" ✓ Capacity Reservation created successfully")
1233 print(f" Reservation ID: {result['reservation_id']}")
1234 print(f" Instance Type: {result['instance_type']}")
1235 print(f" AZ: {result['availability_zone']}")
1236 print(f" Instances: {result['total_instances']}")
1237 print(f" State: {result.get('state', 'N/A')}")
1238 print(f" End: {result.get('end_date') or 'unlimited'}")
1239 print()
1240 print(" To create a NodePool for this reservation:")
1241 print(
1242 f" gco nodepools create-odcr -n my-pool -r {region} "
1243 f"-c {result['reservation_id']} -i {result['instance_type']}"
1244 )
1245 print()
1246 else:
1247 formatter.print_error(
1248 f"Failed: {result.get('error_code', 'Unknown')}: {result.get('error', '')}"
1249 )
1250 sys.exit(1)
1252 except Exception as e:
1253 formatter.print_error(f"Failed to create reservation: {e}")
1254 sys.exit(1)
1257@capacity.command("cancel-reservation")
1258@click.option(
1259 "--reservation-id", "-o", required=True, help="Capacity Reservation ID (cr-xxx) to cancel"
1260)
1261@click.option("--region", "-r", required=True, help="AWS region where the reservation exists")
1262@click.option(
1263 "--dry-run", is_flag=True, help="Validate the cancellation without cancelling (no change)"
1264)
1265@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
1266@pass_config
1267def cancel_reservation(
1268 config: Any, reservation_id: Any, region: Any, dry_run: Any, yes: Any
1269) -> None:
1270 """Cancel an On-Demand Capacity Reservation, releasing its capacity.
1272 Stops On-Demand charges for the reserved capacity. Only ODCRs can be
1273 cancelled; a Capacity Block runs for its fixed term. Instances already
1274 running against the reservation are not terminated — they revert to normal
1275 On-Demand billing.
1277 Examples:
1278 gco capacity cancel-reservation -o cr-0123456789abcdef0 -r us-east-1 --dry-run
1279 gco capacity cancel-reservation -o cr-0123456789abcdef0 -r us-east-1 -y
1280 """
1281 formatter = get_output_formatter(config)
1282 checker = get_capacity_checker(config)
1284 if not dry_run and not yes:
1285 confirm(f"Cancel capacity reservation '{reservation_id}' in {region}?", abort=True)
1287 try:
1288 if dry_run:
1289 formatter.print_info(f"Dry run: validating cancellation of {reservation_id}...")
1290 else:
1291 formatter.print_info(f"Cancelling capacity reservation {reservation_id}...")
1293 result = checker.cancel_capacity_reservation(
1294 reservation_id=reservation_id,
1295 region=region,
1296 dry_run=dry_run,
1297 )
1299 if config.output_format != "table":
1300 formatter.print(result)
1301 return
1303 if result["success"]:
1304 print()
1305 if dry_run:
1306 print(f" ✓ Dry run passed — {reservation_id} can be cancelled")
1307 else:
1308 print(f" ✓ {result.get('message', 'Reservation cancelled.')}")
1309 print()
1310 else:
1311 formatter.print_error(
1312 f"Failed: {result.get('error_code', 'Unknown')}: {result.get('error', '')}"
1313 )
1314 sys.exit(1)
1316 except Exception as e:
1317 formatter.print_error(f"Failed to cancel reservation: {e}")
1318 sys.exit(1)
1321_HISTORY_DISABLED_HINT = (
1322 "The historical capacity surface is not enabled. It is an optional add-on to "
1323 "the global stack: set historical.enabled to true in cdk.json and run "
1324 "'gco stacks deploy gco-global'. See lambda/capacity-poller/README.md."
1325)
1328def _history_disabled(exc: Exception) -> bool:
1329 """True if exc is a 'table does not exist' error (feature not deployed)."""
1330 return (
1331 isinstance(exc, ClientError)
1332 and exc.response.get("Error", {}).get("Code") == "ResourceNotFoundException"
1333 )
1336def _print_historical_enrichment(formatter: Any, instance_type: str, region: str) -> None:
1337 """Append a historical capacity summary to ``gco capacity check`` output."""
1338 from ..capacity.history import get_capacity_history_store
1340 try:
1341 stats = get_capacity_history_store().get_statistics(instance_type, region)
1342 except Exception as e: # supplementary to check; never fail the command
1343 if _history_disabled(e):
1344 formatter.print_warning(_HISTORY_DISABLED_HINT)
1345 else:
1346 formatter.print_warning(f"Historical enrichment unavailable: {e}")
1347 return
1348 if stats["sample_count"] == 0:
1349 formatter.print_warning(
1350 f"No historical samples for {instance_type} in {region} yet "
1351 "(the poller records one about every 15 minutes)."
1352 )
1353 return
1354 formatter.print_info(f"Historical context (last 7 days, {stats['sample_count']} samples):")
1355 spot_stats = stats["metrics"].get("spot_score")
1356 if spot_stats:
1357 print(
1358 f" spot_score p25/p50/p75: "
1359 f"{spot_stats['p25']}/{spot_stats['p50']}/{spot_stats['p75']} "
1360 f"(min {spot_stats['min']}, max {spot_stats['max']})"
1361 )
1362 price_stats = stats["metrics"].get("spot_price")
1363 if price_stats:
1364 print(
1365 f" spot_price p25/p50/p75: "
1366 f"{price_stats['p25']}/{price_stats['p50']}/{price_stats['p75']}"
1367 )
1370def _format_patterns_grid(patterns: dict[str, Any]) -> str:
1371 """Render a day-of-week x hour heatmap of average scores."""
1372 from ..capacity.history import DAY_NAMES
1374 grid = patterns.get("patterns", {})
1375 metric = patterns.get("metric", "spot_score")
1376 lines = [f"Average {metric} by day-of-week and hour (UTC)"]
1377 header = "Day".ljust(10) + "".join(f"{hour:>5}" for hour in range(24))
1378 lines.append(header)
1379 lines.append("-" * len(header))
1380 for day in DAY_NAMES:
1381 hours = grid.get(day, {})
1382 row = day[:9].ljust(10)
1383 for hour in range(24):
1384 cell = hours.get(hour) or hours.get(str(hour))
1385 row += f"{cell['avg']:>5.1f}" if cell else f"{'.':>5}"
1386 lines.append(row)
1387 best = patterns.get("best_windows", [])[:3]
1388 if best:
1389 lines.append("")
1390 lines.append("Best windows:")
1391 for window in best:
1392 lines.append(
1393 f"- {window['day']} {window['hour']:02d}:00 UTC "
1394 f"avg={window['avg']} (n={window['count']})"
1395 )
1396 return "\n".join(lines)
1399@capacity.group("history")
1400def history() -> None:
1401 """Query the historical capacity surface (requires historical.enabled)."""
1404@history.command("show")
1405@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
1406@click.option("--region", "-r", required=True, help="AWS region")
1407@click.option("--hours", "-H", default=168, help="Hours of history (default 168 = 7 days)")
1408@pass_config
1409def history_show(config: Any, instance_type: Any, region: Any, hours: Any) -> None:
1410 """Show the capacity time-series for an instance type in a region."""
1411 from ..capacity.history import get_capacity_history_store
1413 formatter = get_output_formatter(config)
1414 try:
1415 trend = get_capacity_history_store().get_trend(instance_type, region, hours)
1416 if not trend:
1417 formatter.print_warning(
1418 f"No historical samples for {instance_type} in {region} in the last {hours}h yet (the poller records one about every 15 minutes)."
1419 )
1420 return
1421 # Columns derive from the store's exported metric registry so a new
1422 # metric (e.g. another SPS target capacity) appears here without a
1423 # hand-maintained literal drifting out of date.
1424 formatter.print(trend, columns=["timestamp", *METRIC_FIELDS])
1425 except Exception as e:
1426 if _history_disabled(e):
1427 formatter.print_warning(_HISTORY_DISABLED_HINT)
1428 return
1429 formatter.print_error(f"Failed to load capacity history: {e}")
1430 sys.exit(1)
1433@history.command("stats")
1434@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
1435@click.option("--region", "-r", required=True, help="AWS region")
1436@click.option("--hours", "-H", default=168, help="Hours of history (default 168 = 7 days)")
1437@pass_config
1438def history_stats(config: Any, instance_type: Any, region: Any, hours: Any) -> None:
1439 """Show a statistical summary (p25/p50/p75/min/max/stddev) per metric."""
1440 from ..capacity.history import get_capacity_history_store
1442 formatter = get_output_formatter(config)
1443 try:
1444 stats = get_capacity_history_store().get_statistics(instance_type, region, hours)
1445 if stats["sample_count"] == 0:
1446 formatter.print_warning(
1447 f"No historical samples for {instance_type} in {region} in the last {hours}h yet (the poller records one about every 15 minutes)."
1448 )
1449 return
1450 if config.output_format == "table":
1451 rows = [{"metric": name, **values} for name, values in stats["metrics"].items()]
1452 print(
1453 formatter.format(
1454 rows,
1455 columns=[
1456 "metric",
1457 "count",
1458 "min",
1459 "p25",
1460 "p50",
1461 "p75",
1462 "max",
1463 "mean",
1464 "stddev",
1465 ],
1466 )
1467 )
1468 else:
1469 formatter.print(stats)
1470 except Exception as e:
1471 if _history_disabled(e):
1472 formatter.print_warning(_HISTORY_DISABLED_HINT)
1473 return
1474 formatter.print_error(f"Failed to compute capacity statistics: {e}")
1475 sys.exit(1)
1478@history.command("patterns")
1479@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
1480@click.option("--region", "-r", required=True, help="AWS region")
1481@click.option("--hours", "-H", default=168, help="Hours of history (default 168 = 7 days)")
1482@click.option(
1483 "--metric",
1484 "-m",
1485 # Accepted values derive from the store's exported metric registry, so the
1486 # multi-capacity SPS fields (and any future metric) are selectable without
1487 # updating a literal here.
1488 type=click.Choice(METRIC_FIELDS),
1489 default="spot_score",
1490 show_default=True,
1491 help="Metric to aggregate into the day/hour grid",
1492)
1493@pass_config
1494def history_patterns(config: Any, instance_type: Any, region: Any, hours: Any, metric: Any) -> None:
1495 """Show a day/hour heatmap grid of a metric's averages (default spot_score)."""
1496 from ..capacity.history import get_capacity_history_store
1498 formatter = get_output_formatter(config)
1499 try:
1500 patterns = get_capacity_history_store().get_temporal_patterns(
1501 instance_type, region, hours, metric=metric
1502 )
1503 if not patterns["patterns"]:
1504 formatter.print_warning(
1505 f"No historical samples for {instance_type} in {region} in the last {hours}h yet (the poller records one about every 15 minutes)."
1506 )
1507 return
1508 if config.output_format == "table":
1509 print(_format_patterns_grid(patterns))
1510 else:
1511 formatter.print(patterns)
1512 except Exception as e:
1513 if _history_disabled(e):
1514 formatter.print_warning(_HISTORY_DISABLED_HINT)
1515 return
1516 formatter.print_error(f"Failed to compute capacity patterns: {e}")
1517 sys.exit(1)
1520def _prediction_to_dict(prediction: Any) -> dict[str, Any]:
1521 """Serialize a CapacityPredictionResult for non-table output."""
1522 return {
1523 "instance_type": prediction.instance_type,
1524 "region": prediction.region,
1525 "confidence": prediction.confidence,
1526 "best_windows": prediction.best_windows,
1527 "avoid_windows": prediction.avoid_windows,
1528 "reasoning": prediction.reasoning,
1529 }
1532def _print_prediction(prediction: Any, raw: bool) -> None:
1533 """Render a single capacity-window prediction as a table block."""
1534 print()
1535 print(
1536 f" Best time to acquire {prediction.instance_type} in {prediction.region} "
1537 f"(confidence: {prediction.confidence.upper()})"
1538 )
1539 print(" " + "-" * 68)
1540 if prediction.best_windows:
1541 for window in prediction.best_windows[:5]:
1542 print(
1543 f" + {window.get('day', '?')} {window.get('hour_range', '?')}: "
1544 f"{window.get('why', '')}"
1545 )
1546 else:
1547 print(" (no clear best window identified)")
1548 if prediction.avoid_windows:
1549 print()
1550 print(" Windows to avoid:")
1551 for window in prediction.avoid_windows[:5]:
1552 print(
1553 f" - {window.get('day', '?')} {window.get('hour_range', '?')}: "
1554 f"{window.get('why', '')}"
1555 )
1556 if prediction.reasoning:
1557 print()
1558 print(" Reasoning:")
1559 for line in prediction.reasoning.split(". "):
1560 if line.strip():
1561 print(f" {line.strip()}")
1562 if raw:
1563 print()
1564 print(prediction.raw_response)
1567@capacity.command("predict")
1568@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
1569@click.option("--region", "-r", help="AWS region (omit when using --all-regions)")
1570@click.option(
1571 "--all-regions",
1572 "-a",
1573 is_flag=True,
1574 help="Predict across every region that has historical data for the instance type",
1575)
1576@click.option(
1577 "--hours", "-H", default=168, help="Hours of history to analyze (default 168 = 7 days)"
1578)
1579@click.option(
1580 "--model",
1581 "-m",
1582 default=None,
1583 help=(
1584 "Bedrock model ID to use "
1585 "(default: cdk.json context.bedrock.capacity_advisor_default_model_id)."
1586 ),
1587)
1588@click.option("--raw", is_flag=True, help="Show the raw AI response")
1589@pass_config
1590def predict_capacity(
1591 config: Any,
1592 instance_type: Any,
1593 region: Any,
1594 all_regions: Any,
1595 hours: Any,
1596 model: Any,
1597 raw: Any,
1598) -> None:
1599 """Predict the best time to acquire capacity from historical patterns (Bedrock).
1601 Combines the historical capacity surface (an optional add-on to the global
1602 stack) with Amazon Bedrock to recommend the day/hour windows with the best
1603 spot availability and pricing. Requires historical.enabled and collected
1604 samples. Pass --all-regions to run the prediction for every region that has
1605 data for the instance type instead of a single --region.
1606 """
1607 from ..capacity import get_bedrock_capacity_advisor
1609 formatter = get_output_formatter(config)
1610 if all_regions and region:
1611 formatter.print_error("Pass either --region or --all-regions, not both.")
1612 sys.exit(1)
1613 if not all_regions and not region:
1614 formatter.print_error("Provide --region <region> or --all-regions.")
1615 sys.exit(1)
1617 try:
1618 advisor = get_bedrock_capacity_advisor(config, model_id=model)
1619 if all_regions:
1620 predictions = advisor.predict_capacity_windows_all_regions(
1621 instance_type, hours_back=hours
1622 )
1623 else:
1624 predictions = [advisor.predict_capacity_window(instance_type, region, hours_back=hours)]
1625 except ValueError as e:
1626 formatter.print_warning(str(e))
1627 return
1628 except Exception as e:
1629 if _history_disabled(e):
1630 formatter.print_warning(_HISTORY_DISABLED_HINT)
1631 return
1632 if is_bedrock_ftu_form_error(e):
1633 formatter.print_error(BEDROCK_FTU_REMEDIATION)
1634 sys.exit(1)
1635 formatter.print_error(f"Failed to predict capacity window: {e}")
1636 sys.exit(1)
1638 if not predictions:
1639 formatter.print_warning(
1640 f"No usable historical samples for {instance_type} in any region yet."
1641 )
1642 return
1644 if config.output_format != "table":
1645 payload = [_prediction_to_dict(p) for p in predictions]
1646 formatter.print(payload if all_regions else payload[0])
1647 return
1649 if all_regions:
1650 formatter.print_info(
1651 f"Predicted acquisition windows for {instance_type} across "
1652 f"{len(predictions)} region(s) with data:"
1653 )
1654 for prediction in predictions:
1655 _print_prediction(prediction, raw)
1658@capacity.group("traffic-dial")
1659@pass_config
1660def traffic_dial(config: Any) -> None:
1661 """Inspect and control Global Accelerator traffic dials.
1663 Each workload region's endpoint group has a TrafficDialPercentage: the
1664 share of traffic Global Accelerator sends that region relative to optimal
1665 routing. `show` reads the current dials plus the scheduled controller's
1666 last decisions; `set` applies a manual dial and records an override the
1667 controller honors; `clear` removes the override so the controller (when
1668 enabled in enforce mode) resumes managing the region.
1669 """
1672@traffic_dial.command("show")
1673@pass_config
1674def traffic_dial_show(config: Any) -> None:
1675 """Show per-region traffic dials, endpoint health, and overrides."""
1676 from ..capacity.traffic_dial import TrafficDialError, get_traffic_dial_manager
1678 formatter = get_output_formatter(config)
1679 manager = get_traffic_dial_manager(config)
1680 try:
1681 statuses = manager.get_status()
1682 state = manager.read_controller_state()
1683 except TrafficDialError as e:
1684 formatter.print_error(str(e))
1685 sys.exit(1)
1686 except Exception as e:
1687 formatter.print_error(f"Failed to read traffic-dial status: {e}")
1688 sys.exit(1)
1690 if config.output_format == "table" and state:
1691 formatter.print_info(
1692 f"Controller: {state.get('mode', 'unknown')} mode, "
1693 f"last run {state.get('timestamp', 'unknown')}"
1694 )
1695 formatter.print(
1696 statuses,
1697 columns=[
1698 "region",
1699 "traffic_dial",
1700 "endpoint_health",
1701 "healthy_percent",
1702 "override",
1703 "controller_reason",
1704 ],
1705 )
1706 for status in statuses:
1707 if status.endpoint_health == "no endpoints":
1708 # An empty endpoint group gets no global traffic at any dial. The
1709 # region's last convergence never published its Gateway ALB.
1710 formatter.print_warning(
1711 f"{status.region} has no endpoint in its Global Accelerator endpoint group, "
1712 "so global requests never reach it. The ALB is registered by the last step "
1713 "of the Region's add-on convergence: check it with 'gco stacks addons status "
1714 f"-r {status.region}' and re-run it with 'gco stacks addons install -r "
1715 f"{status.region}'. See 'Global Accelerator Stops Routing to a Region' in "
1716 "docs/RUNBOOKS.md."
1717 )
1720@traffic_dial.command("set")
1721@click.argument("region")
1722@click.argument("percentage", type=click.IntRange(0, 100))
1723@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt")
1724@pass_config
1725def traffic_dial_set(config: Any, region: Any, percentage: Any, yes: Any) -> None:
1726 """Manually dial REGION to PERCENTAGE and pin it against the controller.
1728 Applies UpdateEndpointGroup immediately and records an override parameter
1729 so the scheduled controller leaves the region alone until
1730 `gco capacity traffic-dial clear` removes it. Dial changes converge to the
1731 accelerator's edge locations over a few minutes.
1732 """
1733 from ..capacity.traffic_dial import TrafficDialError, get_traffic_dial_manager
1735 formatter = get_output_formatter(config)
1736 if not yes and not confirm(f"Dial {region} to {percentage}% of its optimally routed traffic?"):
1737 formatter.print_info("Aborted; no changes made.")
1738 return
1740 manager = get_traffic_dial_manager(config)
1741 try:
1742 status = manager.set_dial(region, percentage)
1743 except TrafficDialError as e:
1744 formatter.print_error(str(e))
1745 sys.exit(1)
1746 except Exception as e:
1747 formatter.print_error(f"Failed to set traffic dial: {e}")
1748 sys.exit(1)
1750 for warning in status.warnings:
1751 formatter.print_warning(warning)
1752 formatter.print_success(
1753 f"Dialed {region} to {percentage}% and recorded the override "
1754 "(clear it with `gco capacity traffic-dial clear`)."
1755 )
1756 if config.output_format != "table":
1757 formatter.print(status)
1760@traffic_dial.command("clear")
1761@click.argument("region")
1762@pass_config
1763def traffic_dial_clear(config: Any, region: Any) -> None:
1764 """Clear REGION's manual override so the controller resumes managing it.
1766 The dial itself is left unchanged: with the controller disabled or in
1767 monitor mode it keeps the last manual value; in enforce mode the
1768 controller re-converges it from the region's health signal on its next
1769 cycle.
1770 """
1771 from ..capacity.traffic_dial import TrafficDialError, get_traffic_dial_manager
1773 formatter = get_output_formatter(config)
1774 manager = get_traffic_dial_manager(config)
1775 try:
1776 existed = manager.clear_override(region)
1777 except TrafficDialError as e:
1778 formatter.print_error(str(e))
1779 sys.exit(1)
1780 except Exception as e:
1781 formatter.print_error(f"Failed to clear traffic-dial override: {e}")
1782 sys.exit(1)
1784 if existed:
1785 formatter.print_success(f"Cleared the {region} traffic-dial override.")
1786 else:
1787 formatter.print_info(f"No traffic-dial override was set for {region}.")