Coverage for gco/services/cost_monitor.py: 99.57%

195 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1"""Cost Monitor service core: OpenCost allocation reports as Parquet in S3. 

2 

3The cost-monitor Deployment (one per regional cluster) runs two surfaces on 

4top of this module: 

5 

61. A scheduled reporter (driven by :mod:`gco.services.cost_api`) that writes 

7 one Parquet allocation report per interval to the central cost report 

8 bucket under ``reports/region=<region>/date=<YYYY-MM-DD>/`` — the layout 

9 the monitoring stack's Glue table reads with partition projection. 

102. An internal HTTP API the manifest processor proxies as ``/api/v1/cost/*``, 

11 serving ad-hoc report generation (written under ``adhoc/`` so overlapping 

12 windows never double-count in Athena) plus report listing and service 

13 status. 

14 

15Report object keys for scheduled windows are **deterministic** — derived only 

16from the window bounds — so a rollout overlap or retry can never produce two 

17objects for one window: concurrent writers converge on the same key and the 

18last write wins with identical content. 

19""" 

20 

21from __future__ import annotations 

22 

23import io 

24import logging 

25import math 

26import os 

27import uuid 

28from dataclasses import dataclass, field 

29from datetime import UTC, datetime, timedelta 

30from typing import Any 

31 

32import boto3 

33import httpx 

34from botocore.config import Config 

35 

36logger = logging.getLogger(__name__) 

37 

38#: Normalized report row fields, in Parquet column order. This is the 

39#: write-side contract of the monitoring stack's Glue table 

40#: (``gco/stacks/monitoring_stack.py::_create_cost_analytics``) — the two 

41#: must stay in lockstep or Athena reads misaligned columns. 

42ALLOCATION_REPORT_FIELDS: tuple[str, ...] = ( 

43 "window_start", 

44 "window_end", 

45 "cluster", 

46 "namespace", 

47 "cpu_core_hours", 

48 "cpu_cost", 

49 "ram_gib_hours", 

50 "ram_cost", 

51 "gpu_hours", 

52 "gpu_cost", 

53 "pv_cost", 

54 "network_cost", 

55 "load_balancer_cost", 

56 "shared_cost", 

57 "external_cost", 

58 "total_cost", 

59 "total_efficiency", 

60) 

61 

62_GIB = 1024.0**3 

63 

64#: Prefixes must mirror gco/stacks/constants.py (the service image does not 

65#: ship the CDK stacks package, so the values are duplicated deliberately — 

66#: a synth-side test asserts the two stay in lockstep). 

67SCHEDULED_PREFIX = "reports" 

68ADHOC_PREFIX = "adhoc" 

69 

70_MIN_WINDOW_MINUTES = 5 

71_MAX_WINDOW_HOURS = 7 * 24 

72 

73 

74class OpenCostUnavailableError(RuntimeError): 

75 """Raised when the OpenCost API cannot be reached or answers abnormally.""" 

76 

77 

78class ReportWriteError(RuntimeError): 

79 """Raised when a generated report cannot be persisted to S3.""" 

80 

81 

82def _compact_ts(moment: datetime) -> str: 

83 """Render a UTC timestamp as a compact S3-key-safe token.""" 

84 return moment.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ") 

85 

86 

87def _as_float(value: Any) -> float: 

88 """Coerce OpenCost numeric fields defensively; absent/bad values are 0. 

89 

90 Non-finite values (NaN and ±infinity — ``json.loads`` accepts both) are 

91 coerced to 0 too: one poisoned row would otherwise contaminate every 

92 Athena aggregate over the table. 

93 """ 

94 try: 

95 result = float(value) 

96 except TypeError, ValueError: 

97 return 0.0 

98 return result if math.isfinite(result) else 0.0 

99 

100 

101@dataclass(frozen=True) 

102class ReportResult: 

103 """Outcome of one generated allocation report.""" 

104 

105 s3_key: str 

106 row_count: int 

107 total_cost: float 

108 window_start: str 

109 window_end: str 

110 rows: list[dict[str, Any]] = field(default_factory=list) 

111 

112 def summary(self) -> dict[str, Any]: 

113 """Operator-facing summary without the full row payload.""" 

114 return { 

115 "s3_key": self.s3_key, 

116 "row_count": self.row_count, 

117 "total_cost": round(self.total_cost, 6), 

118 "window_start": self.window_start, 

119 "window_end": self.window_end, 

120 } 

