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