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

1"""Cost tracking commands.""" 

2 

3import sys 

4from typing import Any 

5 

6import click 

7 

8from ..config import GCOConfig, _load_cdk_json 

9from ..output import confirm, get_output_formatter 

10 

11pass_config = click.make_pass_decorator(GCOConfig, ensure=True) 

12 

13 

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] 

22 

23 

24@click.group() 

25@pass_config 

26def costs(config: Any) -> None: 

27 """View cost breakdowns and estimates for GCO resources.""" 

28 

29 

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() 

59 

60 

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. 

71 

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 

78 

79 formatter = get_output_formatter(config) 

80 

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" 

85 

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 

99 

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) 

104 

105 for svc in summary.by_service: 

106 print(f" {svc.service:<50} ${svc.amount:>10.2f}") 

107 

108 print(" " + "-" * 75) 

109 print(f" {'TOTAL':<50} ${summary.total:>10.2f}") 

110 print() 

111 

112 except Exception as e: 

113 formatter.print_error(f"Failed to get cost summary: {e}") 

114 sys.exit(1) 

115 

116 

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. 

124 

125 Examples: 

126 gco costs regions 

127 gco costs regions --days 7 

128 """ 

129 from ..costs import get_cost_tracker 

130 

131 formatter = get_output_formatter(config) 

132 

133 try: 

134 tracker = get_cost_tracker(config) 

135 by_region = tracker.get_cost_by_region(days=days) 

136 

137 if config.output_format != "table": 

138 formatter.print(by_region) 

139 return 

140 

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) 

146 

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}%)") 

150 

151 print(" " + "-" * 50) 

152 print(f" {'TOTAL':<30} ${total:>10.2f}") 

153 print() 

154 

155 except Exception as e: 

156 formatter.print_error(f"Failed to get regional costs: {e}") 

157 sys.exit(1) 

158 

159 

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. 

168 

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 

175 

176 formatter = get_output_formatter(config) 

177 

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" 

182 

183 if config.output_format != "table": 

184 formatter.print(trend) 

185 return 

186 

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) 

191 

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}") 

197 

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() 

203 

204 except Exception as e: 

205 formatter.print_error(f"Failed to get cost trend: {e}") 

206 sys.exit(1) 

207 

208 

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). 

214 

215 Examples: 

216 gco costs workloads 

217 gco costs workloads -r us-east-1 

218 """ 

219 from ..costs import get_cost_tracker 

220 

221 formatter = get_output_formatter(config) 

222 

223 try: 

224 tracker = get_cost_tracker(config) 

225 

226 regions = [region] if region else _get_deployment_regions(config) 

227 all_workloads = [] 

228 

229 for r in regions: 

230 workloads = tracker.estimate_running_workloads(r) 

231 all_workloads.extend(workloads) 

232 

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 

250 

251 if not all_workloads: 

252 formatter.print_info("No running workloads found") 

253 return 

254 

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) 

261 

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 

270 

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() 

277 

278 except Exception as e: 

279 formatter.print_error(f"Failed to estimate workload costs: {e}") 

280 sys.exit(1) 

281 

282 

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. 

288 

289 Examples: 

290 gco costs forecast 

291 gco costs forecast --days 60 

292 """ 

293 from ..costs import get_cost_tracker 

294 

295 formatter = get_output_formatter(config) 

296 

297 try: 

298 tracker = get_cost_tracker(config) 

299 forecast = tracker.get_forecast(days_ahead=days) 

300 

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 

305 

306 if config.output_format != "table": 

307 formatter.print(forecast) 

308 return 

309 

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() 

316 

317 except Exception as e: 

318 formatter.print_error(f"Failed to get forecast: {e}") 

319 sys.exit(1) 

320 

321 

322# --------------------------------------------------------------------------- 

323# allocation subgroup — the billing-account switches behind the queries above 

324# --------------------------------------------------------------------------- 

325 

326 

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) 

334 

335 

336@costs.group("allocation") 

337@pass_config 

338def costs_allocation(config: Any) -> None: 

339 """Manage the cost allocation tags behind `gco costs` reporting. 

340 

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 """ 

348 

349 

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. 

361 

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 

367 

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}" 

381 

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 

391 

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) 

426 

427 

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. 

449 

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. 

454 

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 

461 

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 ] 

466 

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 ) 

473 

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 ) 

482 

483 if config.output_format != "table": 

484 formatter.print({**result, "backfill": backfill}) 

485 if result["errors"]: 

486 sys.exit(1) 

487 return 

488 

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) 

514 

515 

516# --------------------------------------------------------------------------- 

517# k8s subgroup — Athena queries over the OpenCost allocation reports 

518# --------------------------------------------------------------------------- 

519 

520 

521@costs.group("k8s") 

522@pass_config 

523def costs_k8s(config: Any) -> None: 

524 """Query Kubernetes allocation costs across regions (Athena-backed). 

525 

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 """ 

530 

531 

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. 

538 

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 

545 

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) 

555 

556 

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. 

562 

563 Examples: 

564 gco costs k8s regions 

565 gco costs k8s regions --days 30 

566 """ 

567 from ..cost_analytics import get_cost_analytics 

568 

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) 

577 

578 

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. 

592 

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 

599 

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) 

609 

610 

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. 

626 

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 

633 

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) 

642 

643 

644# --------------------------------------------------------------------------- 

645# report subgroup — ad-hoc reports + report listing via the GCO API 

646# --------------------------------------------------------------------------- 

647 

648 

649def _cost_api_region(config: Any, region: Any) -> Any: 

650 """Resolve the transport region for /api/v1/cost/* calls. 

651 

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 

660 

661 

662@costs.group("report") 

663@pass_config 

664def costs_report(config: Any) -> None: 

665 """Generate and list OpenCost allocation reports via the GCO API.""" 

666 

667 

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). 

681 

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 

688 

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) 

706 

707 

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. 

715 

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 

722 

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) 

752 

753 

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. 

759 

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 

765 

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) 

793 

794 

795# --------------------------------------------------------------------------- 

796# dashboard — port-forward to the regional cost dashboards 

797# --------------------------------------------------------------------------- 

798 

799 

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. 

846 

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``. 

851 

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 

861 

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"] 

867 

868 try: 

869 update_kubeconfig(cluster, target_region) 

870 except (RuntimeError, ValueError) as exc: 

871 formatter.print_error(str(exc)) 

872 sys.exit(1) 

873 

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 ) 

894 

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)") 

906 

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)