121 

122 

123class OpenCostClient: 

124 """Minimal HTTP client for the in-cluster OpenCost allocation API.""" 

125 

126 def __init__(self, base_url: str, timeout_seconds: float = 30.0) -> None: 

127 self.base_url = base_url.rstrip("/") 

128 self.timeout_seconds = timeout_seconds 

129 

130 def is_healthy(self) -> bool: 

131 """Return whether OpenCost answers its /healthz probe.""" 

132 try: 

133 response = httpx.get( 

134 f"{self.base_url}/healthz", 

135 timeout=self.timeout_seconds, 

136 ) 

137 except httpx.HTTPError: 

138 return False 

139 return response.status_code == 200 

140 

141 def get_allocation( 

142 self, 

143 window_start: datetime, 

144 window_end: datetime, 

145 *, 

146 aggregate: str = "namespace", 

147 ) -> dict[str, dict[str, Any]]: 

148 """Fetch one accumulated allocation set for ``[window_start, window_end)``. 

149 

150 Returns a mapping of allocation name (namespace, by default) to the 

151 raw OpenCost allocation object. Raises 

152 :class:`OpenCostUnavailableError` on transport errors, non-200 

153 responses, or a malformed body — the caller decides whether that 

154 fails a scheduled pass or an API request. 

155 """ 

156 window = ( 

157 f"{window_start.astimezone(UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}," 

158 f"{window_end.astimezone(UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}" 

159 ) 

160 try: 

161 response = httpx.get( 

162 f"{self.base_url}/allocation/compute", 

163 params={ 

164 "window": window, 

165 "aggregate": aggregate, 

166 "accumulate": "true", 

167 }, 

168 timeout=self.timeout_seconds, 

169 ) 

170 except httpx.HTTPError as exc: 

171 raise OpenCostUnavailableError(f"OpenCost request failed: {exc}") from exc 

172 if response.status_code != 200: 

173 raise OpenCostUnavailableError( 

174 f"OpenCost allocation query returned HTTP {response.status_code}" 

175 ) 

176 try: 

177 payload = response.json() 

178 except ValueError as exc: 

179 raise OpenCostUnavailableError("OpenCost returned a non-JSON body") from exc 

180 data = payload.get("data") 

181 if not isinstance(data, list): 

182 raise OpenCostUnavailableError("OpenCost allocation response omitted data") 

183 merged: dict[str, dict[str, Any]] = {} 

184 for allocation_set in data: 

185 if not isinstance(allocation_set, dict): 

186 continue 

187 for name, allocation in allocation_set.items(): 

188 if isinstance(allocation, dict): 188 ↛ 187line 188 didn't jump to line 187 because the condition on line 188 was always true

189 merged[str(name)] = allocation 

190 return merged 

191 

192 

193def allocations_to_rows( 

194 allocations: dict[str, dict[str, Any]], 

195 *, 

196 cluster: str, 

197 window_start: datetime, 

198 window_end: datetime, 

199) -> list[dict[str, Any]]: 

200 """Normalize raw OpenCost allocations into the stable report row schema. 

201 

202 One row per allocation name (namespace). The ``__idle__`` and 

203 ``__unallocated__`` synthetic allocations OpenCost emits are kept — 

204 idle cost is exactly the visibility a cost report exists to provide — 

205 but rows are sorted by descending total cost for human-readable output. 

206 """ 

207 start_iso = window_start.astimezone(UTC).isoformat() 

208 end_iso = window_end.astimezone(UTC).isoformat() 

209 rows: list[dict[str, Any]] = [] 

210 for name, allocation in allocations.items(): 

211 rows.append( 

212 { 

213 "window_start": start_iso, 

214 "window_end": end_iso, 

215 "cluster": cluster, 

216 "namespace": name, 

217 "cpu_core_hours": _as_float(allocation.get("cpuCoreHours")), 

218 "cpu_cost": _as_float(allocation.get("cpuCost")), 

219 "ram_gib_hours": _as_float(allocation.get("ramByteHours")) / _GIB, 

220 "ram_cost": _as_float(allocation.get("ramCost")), 

221 "gpu_hours": _as_float(allocation.get("gpuHours")), 

222 "gpu_cost": _as_float(allocation.get("gpuCost")), 

223 "pv_cost": _as_float(allocation.get("pvCost")), 

224 "network_cost": _as_float(allocation.get("networkCost")), 

225 "load_balancer_cost": _as_float(allocation.get("loadBalancerCost")), 

226 "shared_cost": _as_float(allocation.get("sharedCost")), 

227 "external_cost": _as_float(allocation.get("externalCost")), 

228 "total_cost": _as_float(allocation.get("totalCost")), 

229 "total_efficiency": _as_float(allocation.get("totalEfficiency")), 

230 } 

231 ) 

