Coverage for cli/commands/capacity_cmd.py: 91.06%
700 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""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 ..config import GCOConfig
13from ..output import format_capacity_table, get_output_formatter
15pass_config = click.make_pass_decorator(GCOConfig, ensure=True)
18@click.group()
19@pass_config
20def capacity(config: Any) -> None:
21 """Check EC2 capacity availability."""
22 pass
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 formatter.print_info(f"Recommended: {capacity_type.upper()}")
94 formatter.print_info(f"Reason: {explanation}")
96 except Exception as e:
97 formatter.print_error(f"Failed to get recommendation: {e}")
98 sys.exit(1)
101@capacity.command("spot-prices")
102@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
103@click.option("--region", "-r", required=True, help="AWS region")
104@click.option("--days", "-d", default=7, help="Days of history")
105@pass_config
106def spot_prices(config: Any, instance_type: Any, region: Any, days: Any) -> None:
107 """Get spot price history for an instance type."""
108 formatter = get_output_formatter(config)
109 checker = get_capacity_checker(config)
111 try:
112 prices = checker.get_spot_price_history(instance_type, region, days)
114 if not prices:
115 formatter.print_warning(f"No spot price data for {instance_type} in {region}")
116 return
118 formatter.print(
119 prices,
120 columns=[
121 "availability_zone",
122 "current_price",
123 "avg_price_7d",
124 "min_price_7d",
125 "max_price_7d",
126 "price_stability",
127 ],
128 )
130 except Exception as e:
131 formatter.print_error(f"Failed to get spot prices: {e}")
132 sys.exit(1)
135@capacity.command("instance-info")
136@click.argument("instance_type")
137@pass_config
138def instance_info(config: Any, instance_type: Any) -> None:
139 """Get information about an instance type."""
140 formatter = get_output_formatter(config)
141 checker = get_capacity_checker(config)
143 try:
144 info = checker.get_instance_info(instance_type)
145 if info:
146 formatter.print(info)
147 else:
148 formatter.print_error(f"Instance type {instance_type} not found")
149 sys.exit(1)
150 except Exception as e:
151 formatter.print_error(f"Failed to get instance info: {e}")
152 sys.exit(1)
155@capacity.command("status")
156@click.option("--region", "-r", help="Specific region to check")
157@click.option("--all-regions", "-a", is_flag=True, default=True, help="Check all regions (default)")
158@pass_config
159def capacity_status(config: Any, region: Any, all_regions: Any) -> None:
160 """Show comprehensive resource utilization across regions.
162 Displays pending/running workloads, GPU/CPU utilization, queue depth,
163 and active job counts for one or all GCO clusters.
165 Examples:
166 gco capacity status
167 gco capacity status --region us-east-1
168 gco capacity status --all-regions
169 """
170 from ..capacity import get_multi_region_capacity_checker
172 formatter = get_output_formatter(config)
174 try:
175 checker = get_multi_region_capacity_checker(config)
177 if region:
178 capacity = checker.get_region_capacity(region)
179 formatter.print(capacity)
180 else:
181 capacities = checker.get_all_regions_capacity()
183 if not capacities:
184 formatter.print_warning("No GCO stacks found")
185 return
187 # Format as table
188 print("\n REGION QUEUE RUNNING GPU% CPU% SCORE")
189 print(" " + "-" * 55)
190 for c in sorted(capacities, key=lambda x: x.recommendation_score):
191 print(
192 f" {c.region:<15} {c.queue_depth:>5} {c.running_jobs:>7} "
193 f"{c.gpu_utilization:>4.0f}% {c.cpu_utilization:>4.0f}% {c.recommendation_score:>5.0f}"
194 )
196 # Show recommendation
197 print()
198 best = min(capacities, key=lambda x: x.recommendation_score)
199 formatter.print_info(f"Recommended region: {best.region} (lowest score = best)")
201 except Exception as e:
202 formatter.print_error(f"Failed to get capacity status: {e}")
203 sys.exit(1)
206@capacity.command("recommend-region")
207@click.option("--gpu", is_flag=True, help="Job requires GPUs")
208@click.option("--min-gpus", default=0, help="Minimum GPUs required")
209@click.option(
210 "--instance-type", "-i", default=None, help="Specific instance type for workload-aware scoring"
211)
212@click.option("--gpu-count", default=0, help="Number of GPUs required")
213@pass_config
214def recommend_region(
215 config: Any, gpu: Any, min_gpus: Any, instance_type: Any, gpu_count: Any
216) -> None:
217 """Recommend optimal region for job placement.
219 Analyzes capacity across all deployed EKS regions and recommends
220 the best region. When --instance-type is provided, uses weighted
221 multi-signal scoring that factors in spot placement scores, pricing,
222 queue depth, GPU utilization, and running job counts.
224 Without --instance-type, uses a simpler composite score based on
225 queue depth, GPU utilization, and running jobs.
227 Examples:
228 gco capacity recommend-region
229 gco capacity recommend-region --gpu
230 gco capacity recommend-region -i g5.xlarge
231 gco capacity recommend-region -i p4d.24xlarge --gpu-count 8
232 """
233 from ..capacity import get_multi_region_capacity_checker
235 formatter = get_output_formatter(config)
237 try:
238 checker = get_multi_region_capacity_checker(config)
239 recommendation = checker.recommend_region_for_job(
240 gpu_required=gpu,
241 min_gpus=min_gpus,
242 instance_type=instance_type,
243 gpu_count=gpu_count,
244 )
246 formatter.print_success(f"Recommended region: {recommendation['region']}")
247 formatter.print_info(f"Reason: {recommendation['reason']}")
249 if config.verbose:
250 print("\nAll regions ranked:")
251 for r in recommendation.get("all_regions", []):
252 print(
253 f" {r['region']}: score={r['score']:.4f}, "
254 f"queue={r['queue_depth']}, gpu={r['gpu_utilization']:.0f}%"
255 )
257 except Exception as e:
258 formatter.print_error(f"Failed to get recommendation: {e}")
259 sys.exit(1)
262@capacity.command("ai-recommend")
263@click.option("--workload", "-w", help="Description of your workload")
264@click.option(
265 "--instance-type",
266 "-i",
267 multiple=True,
268 help="Instance types to consider (can specify multiple)",
269)
270@click.option("--region", "-r", multiple=True, help="Regions to consider (can specify multiple)")
271@click.option("--gpu", is_flag=True, help="Workload requires GPUs")
272@click.option("--min-gpus", default=0, help="Minimum GPUs required")
273@click.option("--min-memory-gb", default=0, help="Minimum memory in GB")
274@click.option(
275 "--fault-tolerance",
276 "-f",
277 type=click.Choice(["high", "medium", "low"]),
278 default="medium",
279 help="Fault tolerance level",
280)
281@click.option("--max-cost", type=float, help="Maximum cost per hour in USD")
282@click.option(
283 "--model",
284 "-m",
285 default=None,
286 help="Bedrock model ID to use (default: cdk.json context.bedrock.default_model_id).",
287)
288@click.option("--raw", is_flag=True, help="Show raw AI response")
289@pass_config
290def ai_recommend(
291 config: Any,
292 workload: Any,
293 instance_type: Any,
294 region: Any,
295 gpu: Any,
296 min_gpus: Any,
297 min_memory_gb: Any,
298 fault_tolerance: Any,
299 max_cost: Any,
300 model: Any,
301 raw: Any,
302) -> None:
303 """Get AI-powered capacity recommendation using Amazon Bedrock.
305 This command gathers comprehensive capacity data including:
306 - Spot placement scores, pricing, and 7-day per-AZ price trends across regions
307 - On-demand availability and pricing
308 - Capacity Reservations (ODCRs), Capacity Block offerings, and 26-week
309 block-availability trends
310 - Current cluster utilization (queue depth, GPU/CPU usage)
311 - Running and pending job counts
312 - The algorithmic multi-signal region ranking as advisory context
314 Without --instance-type, one representative type per current GPU
315 generation is scanned (T4, L4, A10G, L40S, RTX PRO 4500/6000 Blackwell,
316 A100, H100, H200, B200, B300).
318 The data is analyzed by an LLM to provide intelligent recommendations
319 for where to place your workload.
321 ⚠️ DISCLAIMER: Recommendations are AI-generated and should be validated
322 before making production decisions. Capacity availability and pricing
323 can change rapidly.
325 REQUIREMENTS:
326 - AWS credentials with bedrock:InvokeModel permission
327 - The specified Bedrock model must be enabled in your account
328 - Default model: cdk.json context.bedrock.default_model_id
330 Examples:
331 gco capacity ai-recommend --workload "Training a large language model"
333 gco capacity ai-recommend -w "Inference workload" --gpu --min-gpus 4
335 gco capacity ai-recommend -i g5.xlarge -i g5.2xlarge -r us-east-1 -r us-west-2
337 gco capacity ai-recommend --fault-tolerance high --max-cost 5.00
338 """
339 from ..capacity import get_bedrock_capacity_advisor
341 formatter = get_output_formatter(config)
343 # Print disclaimer
344 print()
345 print(" " + "=" * 70)
346 print(" ⚠️ AI-POWERED RECOMMENDATION DISCLAIMER")
347 print(" " + "-" * 70)
348 print(" This recommendation is generated by an AI model and should be")
349 print(" validated before making production decisions.")
350 print(" ")
351 print(" • Capacity availability can change rapidly")
352 print(" • Spot instances may be interrupted at any time")
353 print(" • Pricing data may not reflect real-time prices")
354 print(" • AI recommendations are not guaranteed to be optimal")
355 print(" " + "=" * 70)
356 print()
358 try:
359 formatter.print_info("Gathering capacity data across regions...")
361 advisor = get_bedrock_capacity_advisor(config, model_id=model)
363 # Build requirements dict
364 requirements = {
365 "gpu_required": gpu,
366 "min_gpus": min_gpus if min_gpus > 0 else None,
367 "min_memory_gb": min_memory_gb if min_memory_gb > 0 else None,
368 "fault_tolerance": fault_tolerance,
369 "max_cost_per_hour": max_cost,
370 }
371 # Remove None values
372 requirements = {k: v for k, v in requirements.items() if v is not None}
374 formatter.print_info(f"Analyzing with {advisor.model_id}...")
376 recommendation = advisor.get_recommendation(
377 workload_description=workload,
378 instance_types=list(instance_type) if instance_type else None,
379 regions=list(region) if region else None,
380 requirements=requirements if requirements else None,
381 )
383 # Display recommendation
384 print()
385 print(" " + "=" * 70)
386 print(" 🤖 AI RECOMMENDATION")
387 print(" " + "=" * 70)
388 print()
389 print(f" Region: {recommendation.recommended_region}")
390 print(f" Instance Type: {recommendation.recommended_instance_type}")
391 print(f" Capacity Type: {recommendation.recommended_capacity_type.upper()}")
392 print(f" Confidence: {recommendation.confidence.upper()}")
393 if recommendation.cost_estimate:
394 print(f" Est. Cost: {recommendation.cost_estimate}")
395 print()
396 print(" REASONING:")
397 print(" " + "-" * 68)
398 # Word wrap the reasoning
399 reasoning_lines = recommendation.reasoning.split(". ")
400 for line in reasoning_lines:
401 if line.strip():
402 print(f" {line.strip()}.")
403 print()
405 # Show alternatives
406 if recommendation.alternative_options:
407 print(" ALTERNATIVE OPTIONS:")
408 print(" " + "-" * 68)
409 for i, alt in enumerate(recommendation.alternative_options[:3], 1):
410 print(
411 f" {i}. {alt.get('region', 'N/A')} / "
412 f"{alt.get('instance_type', 'N/A')} / "
413 f"{alt.get('capacity_type', 'N/A').upper()}"
414 )
415 if alt.get("reason"):
416 print(f" {alt['reason']}")
417 print()
419 # Show warnings
420 if recommendation.warnings:
421 print(" ⚠️ WARNINGS:")
422 print(" " + "-" * 68)
423 for warning in recommendation.warnings:
424 print(f" • {warning}")
425 print()
427 # Show raw response if requested
428 if raw:
429 print(" RAW AI RESPONSE:")
430 print(" " + "-" * 68)
431 print(recommendation.raw_response)
432 print()
434 print(" " + "=" * 70)
435 print()
437 except Exception as e:
438 if is_bedrock_ftu_form_error(e): 438 ↛ 439line 438 didn't jump to line 439 because the condition on line 438 was never true
439 formatter.print_error(BEDROCK_FTU_REMEDIATION)
440 sys.exit(1)
441 # Advisor errors are already fully worded (and may carry their own
442 # remediation); re-prefixing here used to print the same phrase twice.
443 formatter.print_error(str(e))
444 sys.exit(1)
447@capacity.command("reservations")
448@click.option("--instance-type", "-i", help="Filter by instance type")
449@click.option("--region", "-r", help="Specific region (default: all deployed regions)")
450@pass_config
451def list_reservations(config: Any, instance_type: Any, region: Any) -> None:
452 """List On-Demand Capacity Reservations (ODCRs) across regions.
454 Shows all active capacity reservations with utilization details.
456 Examples:
457 gco capacity reservations
458 gco capacity reservations -i p5.48xlarge
459 gco capacity reservations -r us-east-1
460 """
461 formatter = get_output_formatter(config)
462 checker = get_capacity_checker(config)
464 try:
465 if region:
466 reservations = checker.list_capacity_reservations(region, instance_type=instance_type)
467 result = {
468 "regions_checked": [region],
469 "total_reservations": len(reservations),
470 "total_reserved_instances": sum(r["total_instances"] for r in reservations),
471 "total_available_instances": sum(r["available_instances"] for r in reservations),
472 "reservations": reservations,
473 }
474 else:
475 result = checker.list_all_reservations(instance_type=instance_type)
477 if config.output_format != "table":
478 formatter.print(result)
479 return
481 reservations = result["reservations"]
482 if not reservations:
483 formatter.print_info("No active capacity reservations found")
484 return
486 print(f"\n Capacity Reservations ({len(reservations)} found)")
487 print(" " + "-" * 90)
488 print(
489 f" {'INSTANCE TYPE':<18} {'REGION':<15} {'AZ':<18} "
490 f"{'TOTAL':>5} {'AVAIL':>5} {'USED%':>6} {'MATCH CRITERIA'}"
491 )
492 print(" " + "-" * 90)
493 for r in reservations:
494 print(
495 f" {r['instance_type']:<18} {r['region']:<15} "
496 f"{r['availability_zone']:<18} {r['total_instances']:>5} "
497 f"{r['available_instances']:>5} {r['utilization_pct']:>5.1f}% "
498 f"{r.get('instance_match_criteria', 'open')}"
499 )
501 print()
502 print(
503 f" Total: {result['total_reserved_instances']} reserved, "
504 f"{result['total_available_instances']} available"
505 )
506 print()
508 except Exception as e:
509 formatter.print_error(f"Failed to list reservations: {e}")
510 sys.exit(1)
513@capacity.command("reservation-check")
514@click.option("--instance-type", "-i", required=True, help="Instance type to check")
515@click.option(
516 "--region",
517 "-r",
518 "regions",
519 multiple=True,
520 help="Region(s) to check; repeatable (default: all deployed regions)",
521)
522@click.option("--count", "-c", default=1, help="Minimum instances needed")
523@click.option(
524 "--include-blocks/--no-blocks",
525 default=True,
526 help="Include Capacity Block offerings (default: yes)",
527)
528@click.option(
529 "--block-duration",
530 default=24,
531 type=int,
532 help="Capacity Block duration in hours (default: 24)",
533)
534@click.option(
535 "--block-duration-days",
536 default=None,
537 type=int,
538 help="Capacity Block duration in days (overrides --block-duration)",
539)
540@click.option(
541 "--earliest-start",
542 default=None,
543 help="Earliest block start date (YYYY-MM-DD or ISO datetime)",
544)
545@click.option(
546 "--latest-start",
547 default=None,
548 help="Latest block start date (YYYY-MM-DD or ISO datetime)",
549)
550@pass_config
551def reservation_check(
552 config: Any,
553 instance_type: Any,
554 regions: Any,
555 count: Any,
556 include_blocks: Any,
557 block_duration: Any,
558 block_duration_days: Any,
559 earliest_start: Any,
560 latest_start: Any,
561) -> None:
562 """Check reservation availability and Capacity Block offerings.
564 Checks both existing ODCRs and purchasable Capacity Blocks for ML
565 workloads. Capacity Blocks provide guaranteed GPU capacity for a
566 fixed duration at a known price. Pass --region more than once to check
567 several regions in parallel, and use --earliest-start/--latest-start to
568 bound when the block may begin. For a full duration-range sweep across
569 many regions, use 'gco capacity find-blocks'.
571 Examples:
572 gco capacity reservation-check -i p5.48xlarge
573 gco capacity reservation-check -i p4d.24xlarge -c 2 --block-duration 48
574 gco capacity reservation-check -i g5.48xlarge -r us-east-1 --no-blocks
575 gco capacity reservation-check -i p5.48xlarge -r us-east-1 -r us-west-2 \\
576 --block-duration-days 14 --earliest-start 2026-07-01
577 """
578 formatter = get_output_formatter(config)
579 checker = get_capacity_checker(config)
581 try:
582 formatter.print_info(
583 f"Checking reservations for {instance_type} "
584 f"(min {count} instance{'s' if count > 1 else ''})..."
585 )
587 result = checker.check_reservation_availability(
588 instance_type=instance_type,
589 regions=list(regions) or None,
590 min_count=count,
591 include_capacity_blocks=include_blocks,
592 block_duration_hours=block_duration,
593 block_duration_days=block_duration_days,
594 earliest_start=earliest_start,
595 latest_start=latest_start,
596 )
598 if config.output_format != "table": 598 ↛ 599line 598 didn't jump to line 599 because the condition on line 598 was never true
599 formatter.print(result)
600 return
602 # ODCR section
603 odcr = result["odcr"]
604 print(f"\n On-Demand Capacity Reservations for {instance_type}")
605 print(" " + "-" * 60)
606 if odcr["reservations"]:
607 for r in odcr["reservations"]:
608 print(
609 f" ✓ {r['availability_zone']}: "
610 f"{r['available_instances']}/{r['total_instances']} available "
611 f"({r['reservation_id']})"
612 )
613 print(
614 f"\n Total: {odcr['total_available_instances']} available "
615 f"of {odcr['total_reserved_instances']} reserved"
616 )
617 else:
618 print(" No active ODCRs found for this instance type")
620 # Capacity Blocks section
621 if include_blocks:
622 block_section = result["capacity_blocks"]
623 duration = block_section.get("duration_hours", block_duration)
624 print(f"\n Capacity Block Offerings ({duration}h)")
625 print(" " + "-" * 60)
626 if block_section["offerings"]:
627 for b in block_section["offerings"]:
628 gpu_hr = b.get("price_per_gpu_hour")
629 gpu_hr_str = f" (${gpu_hr}/GPU-hr)" if gpu_hr is not None else ""
630 start = (b.get("start_date") or "")[:16]
631 print(
632 f" ✓ {b['availability_zone']}: "
633 f"{b['instance_count']}x {b['duration_hours']}h "
634 f"starting {start} — ${b['upfront_fee']}{gpu_hr_str}"
635 )
636 else:
637 print(" No Capacity Block offerings available")
639 # Recommendation
640 print()
641 print(f" 💡 {result['recommendation']}")
642 print()
644 except Exception as e:
645 formatter.print_error(f"Failed to check reservations: {e}")
646 sys.exit(1)
649def _print_find_blocks_report(result: dict[str, Any]) -> None:
650 """Render a consolidated find-blocks report as a readable table block."""
651 itype = result["instance_type"]
652 if result.get("requested_instance_type") and result["requested_instance_type"] != itype: 652 ↛ 655line 652 didn't jump to line 655 because the condition on line 652 was always true
653 print(f"\n Capacity Block search for {result['requested_instance_type']} -> {itype}")
654 else:
655 print(f"\n Capacity Block search for {itype}")
656 print(" " + "-" * 72)
658 window = result.get("date_window", {})
659 earliest = (window.get("earliest_start") or "any")[:16]
660 latest = (window.get("latest_start") or "any")[:16]
661 days = result.get("durations_probed_days") or []
662 if days: 662 ↛ 665line 662 didn't jump to line 665 because the condition on line 662 was always true
663 span = f"{min(days):g}-{max(days):g}d" if len(days) > 1 else f"{days[0]:g}d"
664 else:
665 span = "n/a"
666 print(f" Regions: {', '.join(result.get('regions_checked', []))}")
667 print(f" Durations probed: {span} Start window: {earliest} .. {latest}")
669 if not result.get("valid_instance_type", True):
670 print()
671 print(f" ⚠ {result.get('recommendation', 'Invalid instance type.')}")
672 print()
673 return
675 offerings = result.get("offerings", [])
676 if not offerings:
677 print()
678 print(f" {result.get('recommendation', 'No offerings found.')}")
679 print()
680 return
682 print()
683 print(f" {'REGION':<13} {'AZ':<17} {'START':<17} {'DUR':>6} {'UPFRONT':>11} {'$/GPU-hr':>10}")
684 print(" " + "-" * 72)
685 for b in offerings:
686 start = (b.get("start_date") or "")[:16]
687 dur = f"{b.get('duration_days') or '?'}d"
688 fee = b.get("upfront_fee_usd")
689 fee_str = f"${fee:,.0f}" if isinstance(fee, int | float) else "?"
690 gpu_hr = b.get("price_per_gpu_hour")
691 gpu_hr_str = f"${gpu_hr:,.2f}" if isinstance(gpu_hr, int | float) else "-"
692 print(
693 f" {str(b.get('region') or ''):<13} {str(b.get('availability_zone') or ''):<17} "
694 f"{start:<17} {dur:>6} {fee_str:>11} {gpu_hr_str:>10}"
695 )
696 print()
697 print(f" 💡 {result['recommendation']}")
698 print()
701@capacity.command("find-blocks")
702@click.option(
703 "--instance-type", "-i", required=True, help="GPU instance type or alias (e.g. p6-b200)"
704)
705@click.option(
706 "--region",
707 "-r",
708 "regions",
709 multiple=True,
710 help="Region(s) to search; repeatable (default: all deployed regions)",
711)
712@click.option("--count", "-c", default=1, help="Instances per block")
713@click.option("--duration-days", default=None, type=int, help="Single target duration in days")
714@click.option("--duration-hours", default=None, type=int, help="Single target duration in hours")
715@click.option(
716 "--min-duration-days", default=None, type=int, help="Minimum duration (days) for a range search"
717)
718@click.option(
719 "--max-duration-days", default=None, type=int, help="Maximum duration (days) for a range search"
720)
721@click.option("--min-duration-hours", default=None, type=int, help="Minimum duration (hours)")
722@click.option("--max-duration-hours", default=None, type=int, help="Maximum duration (hours)")
723@click.option(
724 "--earliest-start", default=None, help="Earliest block start (YYYY-MM-DD or ISO datetime)"
725)
726@click.option(
727 "--latest-start", default=None, help="Latest block start (YYYY-MM-DD or ISO datetime)"
728)
729@click.option(
730 "--find-longest",
731 is_flag=True,
732 help="Sweep the duration ladder and surface the longest available block",
733)
734@pass_config
735def find_blocks(
736 config: Any,
737 instance_type: Any,
738 regions: Any,
739 count: Any,
740 duration_days: Any,
741 duration_hours: Any,
742 min_duration_days: Any,
743 max_duration_days: Any,
744 min_duration_hours: Any,
745 max_duration_hours: Any,
746 earliest_start: Any,
747 latest_start: Any,
748 find_longest: Any,
749) -> None:
750 """Find Capacity Blocks across regions, durations, and a start-date window.
752 One command sweeps every requested region and every valid Capacity Block
753 duration in the range, in parallel, then returns a single consolidated,
754 de-duplicated, ranked report with per-hour and per-GPU-hour pricing.
756 AWS allows Capacity Block durations in 1-day increments up to 14 days, then
757 7-day increments up to 182 days; a duration range is expanded to those
758 discrete values automatically. Friendly names are normalized (p6-b200 ->
759 p6-b200.48xlarge, p6-b300 -> p6-b300.48xlarge); the Grace-Blackwell GB200/
760 GB300 UltraServer families (P6e-GB200/P6e-GB300) are flagged as not standalone.
762 Examples:
763 gco capacity find-blocks -i p6-b200.48xlarge \\
764 -r us-east-1 -r us-east-2 -r us-west-2 -r eu-west-1 \\
765 --min-duration-days 1 --max-duration-days 63 \\
766 --earliest-start 2026-07-01 --latest-start 2026-07-10
767 gco capacity find-blocks -i p5.48xlarge -r us-east-1 --duration-days 14
768 gco capacity find-blocks -i p5.48xlarge -r us-east-1 --find-longest
769 """
770 formatter = get_output_formatter(config)
771 checker = get_capacity_checker(config)
773 try:
774 result = checker.find_capacity_blocks(
775 instance_type,
776 regions=list(regions) or None,
777 instance_count=count,
778 duration_hours=duration_hours,
779 duration_days=duration_days,
780 min_duration_hours=min_duration_hours,
781 min_duration_days=min_duration_days,
782 max_duration_hours=max_duration_hours,
783 max_duration_days=max_duration_days,
784 earliest_start=earliest_start,
785 latest_start=latest_start,
786 find_longest=find_longest,
787 )
789 if config.output_format != "table":
790 formatter.print(result)
791 return
793 _print_find_blocks_report(result)
795 except Exception as e:
796 formatter.print_error(f"Failed to find capacity blocks: {e}")
797 sys.exit(1)
800@capacity.command("reserve")
801@click.option(
802 "--offering-id",
803 "-o",
804 required=True,
805 help="Capacity Block offering ID (cb-xxx) from reservation-check",
806)
807@click.option("--region", "-r", required=True, help="AWS region where the offering exists")
808@click.option(
809 "--dry-run",
810 is_flag=True,
811 help="Validate the offering without purchasing (no cost incurred)",
812)
813@pass_config
814def reserve_capacity(config: Any, offering_id: Any, region: Any, dry_run: Any) -> None:
815 """Purchase a Capacity Block offering by its ID.
817 Use 'gco capacity reservation-check' first to find available offerings
818 and their IDs, then purchase with this command.
820 ⚠️ WARNING: This command purchases capacity and incurs charges.
821 Use --dry-run to validate first.
823 Examples:
824 # First, find offerings:
825 gco capacity reservation-check -i p4d.24xlarge -r us-east-1
827 # Validate without purchasing:
828 gco capacity reserve -o cb-0123456789abcdef0 -r us-east-1 --dry-run
830 # Purchase:
831 gco capacity reserve -o cb-0123456789abcdef0 -r us-east-1
832 """
833 formatter = get_output_formatter(config)
834 checker = get_capacity_checker(config)
836 try:
837 if dry_run:
838 formatter.print_info(f"Dry run: validating offering {offering_id} in {region}...")
839 else:
840 formatter.print_info(f"Purchasing Capacity Block {offering_id} in {region}...")
842 result = checker.purchase_capacity_block(
843 offering_id=offering_id,
844 region=region,
845 dry_run=dry_run,
846 )
848 if config.output_format != "table": 848 ↛ 849line 848 didn't jump to line 849 because the condition on line 848 was never true
849 formatter.print(result)
850 return
852 if result["success"]:
853 if dry_run:
854 print()
855 print(f" ✓ Dry run passed — offering {offering_id} is valid and purchasable")
856 print(f" Region: {region}")
857 print()
858 print(" To purchase, run without --dry-run:")
859 print(f" gco capacity reserve -o {offering_id} -r {region}")
860 print()
861 else:
862 print()
863 print(" ✓ Capacity Block purchased successfully")
864 print(f" Reservation ID: {result['reservation_id']}")
865 print(f" Instance Type: {result['instance_type']}")
866 print(f" AZ: {result['availability_zone']}")
867 print(f" Instances: {result['total_instances']}")
868 print(f" Start: {result.get('start_date', 'N/A')}")
869 print(f" End: {result.get('end_date', 'N/A')}")
870 print()
871 print(" To create a NodePool for this reservation:")
872 print(
873 f" gco nodepools create-odcr -n my-pool -r {region} "
874 f"-c {result['reservation_id']} -i {result['instance_type']}"
875 )
876 print()
877 else:
878 formatter.print_error(
879 f"Failed: {result.get('error_code', 'Unknown')}: {result.get('error', '')}"
880 )
881 sys.exit(1)
883 except Exception as e:
884 formatter.print_error(f"Failed to reserve capacity: {e}")
885 sys.exit(1)
888def _print_find_reservations_report(result: dict[str, Any]) -> None:
889 """Render a consolidated find-reservations report as a readable table block."""
890 itype = result.get("instance_type") or "any instance type"
891 req = result.get("requested_instance_type")
892 if req and req != itype: 892 ↛ 893line 892 didn't jump to line 893 because the condition on line 892 was never true
893 print(f"\n ODCR search for {req} -> {itype}")
894 else:
895 print(f"\n ODCR search for {itype}")
896 print(" " + "-" * 78)
897 print(f" Regions: {', '.join(result.get('regions_checked', []))}")
899 if req and not result.get("valid_instance_type", True): 899 ↛ 900line 899 didn't jump to line 900 because the condition on line 899 was never true
900 print()
901 print(f" ⚠ {result.get('recommendation', 'Invalid instance type.')}")
902 print()
903 return
905 reservations = result.get("reservations", [])
906 if not reservations:
907 print()
908 print(f" {result.get('recommendation', 'No reservations found.')}")
909 print()
910 return
912 print()
913 print(
914 f" {'INSTANCE TYPE':<18} {'REGION':<13} {'AZ':<17} "
915 f"{'AVAIL':>6} {'TOTAL':>6} {'$/GPU-hr':>10}"
916 )
917 print(" " + "-" * 78)
918 for r in reservations:
919 gpu_hr = r.get("price_per_gpu_hour")
920 gpu_hr_str = f"${gpu_hr:,.2f}" if isinstance(gpu_hr, int | float) else "-"
921 print(
922 f" {str(r.get('instance_type') or ''):<18} {str(r.get('region') or ''):<13} "
923 f"{str(r.get('availability_zone') or ''):<17} "
924 f"{r.get('available_instances', 0):>6} {r.get('total_instances', 0):>6} "
925 f"{gpu_hr_str:>10}"
926 )
927 print()
928 print(f" 💡 {result['recommendation']}")
929 print()
932@capacity.command("find-reservations")
933@click.option(
934 "--instance-type",
935 "-i",
936 default=None,
937 help="Instance type or alias to filter by (e.g. p6-b200); omit for all types",
938)
939@click.option(
940 "--region",
941 "-r",
942 "regions",
943 multiple=True,
944 help="Region(s) to search; repeatable (default: all deployed regions)",
945)
946@click.option(
947 "--count",
948 "-c",
949 default=1,
950 help="Minimum available instances to consider the search satisfied",
951)
952@click.option(
953 "--state",
954 default="active",
955 help="Reservation state filter (default: active; use 'all' for any state)",
956)
957@click.option(
958 "--pricing/--no-pricing",
959 default=True,
960 help="Enrich reservations with On-Demand pricing (default: yes)",
961)
962@pass_config
963def find_reservations(
964 config: Any,
965 instance_type: Any,
966 regions: Any,
967 count: Any,
968 state: Any,
969 pricing: Any,
970) -> None:
971 """Find existing ODCRs across regions in one parallel, ranked report.
973 The ODCR counterpart to 'gco capacity find-blocks': it searches every
974 requested region in parallel, normalizes friendly instance-type aliases
975 (p6-b200 -> p6-b200.48xlarge), enriches each reservation with On-Demand
976 pricing, and ranks them most-available-first (then cheapest per-GPU-hour).
978 Examples:
979 gco capacity find-reservations -i p5.48xlarge
980 gco capacity find-reservations -i p6-b200 -r us-east-1 -r us-west-2
981 gco capacity find-reservations --no-pricing
982 """
983 formatter = get_output_formatter(config)
984 checker = get_capacity_checker(config)
986 try:
987 result = checker.find_capacity_reservations(
988 instance_type=instance_type,
989 regions=list(regions) or None,
990 min_count=count,
991 state=None if str(state).lower() == "all" else state,
992 include_pricing=pricing,
993 )
995 if config.output_format != "table": 995 ↛ 996line 995 didn't jump to line 996 because the condition on line 995 was never true
996 formatter.print(result)
997 return
999 _print_find_reservations_report(result)
1001 except Exception as e:
1002 formatter.print_error(f"Failed to find reservations: {e}")
1003 sys.exit(1)
1006@capacity.command("create-reservation")
1007@click.option("--instance-type", "-i", required=True, help="EC2 instance type or alias")
1008@click.option("--region", "-r", required=True, help="AWS region")
1009@click.option(
1010 "--availability-zone", "-z", required=True, help="Target Availability Zone (e.g. us-east-1a)"
1011)
1012@click.option("--count", "-c", default=1, help="Number of instances to reserve")
1013@click.option("--platform", default="Linux/UNIX", help="Instance platform/OS (default: Linux/UNIX)")
1014@click.option(
1015 "--tenancy",
1016 type=click.Choice(["default", "dedicated"]),
1017 default="default",
1018 help="Reservation tenancy (default: default)",
1019)
1020@click.option(
1021 "--match-criteria",
1022 type=click.Choice(["open", "targeted"]),
1023 default="open",
1024 help="Instance match criteria (default: open)",
1025)
1026@click.option(
1027 "--end-date",
1028 default=None,
1029 help="Optional end date (YYYY-MM-DD or ISO datetime); omit for an unlimited reservation",
1030)
1031@click.option("--ebs-optimized", is_flag=True, help="Reserve EBS-optimized capacity")
1032@click.option(
1033 "--dry-run",
1034 is_flag=True,
1035 help="Validate the request without creating (no cost incurred)",
1036)
1037@pass_config
1038def create_reservation(
1039 config: Any,
1040 instance_type: Any,
1041 region: Any,
1042 availability_zone: Any,
1043 count: Any,
1044 platform: Any,
1045 tenancy: Any,
1046 match_criteria: Any,
1047 end_date: Any,
1048 ebs_optimized: Any,
1049 dry_run: Any,
1050) -> None:
1051 """Create a new On-Demand Capacity Reservation (ODCR).
1053 The ODCR counterpart to 'gco capacity reserve'. Reserves On-Demand capacity
1054 for an instance type in a specific AZ.
1056 ⚠️ WARNING: creating a reservation incurs On-Demand charges for the reserved
1057 capacity whether or not it is used, until the reservation is cancelled.
1058 Use --dry-run to validate first.
1060 Examples:
1061 gco capacity create-reservation -i p5.48xlarge -r us-east-1 -z us-east-1a -c 2 --dry-run
1062 gco capacity create-reservation -i p6-b200 -r us-east-1 -z us-east-1a -c 1
1063 gco capacity create-reservation -i p4d.24xlarge -r us-west-2 -z us-west-2b \\
1064 --end-date 2026-08-01
1065 """
1066 formatter = get_output_formatter(config)
1067 checker = get_capacity_checker(config)
1069 try:
1070 if dry_run:
1071 formatter.print_info(
1072 f"Dry run: validating reservation for {count}x {instance_type} "
1073 f"in {availability_zone}..."
1074 )
1075 else:
1076 formatter.print_info(
1077 f"Creating reservation for {count}x {instance_type} in {availability_zone}..."
1078 )
1080 result = checker.create_capacity_reservation(
1081 instance_type=instance_type,
1082 region=region,
1083 availability_zone=availability_zone,
1084 instance_count=count,
1085 instance_platform=platform,
1086 tenancy=tenancy,
1087 instance_match_criteria=match_criteria,
1088 end_date=end_date,
1089 ebs_optimized=ebs_optimized,
1090 dry_run=dry_run,
1091 )
1093 if config.output_format != "table": 1093 ↛ 1094line 1093 didn't jump to line 1094 because the condition on line 1093 was never true
1094 formatter.print(result)
1095 return
1097 if result["success"]:
1098 if dry_run:
1099 print()
1100 print(" ✓ Dry run passed — reservation parameters are valid")
1101 print(f" Instance Type: {result.get('instance_type')}")
1102 print(f" AZ: {result.get('availability_zone')}")
1103 print(f" Instances: {result.get('instance_count')}")
1104 print()
1105 print(" To create, run without --dry-run:")
1106 print(
1107 f" gco capacity create-reservation -i {result.get('instance_type')} "
1108 f"-r {region} -z {availability_zone} -c {count}"
1109 )
1110 print()
1111 else:
1112 print()
1113 print(" ✓ Capacity Reservation created successfully")
1114 print(f" Reservation ID: {result['reservation_id']}")
1115 print(f" Instance Type: {result['instance_type']}")
1116 print(f" AZ: {result['availability_zone']}")
1117 print(f" Instances: {result['total_instances']}")
1118 print(f" State: {result.get('state', 'N/A')}")
1119 print(f" End: {result.get('end_date') or 'unlimited'}")
1120 print()
1121 print(" To create a NodePool for this reservation:")
1122 print(
1123 f" gco nodepools create-odcr -n my-pool -r {region} "
1124 f"-c {result['reservation_id']} -i {result['instance_type']}"
1125 )
1126 print()
1127 else:
1128 formatter.print_error(
1129 f"Failed: {result.get('error_code', 'Unknown')}: {result.get('error', '')}"
1130 )
1131 sys.exit(1)
1133 except Exception as e:
1134 formatter.print_error(f"Failed to create reservation: {e}")
1135 sys.exit(1)
1138@capacity.command("cancel-reservation")
1139@click.option(
1140 "--reservation-id", "-o", required=True, help="Capacity Reservation ID (cr-xxx) to cancel"
1141)
1142@click.option("--region", "-r", required=True, help="AWS region where the reservation exists")
1143@click.option(
1144 "--dry-run", is_flag=True, help="Validate the cancellation without cancelling (no change)"
1145)
1146@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
1147@pass_config
1148def cancel_reservation(
1149 config: Any, reservation_id: Any, region: Any, dry_run: Any, yes: Any
1150) -> None:
1151 """Cancel an On-Demand Capacity Reservation, releasing its capacity.
1153 Stops On-Demand charges for the reserved capacity. Only ODCRs can be
1154 cancelled; a Capacity Block runs for its fixed term. Instances already
1155 running against the reservation are not terminated — they revert to normal
1156 On-Demand billing.
1158 Examples:
1159 gco capacity cancel-reservation -o cr-0123456789abcdef0 -r us-east-1 --dry-run
1160 gco capacity cancel-reservation -o cr-0123456789abcdef0 -r us-east-1 -y
1161 """
1162 formatter = get_output_formatter(config)
1163 checker = get_capacity_checker(config)
1165 if not dry_run and not yes:
1166 click.confirm(f"Cancel capacity reservation '{reservation_id}' in {region}?", abort=True)
1168 try:
1169 if dry_run:
1170 formatter.print_info(f"Dry run: validating cancellation of {reservation_id}...")
1171 else:
1172 formatter.print_info(f"Cancelling capacity reservation {reservation_id}...")
1174 result = checker.cancel_capacity_reservation(
1175 reservation_id=reservation_id,
1176 region=region,
1177 dry_run=dry_run,
1178 )
1180 if config.output_format != "table": 1180 ↛ 1181line 1180 didn't jump to line 1181 because the condition on line 1180 was never true
1181 formatter.print(result)
1182 return
1184 if result["success"]:
1185 print()
1186 if dry_run:
1187 print(f" ✓ Dry run passed — {reservation_id} can be cancelled")
1188 else:
1189 print(f" ✓ {result.get('message', 'Reservation cancelled.')}")
1190 print()
1191 else:
1192 formatter.print_error(
1193 f"Failed: {result.get('error_code', 'Unknown')}: {result.get('error', '')}"
1194 )
1195 sys.exit(1)
1197 except Exception as e:
1198 formatter.print_error(f"Failed to cancel reservation: {e}")
1199 sys.exit(1)
1202_HISTORY_DISABLED_HINT = (
1203 "The historical capacity surface is not enabled. It is an optional add-on to "
1204 "the global stack: set historical.enabled to true in cdk.json and run "
1205 "'gco stacks deploy gco-global'. See lambda/capacity-poller/README.md."
1206)
1209def _history_disabled(exc: Exception) -> bool:
1210 """True if exc is a 'table does not exist' error (feature not deployed)."""
1211 return (
1212 isinstance(exc, ClientError)
1213 and exc.response.get("Error", {}).get("Code") == "ResourceNotFoundException"
1214 )
1217def _print_historical_enrichment(formatter: Any, instance_type: str, region: str) -> None:
1218 """Append a historical capacity summary to ``gco capacity check`` output."""
1219 from ..capacity.history import get_capacity_history_store
1221 try:
1222 stats = get_capacity_history_store().get_statistics(instance_type, region)
1223 except Exception as e: # supplementary to check; never fail the command
1224 if _history_disabled(e): 1224 ↛ 1227line 1224 didn't jump to line 1227 because the condition on line 1224 was always true
1225 formatter.print_warning(_HISTORY_DISABLED_HINT)
1226 else:
1227 formatter.print_warning(f"Historical enrichment unavailable: {e}")
1228 return
1229 if stats["sample_count"] == 0: 1229 ↛ 1230line 1229 didn't jump to line 1230 because the condition on line 1229 was never true
1230 formatter.print_warning(
1231 f"No historical samples for {instance_type} in {region} yet "
1232 "(the poller records one about every 15 minutes)."
1233 )
1234 return
1235 formatter.print_info(f"Historical context (last 7 days, {stats['sample_count']} samples):")
1236 spot_stats = stats["metrics"].get("spot_score")
1237 if spot_stats: 1237 ↛ 1243line 1237 didn't jump to line 1243 because the condition on line 1237 was always true
1238 print(
1239 f" spot_score p25/p50/p75: "
1240 f"{spot_stats['p25']}/{spot_stats['p50']}/{spot_stats['p75']} "
1241 f"(min {spot_stats['min']}, max {spot_stats['max']})"
1242 )
1243 price_stats = stats["metrics"].get("spot_price")
1244 if price_stats: 1244 ↛ 1245line 1244 didn't jump to line 1245 because the condition on line 1244 was never true
1245 print(
1246 f" spot_price p25/p50/p75: "
1247 f"{price_stats['p25']}/{price_stats['p50']}/{price_stats['p75']}"
1248 )
1251def _format_patterns_grid(patterns: dict[str, Any]) -> str:
1252 """Render a day-of-week x hour heatmap of average scores."""
1253 from ..capacity.history import DAY_NAMES
1255 grid = patterns.get("patterns", {})
1256 metric = patterns.get("metric", "spot_score")
1257 lines = [f"Average {metric} by day-of-week and hour (UTC)"]
1258 header = "Day".ljust(10) + "".join(f"{hour:>5}" for hour in range(24))
1259 lines.append(header)
1260 lines.append("-" * len(header))
1261 for day in DAY_NAMES:
1262 hours = grid.get(day, {})
1263 row = day[:9].ljust(10)
1264 for hour in range(24):
1265 cell = hours.get(hour) or hours.get(str(hour))
1266 row += f"{cell['avg']:>5.1f}" if cell else f"{'.':>5}"
1267 lines.append(row)
1268 best = patterns.get("best_windows", [])[:3]
1269 if best: 1269 ↛ 1277line 1269 didn't jump to line 1277 because the condition on line 1269 was always true
1270 lines.append("")
1271 lines.append("Best windows:")
1272 for window in best:
1273 lines.append(
1274 f"- {window['day']} {window['hour']:02d}:00 UTC "
1275 f"avg={window['avg']} (n={window['count']})"
1276 )
1277 return "\n".join(lines)
1280@capacity.group("history")
1281def history() -> None:
1282 """Query the historical capacity surface (requires historical.enabled)."""
1285@history.command("show")
1286@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
1287@click.option("--region", "-r", required=True, help="AWS region")
1288@click.option("--hours", "-H", default=168, help="Hours of history (default 168 = 7 days)")
1289@pass_config
1290def history_show(config: Any, instance_type: Any, region: Any, hours: Any) -> None:
1291 """Show the capacity time-series for an instance type in a region."""
1292 from ..capacity.history import get_capacity_history_store
1294 formatter = get_output_formatter(config)
1295 try:
1296 trend = get_capacity_history_store().get_trend(instance_type, region, hours)
1297 if not trend:
1298 formatter.print_warning(
1299 f"No historical samples for {instance_type} in {region} in the last {hours}h yet (the poller records one about every 15 minutes)."
1300 )
1301 return
1302 formatter.print(
1303 trend,
1304 columns=[
1305 "timestamp",
1306 "spot_score",
1307 "spot_price",
1308 "az_count",
1309 "queue_depth",
1310 "capacity_blocks_available",
1311 "capacity_blocks_total",
1312 "capacity_blocks_long_available",
1313 "capacity_blocks_long_total",
1314 ],
1315 )
1316 except Exception as e:
1317 if _history_disabled(e): 1317 ↛ 1320line 1317 didn't jump to line 1320 because the condition on line 1317 was always true
1318 formatter.print_warning(_HISTORY_DISABLED_HINT)
1319 return
1320 formatter.print_error(f"Failed to load capacity history: {e}")
1321 sys.exit(1)
1324@history.command("stats")
1325@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
1326@click.option("--region", "-r", required=True, help="AWS region")
1327@click.option("--hours", "-H", default=168, help="Hours of history (default 168 = 7 days)")
1328@pass_config
1329def history_stats(config: Any, instance_type: Any, region: Any, hours: Any) -> None:
1330 """Show a statistical summary (p25/p50/p75/min/max/stddev) per metric."""
1331 from ..capacity.history import get_capacity_history_store
1333 formatter = get_output_formatter(config)
1334 try:
1335 stats = get_capacity_history_store().get_statistics(instance_type, region, hours)
1336 if stats["sample_count"] == 0:
1337 formatter.print_warning(
1338 f"No historical samples for {instance_type} in {region} in the last {hours}h yet (the poller records one about every 15 minutes)."
1339 )
1340 return
1341 if config.output_format == "table": 1341 ↛ 1360line 1341 didn't jump to line 1360 because the condition on line 1341 was always true
1342 rows = [{"metric": name, **values} for name, values in stats["metrics"].items()]
1343 print(
1344 formatter.format(
1345 rows,
1346 columns=[
1347 "metric",
1348 "count",
1349 "min",
1350 "p25",
1351 "p50",
1352 "p75",
1353 "max",
1354 "mean",
1355 "stddev",
1356 ],
1357 )
1358 )
1359 else:
1360 formatter.print(stats)
1361 except Exception as e:
1362 if _history_disabled(e): 1362 ↛ 1365line 1362 didn't jump to line 1365 because the condition on line 1362 was always true
1363 formatter.print_warning(_HISTORY_DISABLED_HINT)
1364 return
1365 formatter.print_error(f"Failed to compute capacity statistics: {e}")
1366 sys.exit(1)
1369@history.command("patterns")
1370@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
1371@click.option("--region", "-r", required=True, help="AWS region")
1372@click.option("--hours", "-H", default=168, help="Hours of history (default 168 = 7 days)")
1373@pass_config
1374def history_patterns(config: Any, instance_type: Any, region: Any, hours: Any) -> None:
1375 """Show a day/hour heatmap grid of average spot scores."""
1376 from ..capacity.history import get_capacity_history_store
1378 formatter = get_output_formatter(config)
1379 try:
1380 patterns = get_capacity_history_store().get_temporal_patterns(instance_type, region, hours)
1381 if not patterns["patterns"]: 1381 ↛ 1382line 1381 didn't jump to line 1382 because the condition on line 1381 was never true
1382 formatter.print_warning(
1383 f"No historical samples for {instance_type} in {region} in the last {hours}h yet (the poller records one about every 15 minutes)."
1384 )
1385 return
1386 if config.output_format == "table": 1386 ↛ 1389line 1386 didn't jump to line 1389 because the condition on line 1386 was always true
1387 print(_format_patterns_grid(patterns))
1388 else:
1389 formatter.print(patterns)
1390 except Exception as e:
1391 if _history_disabled(e): 1391 ↛ 1394line 1391 didn't jump to line 1394 because the condition on line 1391 was always true
1392 formatter.print_warning(_HISTORY_DISABLED_HINT)
1393 return
1394 formatter.print_error(f"Failed to compute capacity patterns: {e}")
1395 sys.exit(1)
1398def _prediction_to_dict(prediction: Any) -> dict[str, Any]:
1399 """Serialize a CapacityPredictionResult for non-table output."""
1400 return {
1401 "instance_type": prediction.instance_type,
1402 "region": prediction.region,
1403 "confidence": prediction.confidence,
1404 "best_windows": prediction.best_windows,
1405 "avoid_windows": prediction.avoid_windows,
1406 "reasoning": prediction.reasoning,
1407 }
1410def _print_prediction(prediction: Any, raw: bool) -> None:
1411 """Render a single capacity-window prediction as a table block."""
1412 print()
1413 print(
1414 f" Best time to acquire {prediction.instance_type} in {prediction.region} "
1415 f"(confidence: {prediction.confidence.upper()})"
1416 )
1417 print(" " + "-" * 68)
1418 if prediction.best_windows:
1419 for window in prediction.best_windows[:5]:
1420 print(
1421 f" + {window.get('day', '?')} {window.get('hour_range', '?')}: "
1422 f"{window.get('why', '')}"
1423 )
1424 else:
1425 print(" (no clear best window identified)")
1426 if prediction.avoid_windows:
1427 print()
1428 print(" Windows to avoid:")
1429 for window in prediction.avoid_windows[:5]:
1430 print(
1431 f" - {window.get('day', '?')} {window.get('hour_range', '?')}: "
1432 f"{window.get('why', '')}"
1433 )
1434 if prediction.reasoning:
1435 print()
1436 print(" Reasoning:")
1437 for line in prediction.reasoning.split(". "):
1438 if line.strip(): 1438 ↛ 1437line 1438 didn't jump to line 1437 because the condition on line 1438 was always true
1439 print(f" {line.strip()}")
1440 if raw: 1440 ↛ 1441line 1440 didn't jump to line 1441 because the condition on line 1440 was never true
1441 print()
1442 print(prediction.raw_response)
1445@capacity.command("predict")
1446@click.option("--instance-type", "-i", required=True, help="EC2 instance type")
1447@click.option("--region", "-r", help="AWS region (omit when using --all-regions)")
1448@click.option(
1449 "--all-regions",
1450 "-a",
1451 is_flag=True,
1452 help="Predict across every region that has historical data for the instance type",
1453)
1454@click.option(
1455 "--hours", "-H", default=168, help="Hours of history to analyze (default 168 = 7 days)"
1456)
1457@click.option(
1458 "--model",
1459 "-m",
1460 default=None,
1461 help="Bedrock model ID to use (default: cdk.json context.bedrock.default_model_id).",
1462)
1463@click.option("--raw", is_flag=True, help="Show the raw AI response")
1464@pass_config
1465def predict_capacity(
1466 config: Any,
1467 instance_type: Any,
1468 region: Any,
1469 all_regions: Any,
1470 hours: Any,
1471 model: Any,
1472 raw: Any,
1473) -> None:
1474 """Predict the best time to acquire capacity from historical patterns (Bedrock).
1476 Combines the historical capacity surface (an optional add-on to the global
1477 stack) with Amazon Bedrock to recommend the day/hour windows with the best
1478 spot availability and pricing. Requires historical.enabled and collected
1479 samples. Pass --all-regions to run the prediction for every region that has
1480 data for the instance type instead of a single --region.
1481 """
1482 from ..capacity import get_bedrock_capacity_advisor
1484 formatter = get_output_formatter(config)
1485 if all_regions and region:
1486 formatter.print_error("Pass either --region or --all-regions, not both.")
1487 sys.exit(1)
1488 if not all_regions and not region:
1489 formatter.print_error("Provide --region <region> or --all-regions.")
1490 sys.exit(1)
1492 try:
1493 advisor = get_bedrock_capacity_advisor(config, model_id=model)
1494 if all_regions:
1495 predictions = advisor.predict_capacity_windows_all_regions(
1496 instance_type, hours_back=hours
1497 )
1498 else:
1499 predictions = [advisor.predict_capacity_window(instance_type, region, hours_back=hours)]
1500 except ValueError as e:
1501 formatter.print_warning(str(e))
1502 return
1503 except Exception as e:
1504 if _history_disabled(e): 1504 ↛ 1507line 1504 didn't jump to line 1507 because the condition on line 1504 was always true
1505 formatter.print_warning(_HISTORY_DISABLED_HINT)
1506 return
1507 if is_bedrock_ftu_form_error(e):
1508 formatter.print_error(BEDROCK_FTU_REMEDIATION)
1509 sys.exit(1)
1510 formatter.print_error(f"Failed to predict capacity window: {e}")
1511 sys.exit(1)
1513 if not predictions: 1513 ↛ 1514line 1513 didn't jump to line 1514 because the condition on line 1513 was never true
1514 formatter.print_warning(
1515 f"No usable historical samples for {instance_type} in any region yet."
1516 )
1517 return
1519 if config.output_format != "table": 1519 ↛ 1520line 1519 didn't jump to line 1520 because the condition on line 1519 was never true
1520 payload = [_prediction_to_dict(p) for p in predictions]
1521 formatter.print(payload if all_regions else payload[0])
1522 return
1524 if all_regions:
1525 formatter.print_info(
1526 f"Predicted acquisition windows for {instance_type} across "
1527 f"{len(predictions)} region(s) with data:"
1528 )
1529 for prediction in predictions:
1530 _print_prediction(prediction, raw)