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