232 rows.sort(key=lambda row: row["total_cost"], reverse=True) 

233 return rows 

234 

235 

236def rows_to_parquet_bytes(rows: list[dict[str, Any]]) -> bytes: 

237 """Serialize normalized report rows to a Parquet byte payload. 

238 

239 ``pyarrow`` is imported lazily so environments that never write reports 

240 (unit tests exercising only transformations, or a future reader-only 

241 consumer) do not need the dependency at import time. The window bound 

242 columns are stored as real timestamps so the Glue ``timestamp`` columns 

243 read them natively. 

244 """ 

245 try: 

246 import pyarrow as pa 

247 import pyarrow.parquet as pq 

248 except ImportError as exc: # pragma: no cover - image always ships pyarrow 

249 raise ReportWriteError( 

250 "pyarrow is required to write cost reports; install the " 

251 "image-cost-monitor dependency group" 

252 ) from exc 

253 

254 schema = pa.schema( 

255 [ 

256 ("window_start", pa.timestamp("ms", tz="UTC")), 

257 ("window_end", pa.timestamp("ms", tz="UTC")), 

258 ("cluster", pa.string()), 

259 ("namespace", pa.string()), 

260 *[ 

261 (name, pa.float64()) 

262 for name in ALLOCATION_REPORT_FIELDS 

263 if name not in {"window_start", "window_end", "cluster", "namespace"} 

264 ], 

265 ] 

266 ) 

267 columns: dict[str, list[Any]] = {name: [] for name in ALLOCATION_REPORT_FIELDS} 

268 for row in rows: 

269 for name in ALLOCATION_REPORT_FIELDS: 

270 value: Any = row.get(name) 

271 if name in {"window_start", "window_end"}: 

272 value = datetime.fromisoformat(str(value)) 

273 columns[name].append(value) 

274 table = pa.Table.from_pydict(columns, schema=schema) 

275 sink = io.BytesIO() 

276 # pyarrow ships no type stubs; route the call through an Any-typed name so 

277 # environments with pyarrow installed (tests) and without it (the mypy 

278 # strict CI job) type-check identically — no conditional type: ignore. 

279 write_table: Any = pq.write_table 

280 write_table(table, sink) 

281 return sink.getvalue() 

282 

283 

284def scheduled_report_key(region: str, window_start: datetime, window_end: datetime) -> str: 

285 """Deterministic S3 key for one scheduled report window.""" 

286 date_partition = window_start.astimezone(UTC).strftime("%Y-%m-%d") 

287 return ( 

288 f"{SCHEDULED_PREFIX}/region={region}/date={date_partition}/" 

289 f"allocation-{_compact_ts(window_start)}-{_compact_ts(window_end)}.parquet" 

290 ) 

291 

292 

293def adhoc_report_key(region: str, window_start: datetime, window_end: datetime) -> str: 

294 """Unique S3 key for one ad-hoc report (kept out of the Athena table).""" 

295 date_partition = datetime.now(UTC).strftime("%Y-%m-%d") 

296 return ( 

297 f"{ADHOC_PREFIX}/region={region}/date={date_partition}/" 

298 f"allocation-{_compact_ts(window_start)}-{_compact_ts(window_end)}" 

299 f"-{uuid.uuid4().hex[:8]}.parquet" 

300 ) 

301 

302 

303def aligned_window(now: datetime, interval_minutes: int) -> tuple[datetime, datetime]: 

304 """Return the most recent *completed* interval-aligned window. 

305 

306 For ``interval_minutes=60`` at 10:25 this yields ``[09:00, 10:00)`` — 

307 aligning to interval boundaries makes the scheduled key deterministic 

308 across restarts and replicas, which is what makes report writes 

309 idempotent. 

310 """ 

311 interval = timedelta(minutes=interval_minutes) 

312 epoch = datetime(1970, 1, 1, tzinfo=UTC) 

