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

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

29 

30 

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

60 

61 

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. 

72 

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 

79 

80 formatter = get_output_formatter(config) 

81 

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" 

86 

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 

100 

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) 

105 

106 for svc in summary.by_service: 

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

108 

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

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

111 print() 

112 

113 except Exception as e: 

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

115 sys.exit(1) 

116 

117 

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. 

125 

126 Examples: 

127 gco costs regions 

128 gco costs regions --days 7 

129 """ 

130 from ..costs import get_cost_tracker 

131 

132 formatter = get_output_formatter(config) 

133 

134 try: 

135 tracker = get_cost_tracker(config) 

136 by_region = tracker.get_cost_by_region(days=days) 

137 

138 if config.output_format != "table": 

139 formatter.print(by_region) 

140 return 

141 

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) 

147 

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

151 

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

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

154 print() 

155 

156 except Exception as e: 

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

158 sys.exit(1) 

159 

160 

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. 

169 

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 

176 

177 formatter = get_output_formatter(config) 

178 

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" 

183 

184 if config.output_format != "table": 

185 formatter.print(trend) 

186 return 

187 

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) 

192 

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

198 

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

204 

205 except Exception as e: 

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

207 sys.exit(1) 

208 

209 

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

215 

216 Examples: 

217 gco costs workloads 

218 gco costs workloads -r us-east-1 

219 """ 

220 from ..costs import get_cost_tracker 

221 

222 formatter = get_output_formatter(config) 

223 

224 try: 

225 tracker = get_cost_tracker(config) 

226 

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

228 all_workloads = [] 

229 

230 for r in regions: 

231 workloads = tracker.estimate_running_workloads(r) 

232 all_workloads.extend(workloads) 

233 

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 

251 

252 if not all_workloads: 

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

254 return 

255 

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) 

262 

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 

271 

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

278 

279 except Exception as e: 

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

281 sys.exit(1) 

282 

283 

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. 

289 

290 Examples: 

291 gco costs forecast 

292 gco costs forecast --days 60 

293 """ 

294 from ..costs import get_cost_tracker 

295 

296 formatter = get_output_formatter(config) 

297 

298 try: 

299 tracker = get_cost_tracker(config) 

300 forecast = tracker.get_forecast(days_ahead=days) 

301 

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 

306 

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 

310 

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

317 

318 except Exception as e: 

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

320 sys.exit(1) 

321 

322 

323# --------------------------------------------------------------------------- 

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

325# --------------------------------------------------------------------------- 

326 

327 

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) 

335 

336 

337@costs.group("allocation") 

338@pass_config 

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

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

341 

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 

350 

351 

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. 

363 

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 

369 

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

383 

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 

393 

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) 

428 

429 

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. 

451 

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. 

456 

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 

463 

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 ] 

468 

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 ) 

475 

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 ) 

484 

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 

490 

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) 

516 

517 

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

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

520# --------------------------------------------------------------------------- 

521 

522 

523@costs.group("k8s") 

524@pass_config 

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

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

527 

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 

533 

534 

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. 

541 

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 

548 

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) 

558 

559 

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. 

565 

566 Examples: 

567 gco costs k8s regions 

568 gco costs k8s regions --days 30 

569 """ 

570 from ..cost_analytics import get_cost_analytics 

571 

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) 

580 

581 

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. 

595 

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 

602 

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) 

612 

613 

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. 

629 

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 

636 

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) 

645 

646 

647# --------------------------------------------------------------------------- 

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

649# --------------------------------------------------------------------------- 

650 

651 

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

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

654 

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 

663 

664 

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 

670 

671 

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

685 

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 

692 

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) 

710 

711 

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. 

719 

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 

726 

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) 

756 

757 

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. 

763 

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 

769 

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) 

797 

798 

799# --------------------------------------------------------------------------- 

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

801# --------------------------------------------------------------------------- 

802 

803 

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. 

849 

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

854 

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 

861 

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 

865 

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

871 

872 try: 

873 update_kubeconfig(cluster, target_region) 

874 except (RuntimeError, ValueError) as exc: 

875 formatter.print_error(str(exc)) 

876 sys.exit(1) 

877 

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)