Coverage for cli / commands / costs_cmd.py: 100.00%
443 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Cost tracking commands."""
3import sys
4from typing import Any
6import click
8from ..config import GCOConfig, _load_cdk_json
9from ..output import confirm, get_output_formatter
11pass_config = click.make_pass_decorator(GCOConfig, ensure=True)
14def _get_deployment_regions(config: GCOConfig) -> list[str]:
15 """Get the list of regional deployment regions from cdk.json or fallback to default."""
16 cdk_regions = _load_cdk_json()
17 if cdk_regions and "regional" in cdk_regions:
18 regional = cdk_regions["regional"]
19 if isinstance(regional, list) and all(isinstance(r, str) for r in regional):
20 return regional
21 return [config.default_region]
24@click.group()
25@pass_config
26def costs(config: Any) -> None:
27 """View cost breakdowns and estimates for GCO resources."""
30def _print_query_result(config: Any, result: Any, title: str) -> None:
31 """Render an Athena QueryResult as a table or structured output."""
32 formatter = get_output_formatter(config)
33 if config.output_format != "table":
34 formatter.print({"columns": result.columns, "rows": result.rows})
35 return
36 if not result.rows:
37 formatter.print_info("No cost data found for the requested window")
38 formatter.print_info(
39 "Scheduled reports accrue once cost monitoring is deployed; see docs/COST_MONITORING.md"
40 )
41 return
42 widths = {
43 column: max(len(column), *(len(str(row.get(column) or "")) for row in result.rows))
44 for column in result.columns
45 }
46 print(f"\n {title}")
47 header = " " + " ".join(column.upper().ljust(widths[column]) for column in result.columns)
48 print(" " + "-" * (len(header) - 2))
49 print(header)
50 print(" " + "-" * (len(header) - 2))
51 for row in result.rows:
52 print(
53 " "
54 + " ".join(
55 str(row.get(column) or "").ljust(widths[column]) for column in result.columns
56 )
57 )
58 print()
61@costs.command("summary")
62@click.option(
63 "--days", "-d", default=30, type=int, help="Number of days to look back (default: 30)"
64)
65@click.option(
66 "--all", "show_all", is_flag=True, help="Show all account costs (not filtered by GCO tag)"
67)
68@pass_config
69def costs_summary(config: Any, days: Any, show_all: Any) -> None:
70 """Show total GCO spend by service.
72 Examples:
73 gco costs summary
74 gco costs summary --days 7
75 gco costs summary --all # All account costs (useful before tags propagate)
76 """
77 from ..costs import get_cost_tracker
79 formatter = get_output_formatter(config)
81 try:
82 tracker = get_cost_tracker(config)
83 summary = tracker.get_cost_summary(days=days, unfiltered=show_all)
84 label = "Account" if show_all else "GCO"
86 if config.output_format != "table":
87 formatter.print(
88 {
89 "total": summary.total,
90 "currency": summary.currency,
91 "period_start": summary.period_start,
92 "period_end": summary.period_end,
93 "by_service": [
94 {"service": s.service, "amount": s.amount} for s in summary.by_service
95 ],
96 }
97 )
98 return
100 print(f"\n {label} Cost Summary ({summary.period_start} to {summary.period_end})")
101 print(" " + "-" * 75)
102 print(f" {'SERVICE':<50} {'COST':>12}")
103 print(" " + "-" * 75)
105 for svc in summary.by_service:
106 print(f" {svc.service:<50} ${svc.amount:>10.2f}")
108 print(" " + "-" * 75)
109 print(f" {'TOTAL':<50} ${summary.total:>10.2f}")
110 print()
112 except Exception as e:
113 formatter.print_error(f"Failed to get cost summary: {e}")
114 sys.exit(1)
117@costs.command("regions")
118@click.option(
119 "--days", "-d", default=30, type=int, help="Number of days to look back (default: 30)"
120)
121@pass_config
122def costs_regions(config: Any, days: Any) -> None:
123 """Show cost breakdown by region.
125 Examples:
126 gco costs regions
127 gco costs regions --days 7
128 """
129 from ..costs import get_cost_tracker
131 formatter = get_output_formatter(config)
133 try:
134 tracker = get_cost_tracker(config)
135 by_region = tracker.get_cost_by_region(days=days)
137 if config.output_format != "table":
138 formatter.print(by_region)
139 return
141 total = sum(by_region.values())
142 print(f"\n GCO Cost by Region (last {days} days)")
143 print(" " + "-" * 50)
144 print(f" {'REGION':<30} {'COST':>12}")
145 print(" " + "-" * 50)
147 for region, amount in by_region.items():
148 pct = (amount / total * 100) if total > 0 else 0
149 print(f" {region:<30} ${amount:>10.2f} ({pct:.0f}%)")
151 print(" " + "-" * 50)
152 print(f" {'TOTAL':<30} ${total:>10.2f}")
153 print()
155 except Exception as e:
156 formatter.print_error(f"Failed to get regional costs: {e}")
157 sys.exit(1)
160@costs.command("trend")
161@click.option("--days", "-d", default=14, type=int, help="Number of days (default: 14)")
162@click.option(
163 "--all", "show_all", is_flag=True, help="Show all account costs (not filtered by GCO tag)"
164)
165@pass_config
166def costs_trend(config: Any, days: Any, show_all: Any) -> None:
167 """Show daily cost trend.
169 Examples:
170 gco costs trend
171 gco costs trend --days 7
172 gco costs trend --all
173 """
174 from ..costs import get_cost_tracker
176 formatter = get_output_formatter(config)
178 try:
179 tracker = get_cost_tracker(config)
180 trend = tracker.get_daily_trend(days=days, unfiltered=show_all)
181 label = "Account" if show_all else "GCO"
183 if config.output_format != "table":
184 formatter.print(trend)
185 return
187 print(f"\n Daily Cost Trend — {label} (last {days} days)")
188 print(" " + "-" * 45)
189 print(f" {'DATE':<15} {'COST':>10} {'CHART'}")
190 print(" " + "-" * 45)
192 max_amount = max((d["amount"] for d in trend), default=1) or 1
193 for day in trend:
194 bar_len = int(day["amount"] / max_amount * 25)
195 bar = "█" * bar_len
196 print(f" {day['date']:<15} ${day['amount']:>8.2f} {bar}")
198 total = sum(d["amount"] for d in trend)
199 avg = total / len(trend) if trend else 0
200 print(" " + "-" * 45)
201 print(f" Total: ${total:.2f} | Avg/day: ${avg:.2f}")
202 print()
204 except Exception as e:
205 formatter.print_error(f"Failed to get cost trend: {e}")
206 sys.exit(1)
209@costs.command("workloads")
210@click.option("--region", "-r", help="Region to check (default: all deployment regions)")
211@pass_config
212def costs_workloads(config: Any, region: Any) -> None:
213 """Estimate costs for running workloads (jobs and inference endpoints).
215 Examples:
216 gco costs workloads
217 gco costs workloads -r us-east-1
218 """
219 from ..costs import get_cost_tracker
221 formatter = get_output_formatter(config)
223 try:
224 tracker = get_cost_tracker(config)
226 regions = [region] if region else _get_deployment_regions(config)
227 all_workloads = []
229 for r in regions:
230 workloads = tracker.estimate_running_workloads(r)
231 all_workloads.extend(workloads)
233 if config.output_format != "table":
234 formatter.print(
235 [
236 {
237 "name": w.name,
238 "type": w.workload_type,
239 "instance_type": w.instance_type,
240 "gpu_count": w.gpu_count,
241 "hourly_rate": w.hourly_rate,
242 "runtime_hours": w.runtime_hours,
243 "estimated_cost": w.estimated_cost,
244 "region": w.region,
245 }
246 for w in all_workloads
247 ]
248 )
249 return
251 if not all_workloads:
252 formatter.print_info("No running workloads found")
253 return
255 print(f"\n Running Workload Costs ({len(all_workloads)} workloads)")
256 print(" " + "-" * 95)
257 print(
258 f" {'NAME':<30} {'TYPE':<10} {'INSTANCE':<15} {'GPU':>3} {'$/HR':>8} {'HOURS':>7} {'COST':>10}"
259 )
260 print(" " + "-" * 95)
262 total = 0.0
263 for w in sorted(all_workloads, key=lambda x: x.estimated_cost, reverse=True):
264 name = w.name[:29]
265 print(
266 f" {name:<30} {w.workload_type:<10} {w.instance_type:<15} "
267 f"{w.gpu_count:>3} ${w.hourly_rate:>7.3f} {w.runtime_hours:>7.1f} ${w.estimated_cost:>9.4f}"
268 )
269 total += w.estimated_cost
271 total_hourly = sum(w.hourly_rate for w in all_workloads)
272 print(" " + "-" * 95)
273 print(
274 f" {'TOTAL':<30} {'':10} {'':15} {'':>3} ${total_hourly:>7.3f} {'':>7} ${total:>9.4f}"
275 )
276 print()
278 except Exception as e:
279 formatter.print_error(f"Failed to estimate workload costs: {e}")
280 sys.exit(1)
283@costs.command("forecast")
284@click.option("--days", "-d", default=30, type=int, help="Days to forecast (default: 30)")
285@pass_config
286def costs_forecast(config: Any, days: Any) -> None:
287 """Forecast GCO costs for the next N days.
289 Examples:
290 gco costs forecast
291 gco costs forecast --days 60
292 """
293 from ..costs import get_cost_tracker
295 formatter = get_output_formatter(config)
297 try:
298 tracker = get_cost_tracker(config)
299 forecast = tracker.get_forecast(days_ahead=days)
301 if "error" in forecast:
302 formatter.print_error(f"Forecast unavailable: {forecast['error']}")
303 formatter.print_info("Cost Explorer needs 14+ days of data to generate forecasts")
304 return
306 if config.output_format != "table":
307 formatter.print(forecast)
308 return
310 total = forecast.get("forecast_total", 0)
311 print(f"\n Cost Forecast ({forecast['period_start']} to {forecast['period_end']})")
312 print(" " + "-" * 40)
313 print(f" Projected spend: ${total:>10.2f}")
314 print(f" Daily average: ${total / days:>10.2f}")
315 print()
317 except Exception as e:
318 formatter.print_error(f"Failed to get forecast: {e}")
319 sys.exit(1)
322# ---------------------------------------------------------------------------
323# allocation subgroup — the billing-account switches behind the queries above
324# ---------------------------------------------------------------------------
327_SPLIT_COST_GUIDANCE = (
328 "Split cost allocation data (per-pod/namespace EC2 allocation in the CUR) "
329 "is a separate, console-only opt-in: Billing and Cost Management -> Cost "
330 "Management preferences -> Split cost allocation data -> Amazon EKS. It "
331 "has no public API, requires the payer account, and surfaces only in "
332 "CUR/CUR 2.0 exports (not Cost Explorer). See docs/COST_MONITORING.md."
333)
336@costs.group("allocation")
337@pass_config
338def costs_allocation(config: Any) -> None:
339 """Manage the cost allocation tags behind `gco costs` reporting.
341 Every `gco costs` query filters on the Project tag, which only sees
342 spend once the tag key is activated as a cost allocation tag in the
343 billing account. The AWS-generated aws:eks:cluster-name key adds
344 per-cluster attribution for the EC2 capacity EKS Auto Mode launches
345 outside CloudFormation. In an AWS Organization, activation requires
346 the management (payer) account.
347 """
350@costs_allocation.command("status")
351@click.option(
352 "--tag",
353 "-t",
354 "extra_tags",
355 multiple=True,
356 help="Additional tag key to check (repeatable)",
357)
358@pass_config
359def costs_allocation_status(config: Any, extra_tags: Any) -> None:
360 """Show activation status for GCO's cost allocation tag keys.
362 Examples:
363 gco costs allocation status
364 gco costs allocation status -t Environment -t Owner
365 """
366 from ..costs import DEFAULT_COST_ALLOCATION_TAG_KEYS, get_cost_tracker
368 formatter = get_output_formatter(config)
369 keys = list(DEFAULT_COST_ALLOCATION_TAG_KEYS) + [
370 key for key in extra_tags if key not in DEFAULT_COST_ALLOCATION_TAG_KEYS
371 ]
372 try:
373 tracker = get_cost_tracker(config)
374 statuses = tracker.get_cost_allocation_tag_status(keys)
375 backfill_note: str | None = None
376 try:
377 backfills = tracker.get_cost_allocation_backfill_history()
378 except Exception as exc: # history is advisory only
379 backfills = []
380 backfill_note = f"backfill history unavailable: {exc}"
382 if config.output_format != "table":
383 formatter.print(
384 {
385 "tags": statuses,
386 "backfills": backfills,
387 "split_cost_allocation_data": _SPLIT_COST_GUIDANCE,
388 }
389 )
390 return
392 print("\n Cost Allocation Tag Status")
393 print(" " + "-" * 78)
394 print(f" {'TAG KEY':<28} {'TYPE':<14} {'STATUS':<10} {'LAST USED':<20}")
395 print(" " + "-" * 78)
396 for tag in statuses:
397 print(
398 f" {tag['tag_key']:<28} {tag['type']:<14} {tag['status']:<10} "
399 f"{tag['last_used'][:19]:<20}"
400 )
401 print(" " + "-" * 78)
402 if any(tag["status"] == "NotFound" for tag in statuses):
403 formatter.print_info(
404 "NotFound: Billing has not seen this key on billing data yet. Keys "
405 "appear up to 24 hours after first use on a resource that accrues "
406 "cost, and only then can they be activated."
407 )
408 if any(tag["status"] == "Inactive" for tag in statuses):
409 formatter.print_info(
410 "Inactive: run `gco costs allocation activate` from the management "
411 "(payer) account to start tagging billing data with this key."
412 )
413 if backfill_note:
414 formatter.print_info(backfill_note)
415 elif backfills:
416 latest = backfills[0]
417 formatter.print_info(
418 f"Latest backfill: from {latest['backfill_from'][:10]} "
419 f"requested {latest['requested_at'][:19]} — {latest['status']}"
420 )
421 formatter.print_info(_SPLIT_COST_GUIDANCE)
422 print()
423 except Exception as e:
424 formatter.print_error(f"Failed to get cost allocation tag status: {e}")
425 sys.exit(1)
428@costs_allocation.command("activate")
429@click.option(
430 "--tag",
431 "-t",
432 "extra_tags",
433 multiple=True,
434 help="Additional tag key to activate (repeatable)",
435)
436@click.option(
437 "--backfill-from",
438 help=(
439 "Also re-tag historical usage from this date (YYYY-MM-DD, up to 12 "
440 "months back; Billing aligns it to a quarter start)"
441 ),
442)
443@click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt")
444@pass_config
445def costs_allocation_activate(
446 config: Any, extra_tags: Any, backfill_from: Any, assume_yes: bool
447) -> None:
448 """Activate GCO's cost allocation tag keys in the billing account.
450 Activates the Project tag (user-defined) and aws:eks:cluster-name
451 (AWS-generated) by default. Activation is reversible in the Billing
452 console and only affects billing data from now on; pass
453 --backfill-from to also re-tag past usage.
455 Examples:
456 gco costs allocation activate
457 gco costs allocation activate --backfill-from 2026-01-01
458 gco costs allocation activate -t Environment -y
459 """
460 from ..costs import DEFAULT_COST_ALLOCATION_TAG_KEYS, get_cost_tracker
462 formatter = get_output_formatter(config)
463 keys = list(DEFAULT_COST_ALLOCATION_TAG_KEYS) + [
464 key for key in extra_tags if key not in DEFAULT_COST_ALLOCATION_TAG_KEYS
465 ]
467 if not assume_yes:
468 confirm(
469 f"Activate cost allocation for {', '.join(keys)} in this billing "
470 "account (management/payer account required in an Organization)?",
471 abort=True,
472 )
474 try:
475 tracker = get_cost_tracker(config)
476 result = tracker.activate_cost_allocation_tags(keys)
477 backfill = None
478 if backfill_from and result["activated"]:
479 backfill = tracker.start_cost_allocation_tag_backfill(
480 f"{backfill_from}T00:00:00Z" if "T" not in backfill_from else backfill_from
481 )
483 if config.output_format != "table":
484 formatter.print({**result, "backfill": backfill})
485 if result["errors"]:
486 sys.exit(1)
487 return
489 for key in result["activated"]:
490 formatter.print_success(f"{key}: active")
491 for error in result["errors"]:
492 formatter.print_error(f"{error['tag_key']}: {error['code']} — {error['message']}")
493 if error["code"] == "TagKeysNotFoundException":
494 formatter.print_info(
495 f" {error['tag_key']} has not appeared on billing data yet. Tag "
496 "keys become activatable up to 24 hours after first use on a "
497 "resource that accrues cost; deploy first, then retry."
498 )
499 if backfill:
500 formatter.print_success(
501 f"Backfill from {backfill['backfill_from'][:10]} requested — "
502 f"status {backfill['status']}"
503 )
504 elif backfill_from and not result["activated"]:
505 formatter.print_info("Backfill skipped: no tag keys were activated.")
506 formatter.print_info(
507 "Newly activated keys start appearing in Cost Explorer within ~24 hours."
508 )
509 if result["errors"]:
510 sys.exit(1)
511 except Exception as e:
512 formatter.print_error(f"Failed to activate cost allocation tags: {e}")
513 sys.exit(1)
516# ---------------------------------------------------------------------------
517# k8s subgroup — Athena queries over the OpenCost allocation reports
518# ---------------------------------------------------------------------------
521@costs.group("k8s")
522@pass_config
523def costs_k8s(config: Any) -> None:
524 """Query Kubernetes allocation costs across regions (Athena-backed).
526 These commands aggregate the Parquet allocation reports the per-region
527 cost-monitor services write to the central cost report bucket. Requires
528 cost_monitoring.enabled in cdk.json and a deployed monitoring stack.
529 """
532@costs_k8s.command("namespaces")
533@click.option("--days", "-d", default=7, type=int, help="Days to look back (default: 7)")
534@click.option("--region", "-r", help="Restrict to one deployment region")
535@pass_config
536def costs_k8s_namespaces(config: Any, days: Any, region: Any) -> None:
537 """Show Kubernetes cost by namespace across all regions.
539 Examples:
540 gco costs k8s namespaces
541 gco costs k8s namespaces --days 30
542 gco costs k8s namespaces -r us-east-1
543 """
544 from ..cost_analytics import get_cost_analytics
546 formatter = get_output_formatter(config)
547 try:
548 analytics = get_cost_analytics(config)
549 result = analytics.cost_by_namespace(days=days, region=region)
550 scope = f"region {region}" if region else "all regions"
551 _print_query_result(config, result, f"Kubernetes cost by namespace — {scope}, last {days}d")
552 except Exception as e:
553 formatter.print_error(f"Failed to query namespace costs: {e}")
554 sys.exit(1)
557@costs_k8s.command("regions")
558@click.option("--days", "-d", default=7, type=int, help="Days to look back (default: 7)")
559@pass_config
560def costs_k8s_regions(config: Any, days: Any) -> None:
561 """Show Kubernetes allocation cost by deployment region.
563 Examples:
564 gco costs k8s regions
565 gco costs k8s regions --days 30
566 """
567 from ..cost_analytics import get_cost_analytics
569 formatter = get_output_formatter(config)
570 try:
571 analytics = get_cost_analytics(config)
572 result = analytics.cost_by_region(days=days)
573 _print_query_result(config, result, f"Kubernetes cost by region — last {days}d")
574 except Exception as e:
575 formatter.print_error(f"Failed to query regional costs: {e}")
576 sys.exit(1)
579@costs_k8s.command("trend")
580@click.option("--days", "-d", default=14, type=int, help="Days to look back (default: 14)")
581@click.option(
582 "--granularity",
583 type=click.Choice(["daily", "hourly"]),
584 default="daily",
585 show_default=True,
586 help="Trend bucket size",
587)
588@click.option("--namespace", "-n", help="Restrict to one namespace")
589@pass_config
590def costs_k8s_trend(config: Any, days: Any, granularity: Any, namespace: Any) -> None:
591 """Show Kubernetes cost over time.
593 Examples:
594 gco costs k8s trend
595 gco costs k8s trend --days 30 --granularity daily
596 gco costs k8s trend -n gco-jobs --granularity hourly --days 2
597 """
598 from ..cost_analytics import get_cost_analytics
600 formatter = get_output_formatter(config)
601 try:
602 analytics = get_cost_analytics(config)
603 result = analytics.cost_over_time(days=days, granularity=granularity, namespace=namespace)
604 scope = f"namespace {namespace}" if namespace else "all namespaces"
605 _print_query_result(config, result, f"Kubernetes cost trend — {scope}, last {days}d")
606 except Exception as e:
607 formatter.print_error(f"Failed to query cost trend: {e}")
608 sys.exit(1)
611@costs_k8s.command("top")
612@click.option(
613 "--limit", "-n", "top_n", default=10, type=int, help="Number of results (default: 10)"
614)
615@click.option(
616 "--by",
617 type=click.Choice(["namespace", "region", "cluster"]),
618 default="namespace",
619 show_default=True,
620 help="Grouping dimension",
621)
622@click.option("--days", "-d", default=7, type=int, help="Days to look back (default: 7)")
623@pass_config
624def costs_k8s_top(config: Any, top_n: Any, by: Any, days: Any) -> None:
625 """Show the top-N spenders by namespace, region, or cluster.
627 Examples:
628 gco costs k8s top
629 gco costs k8s top -n 5 --by region
630 gco costs k8s top --by cluster --days 30
631 """
632 from ..cost_analytics import get_cost_analytics
634 formatter = get_output_formatter(config)
635 try:
636 analytics = get_cost_analytics(config)
637 result = analytics.top_spenders(n=top_n, by=by, days=days)
638 _print_query_result(config, result, f"Top {top_n} spenders by {by} — last {days}d")
639 except Exception as e:
640 formatter.print_error(f"Failed to query top spenders: {e}")
641 sys.exit(1)
644# ---------------------------------------------------------------------------
645# report subgroup — ad-hoc reports + report listing via the GCO API
646# ---------------------------------------------------------------------------
649def _cost_api_region(config: Any, region: Any) -> Any:
650 """Resolve the transport region for /api/v1/cost/* calls.
652 An explicit ``--region`` pins the request to that region's API bridge
653 (each region's cost monitor owns its own OpenCost data). Without it the
654 request rides the global API and is served by the nearest healthy region;
655 the response payload names the region that answered.
656 """
657 if region:
658 return region
659 return config.default_region if config.use_regional_api else None
662@costs.group("report")
663@pass_config
664def costs_report(config: Any) -> None:
665 """Generate and list OpenCost allocation reports via the GCO API."""
668@costs_report.command("generate")
669@click.option("--region", "-r", help="Region whose cost monitor generates the report")
670@click.option(
671 "--window-hours",
672 default=24,
673 type=click.IntRange(1, 168),
674 show_default=True,
675 help="Trailing window the report covers",
676)
677@click.option("--show-rows", is_flag=True, help="Print the allocation rows in the response")
678@pass_config
679def costs_report_generate(config: Any, region: Any, window_hours: Any, show_rows: Any) -> None:
680 """Generate an ad-hoc cost report now (written under adhoc/ in S3).
682 Examples:
683 gco costs report generate
684 gco costs report generate -r us-east-1 --window-hours 48
685 gco costs report generate --show-rows
686 """
687 from ..aws_client import get_aws_client
689 formatter = get_output_formatter(config)
690 try:
691 aws_client = get_aws_client(config)
692 result = aws_client.call_api(
693 method="POST",
694 path="/api/v1/cost/reports",
695 region=_cost_api_region(config, region),
696 body={"window_hours": window_hours, "include_rows": bool(show_rows)},
697 )
698 report = result.get("report", {})
699 formatter.print_success(
700 f"Report written to s3://{result.get('bucket')}/{report.get('s3_key')}"
701 )
702 formatter.print(result)
703 except Exception as e:
704 formatter.print_error(f"Failed to generate cost report: {e}")
705 sys.exit(1)
708@costs_report.command("list")
709@click.option("--region", "-r", help="Region whose reports to list")
710@click.option("--adhoc", is_flag=True, help="List ad-hoc instead of scheduled reports")
711@click.option("--limit", "-l", default=20, type=click.IntRange(1, 1000), help="Maximum results")
712@pass_config
713def costs_report_list(config: Any, region: Any, adhoc: Any, limit: Any) -> None:
714 """List recent cost report objects in the cost report bucket.
716 Examples:
717 gco costs report list
718 gco costs report list -r us-east-1 --limit 50
719 gco costs report list --adhoc
720 """
721 from ..aws_client import get_aws_client
723 formatter = get_output_formatter(config)
724 try:
725 aws_client = get_aws_client(config)
726 result = aws_client.call_api(
727 method="GET",
728 path="/api/v1/cost/reports",
729 region=_cost_api_region(config, region),
730 params={"adhoc": str(bool(adhoc)).lower(), "limit": str(limit)},
731 )
732 if config.output_format != "table":
733 formatter.print(result)
734 return
735 reports = result.get("reports", [])
736 if not reports:
737 formatter.print_info("No reports found yet")
738 return
739 print(f"\n Cost Reports — {result.get('region')} ({result.get('count', 0)} shown)")
740 print(" " + "-" * 100)
741 print(f" {'KEY':<75} {'SIZE':>9} {'MODIFIED':<20}")
742 print(" " + "-" * 100)
743 for report in reports:
744 key = str(report.get("key", ""))[:74]
745 size = report.get("size_bytes", 0)
746 modified = str(report.get("last_modified", ""))[:19]
747 print(f" {key:<75} {size:>9} {modified:<20}")
748 print()
749 except Exception as e:
750 formatter.print_error(f"Failed to list cost reports: {e}")
751 sys.exit(1)
754@costs_report.command("status")
755@click.option("--region", "-r", help="Region whose cost monitor to check")
756@pass_config
757def costs_report_status(config: Any, region: Any) -> None:
758 """Show cost monitoring health, including OpenCost status.
760 Examples:
761 gco costs report status
762 gco costs report status -r us-east-1
763 """
764 from ..aws_client import get_aws_client
766 formatter = get_output_formatter(config)
767 try:
768 aws_client = get_aws_client(config)
769 result = aws_client.call_api(
770 method="GET",
771 path="/api/v1/cost/status",
772 region=_cost_api_region(config, region),
773 )
774 if config.output_format != "table":
775 formatter.print(result)
776 return
777 print(f"\n Cost Monitoring Status — {result.get('region')}")
778 print(" " + "-" * 55)
779 print(f" OpenCost healthy: {result.get('opencost_healthy')}")
780 print(f" OpenCost returning data: {result.get('opencost_returning_data')}")
781 print(f" Report bucket: {result.get('bucket')}")
782 print(f" Report interval: {result.get('report_interval_minutes')} minutes")
783 last = result.get("last_scheduled_report")
784 if last:
785 print(f" Last scheduled report: {last.get('s3_key')}")
786 print(f" rows={last.get('row_count')} total=${last.get('total_cost')}")
787 if result.get("last_error"):
788 print(f" Last error: {result.get('last_error')}")
789 print()
790 except Exception as e:
791 formatter.print_error(f"Failed to get cost monitoring status: {e}")
792 sys.exit(1)
795# ---------------------------------------------------------------------------
796# dashboard — port-forward to the regional cost dashboards
797# ---------------------------------------------------------------------------
800@costs.command("dashboard")
801@click.option(
802 "--service",
803 type=click.Choice(["grafana", "opencost"]),
804 default="grafana",
805 show_default=True,
806 help="grafana opens the GCO Cost dashboard; opencost opens the native OpenCost UI",
807)
808@click.option("--region", help="Cluster region (defaults to the first cdk.json regional entry)")
809@click.option("--local-port", type=int, help="Local port to bind (defaults per-service)")
810@click.option(
811 "--via-ssm",
812 "via_ssm",
813 metavar="INSTANCE_ID|auto",
814 help=(
815 "Tunnel to the private API endpoint through an SSM-managed instance. "
816 "Pass an instance id to use an existing one, or 'auto' to provision a "
817 "self-terminating ephemeral bastion and tear it down when the forward stops. "
818 "Used whenever private endpoint access is on, PUBLIC_AND_PRIVATE included."
819 ),
820)
821@click.option(
822 "--bastion-ttl-minutes",
823 type=int,
824 default=120,
825 show_default=True,
826 help="Self-terminate backstop (minutes) for an `--via-ssm auto` bastion",
827)
828@click.option(
829 "--yes",
830 "-y",
831 "assume_yes",
832 is_flag=True,
833 help="Skip the confirmation prompt when provisioning an `--via-ssm auto` bastion",
834)
835@pass_config
836def costs_dashboard(
837 config: Any,
838 service: str,
839 region: Any,
840 local_port: Any,
841 via_ssm: Any,
842 bastion_ttl_minutes: int,
843 assume_yes: bool,
844) -> None:
845 """Open a regional cost dashboard over the private EKS endpoint.
847 Port-forwards to the in-cluster Grafana (GCO Cost dashboard) or the
848 native OpenCost UI. Runs in the foreground; press Ctrl-C to stop. On a
849 private-endpoint cluster (the default) pass ``--via-ssm <instance-id>``
850 or ``--via-ssm auto`` exactly like ``gco monitoring open``.
852 Examples:
853 gco costs dashboard
854 gco costs dashboard --service opencost --region us-east-1
855 gco costs dashboard --via-ssm auto -y
856 """
857 from .. import cluster_ui
858 from ..cluster_tunnel import open_api_server_tunnel, resolve_region
859 from ..kubectl_helpers import build_port_forward_command, update_kubeconfig
860 from .monitoring_cmd import _MONITORING_NAMESPACE, _SERVICES
862 formatter = get_output_formatter(config)
863 svc = _SERVICES[service]
864 target_region = resolve_region(config, region)
865 cluster = f"{config.project_name}-{target_region}"
866 bind_port = local_port or svc["default_local_port"]
868 try:
869 update_kubeconfig(cluster, target_region)
870 except (RuntimeError, ValueError) as exc:
871 formatter.print_error(str(exc))
872 sys.exit(1)
874 try:
875 with open_api_server_tunnel(
876 formatter,
877 cluster=cluster,
878 region=target_region,
879 via_ssm=via_ssm,
880 bastion_ttl_minutes=bastion_ttl_minutes,
881 assume_yes=assume_yes,
882 ) as session:
883 cmd = build_port_forward_command(
884 _MONITORING_NAMESPACE,
885 svc["target"],
886 bind_port,
887 svc["remote_port"],
888 server=session.server,
889 tls_server_name=session.tls_server_name,
890 )
891 formatter.print_info(
892 f"Starting kubectl port-forward for {service} on localhost:{bind_port}..."
893 )
895 def _announce() -> None:
896 if service == "grafana":
897 url = f"http://localhost:{bind_port}/d/gco-cost/gco-cost-opencost"
898 formatter.print_success(f"GCO Cost dashboard → {url} (Ctrl-C to stop)")
899 formatter.print_info(
900 "Log in with the Grafana admin credential from the "
901 "kube-prometheus-stack-grafana Secret (monitoring namespace)."
902 )
903 else:
904 url = f"http://localhost:{bind_port}"
905 formatter.print_success(f"OpenCost UI → {url} (Ctrl-C to stop)")
907 try:
908 cluster_ui.exec_port_forward(cmd, bind_port, on_ready=_announce)
909 except KeyboardInterrupt: # pragma: no cover - interactive Ctrl-C
910 return
911 except (RuntimeError, ValueError) as exc:
912 formatter.print_error(str(exc))
913 sys.exit(1)