313 elapsed = now.astimezone(UTC) - epoch 

314 completed_intervals = int(elapsed / interval) 

315 window_end = epoch + interval * completed_intervals 

316 return window_end - interval, window_end 

317 

318 

319class CostMonitor: 

320 """Generates OpenCost allocation reports and persists them to S3.""" 

321 

322 def __init__( 

323 self, 

324 *, 

325 region: str, 

326 cluster: str, 

327 bucket: str, 

328 opencost: OpenCostClient, 

329 report_interval_minutes: int = 60, 

330 s3_client: Any | None = None, 

331 ) -> None: 

332 self.region = region 

333 self.cluster = cluster 

334 self.bucket = bucket 

335 self.opencost = opencost 

336 self.report_interval_minutes = min(max(int(report_interval_minutes), 5), 1_440) 

337 self._s3 = s3_client or boto3.client( 

338 "s3", 

339 config=Config( 

340 connect_timeout=5, 

341 read_timeout=60, 

342 retries={"max_attempts": 3, "mode": "standard"}, 

343 ), 

344 ) 

345 self.last_scheduled_report: dict[str, Any] | None = None 

346 self.last_error: str | None = None 

347 

348 # ------------------------------------------------------------------ 

349 # Report generation 

350 # ------------------------------------------------------------------ 

351 

352 def generate_report( 

353 self, 

354 window_start: datetime, 

355 window_end: datetime, 

356 *, 

357 adhoc: bool, 

358 include_rows: bool = False, 

359 ) -> ReportResult: 

360 """Query OpenCost for one window, write Parquet to S3, return the result. 

361 

362 Raises :class:`OpenCostUnavailableError` when OpenCost cannot answer 

363 and :class:`ReportWriteError` when S3 persistence fails; callers map 

364 those to a failed scheduled pass or an HTTP 502/503 respectively. 

365 """ 

366 if window_end <= window_start: 

367 raise ValueError("window_end must be after window_start") 

368 if window_end - window_start > timedelta(hours=_MAX_WINDOW_HOURS): 

369 raise ValueError(f"report windows are capped at {_MAX_WINDOW_HOURS} hours") 

370 if window_end - window_start < timedelta(minutes=_MIN_WINDOW_MINUTES): 

371 raise ValueError(f"report windows must span at least {_MIN_WINDOW_MINUTES} minutes") 

372 

373 allocations = self.opencost.get_allocation(window_start, window_end) 

374 rows = allocations_to_rows( 

375 allocations, 

376 cluster=self.cluster, 

377 window_start=window_start, 

378 window_end=window_end, 

379 ) 

380 key = ( 

381 adhoc_report_key(self.region, window_start, window_end) 

382 if adhoc 

383 else scheduled_report_key(self.region, window_start, window_end) 

384 ) 

385 payload = rows_to_parquet_bytes(rows) 

386 try: 

387 self._s3.put_object(Bucket=self.bucket, Key=key, Body=payload) 

388 except Exception as exc: # noqa: BLE001 - boto surfaces many shapes 

389 raise ReportWriteError(f"Failed to write cost report to S3: {exc}") from exc 

390 

391 return ReportResult( 

392 s3_key=key, 

393 row_count=len(rows), 

394 total_cost=sum(row["total_cost"] for row in rows), 

395 window_start=window_start.astimezone(UTC).isoformat(), 

396 window_end=window_end.astimezone(UTC).isoformat(), 

397 rows=rows if include_rows else [], 

398 ) 

399 

400 def run_scheduled_once(self, now: datetime | None = None) -> ReportResult | None: 

401 """Write the report for the most recent completed aligned window. 

402 

403 Skips (returns ``None``) when that window's object already exists — 

404 the previous pass, or another replica during a rollout, already 

405 persisted it. Failures update ``last_error`` and re-raise so the 

406 caller's loop logs and retries on the next tick. 

407 """ 

408 moment = now or datetime.now(UTC) 

409 window_start, window_end = aligned_window(moment, self.report_interval_minutes) 

410 key = scheduled_report_key(self.region, window_start, window_end) 

411 if self._object_exists(key): 

412 logger.debug("Scheduled cost report already present: %s", key) 

413 return None 

414 try: 

415 result = self.generate_report(window_start, window_end, adhoc=False) 

416 except Exception as exc: 

417 self.last_error = str(exc) 

418 raise 

419 self.last_scheduled_report = result.summary() 

420 self.last_error = None 

421 logger.info( 

422 "Wrote scheduled cost report %s (%d rows, total %.4f USD)", 

423 result.s3_key, 

424 result.row_count, 

425 result.total_cost, 

426 ) 

427 return result 

428 

429 def _object_exists(self, key: str) -> bool: 

430 try: 

431 self._s3.head_object(Bucket=self.bucket, Key=key) 

432 except Exception: # noqa: BLE001 - 404 and transport errors both mean "write it" 

433 return False 

434 return True 

435 

436 # ------------------------------------------------------------------ 

437 # Introspection for the API surface 

438 # ------------------------------------------------------------------ 

439 

440 def list_reports(self, *, adhoc: bool = False, limit: int = 50) -> list[dict[str, Any]]: 

441 """List this region's most recent report objects, newest first.""" 

442 prefix = ( 

443 f"{ADHOC_PREFIX}/region={self.region}/" 

444 if adhoc 

445 else f"{SCHEDULED_PREFIX}/region={self.region}/" 

446 ) 

447 bounded_limit = min(max(int(limit), 1), 1_000) 

448 paginator = self._s3.get_paginator("list_objects_v2") 

449 objects: list[dict[str, Any]] = [] 

450 for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix): 

451 for entry in page.get("Contents", []): 

452 objects.append( 

453 { 

454 "key": entry["Key"], 

455 "size_bytes": int(entry.get("Size", 0)), 

456 "last_modified": ( 

457 entry["LastModified"].astimezone(UTC).isoformat() 

458 if entry.get("LastModified") 

459 else None 

460 ), 

461 } 

462 ) 

463 objects.sort(key=lambda item: str(item["last_modified"] or ""), reverse=True) 

464 return objects[:bounded_limit] 

465 

466 def status(self) -> dict[str, Any]: 

467 """Operator-facing service status, including OpenCost health. 

468 

469 ``opencost_returning_data`` performs a live one-hour allocation probe 

470 — this is the signal release validation gates on, so a healthy-but- 

471 empty OpenCost (e.g. Prometheus scrape broken) fails validation 

472 rather than silently producing empty reports. 

473 """ 

474 opencost_healthy = self.opencost.is_healthy() 

475 returning_data = False 

476 allocation_names: list[str] = [] 

477 if opencost_healthy: 

478 try: 

479 now = datetime.now(UTC) 

480 allocations = self.opencost.get_allocation(now - timedelta(hours=1), now) 

481 allocation_names = sorted(allocations) 

482 returning_data = bool(allocations) 

483 except OpenCostUnavailableError as exc: 

484 logger.warning("OpenCost allocation probe failed: %s", exc) 

485 return { 

486 "service": "cost-monitor", 

487 "region": self.region, 

488 "cluster": self.cluster, 

489 "bucket": self.bucket, 

490 "report_interval_minutes": self.report_interval_minutes, 

491 "opencost_healthy": opencost_healthy, 

492 "opencost_returning_data": returning_data, 

493 "allocation_names": allocation_names[:25], 

494 "last_scheduled_report": self.last_scheduled_report, 

495 "last_error": self.last_error, 

496 "timestamp": datetime.now(UTC).isoformat(), 

497 } 

498 

499 

500def create_cost_monitor_from_env() -> CostMonitor: 

501 """Build a :class:`CostMonitor` from the Deployment's environment.""" 

502 bucket = os.getenv("COST_REPORT_BUCKET", "") 

503 if not bucket: 

504 raise RuntimeError("COST_REPORT_BUCKET environment variable is required") 

505 region = os.getenv("REGION") or os.getenv("AWS_REGION", "") 

506 if not region: 

507 raise RuntimeError("REGION environment variable is required") 

508 cluster = os.getenv("CLUSTER_NAME", f"gco-{region}") 

509 base_url = os.getenv( 

510 "OPENCOST_BASE_URL", 

511 "http://opencost.monitoring.svc.cluster.local:9003", 

512 ) 

513 try: 

514 interval = int(os.getenv("COST_REPORT_INTERVAL_MINUTES", "60")) 

515 except ValueError: 

516 interval = 60 

517 return CostMonitor( 

518 region=region, 

519 cluster=cluster, 

520 bucket=bucket, 

521 opencost=OpenCostClient(base_url), 

522 report_interval_minutes=interval, 

523 )