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

292 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +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 

20The bucket itself is *discovered*, not configured: its CloudFormation-generated 

21name is published by the monitoring stack as an SSM parameter in the monitoring 

22region (``COST_REPORT_BUCKET_PARAMETER`` / ``COST_REPORT_BUCKET_PARAMETER_REGION``), 

23and that stack deploys after the regional stack this service runs in. Until the 

24parameter exists the scheduled pass skips and the API answers 503; nothing here 

25ever reconstructs a bucket name. ``COST_REPORT_BUCKET`` remains an explicit 

26override for kind/CI. 

27 

28OpenCost is reached over verified HTTPS through the ``opencost-tls`` Service, 

29a TLS sidecar in the OpenCost pod that serves a certificate issued by the GCO 

30internal CA; the client trusts that CA alone (see 

31:mod:`gco.services.internal_tls`). An ``http://`` ``OPENCOST_BASE_URL`` 

32override (kind/CI) opens no TLS session and needs no CA bundle. 

33""" 

34 

35from __future__ import annotations 

36 

37import importlib 

38import io 

39import logging 

40import math 

41import os 

42import time 

43import uuid 

44from collections.abc import Callable 

45from dataclasses import dataclass, field 

46from datetime import UTC, datetime, timedelta 

47from typing import Any 

48 

49import boto3 

50import httpx2 

51from botocore.config import Config 

52 

53from gco.services import internal_tls, tracing 

54 

55logger = logging.getLogger(__name__) 

56 

57#: In-cluster OpenCost API behind the OpenCost pod's TLS sidecar. 

58DEFAULT_OPENCOST_BASE_URL = "https://opencost-tls.monitoring.svc.cluster.local:9443" 

59 

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

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

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

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

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

65 "window_start", 

66 "window_end", 

67 "cluster", 

68 "namespace", 

69 "cpu_core_hours", 

70 "cpu_cost", 

71 "ram_gib_hours", 

72 "ram_cost", 

73 "gpu_hours", 

74 "gpu_cost", 

75 "pv_cost", 

76 "network_cost", 

77 "load_balancer_cost", 

78 "shared_cost", 

79 "external_cost", 

80 "total_cost", 

81 "total_efficiency", 

82) 

83 

84_GIB = 1024.0**3 

85 

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

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

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

89SCHEDULED_PREFIX = "reports" 

90ADHOC_PREFIX = "adhoc" 

91 

92_MIN_WINDOW_MINUTES = 5 

93_MAX_WINDOW_HOURS = 7 * 24 

94 

95 

96class OpenCostUnavailableError(RuntimeError): 

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

98 

99 

100class ReportWriteError(RuntimeError): 

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

102 

103 

104class CostReportBucketUnavailableError(RuntimeError): 

105 """Raised while the cost report bucket's identity is not yet resolvable. 

106 

107 Expected on a fresh deploy-all: the regional cost-monitor boots before the 

108 monitoring stack publishes the bucket. Callers treat it as "not ready yet" 

109 (skip the scheduled pass, answer HTTP 503) rather than as a write failure. 

110 """ 

111 

112 

113#: How long a resolved bucket name is served before SSM is consulted again. 

114#: A monitoring-stack redeploy that replaces the bucket updates the parameter; 

115#: one GetParameter per refresh keeps the service converging on it. 

116DEFAULT_BUCKET_REFRESH_SECONDS = 900.0 

117 

118 

119class CostReportBucketLocator: 

120 """Resolve the cost report bucket name from its published SSM parameter. 

121 

122 The monitoring stack writes ``/<project>/cost-report-bucket/name`` in the 

123 monitoring region; the regional cost-monitor role holds ``ssm:GetParameter`` 

124 on exactly that parameter. Resolution is lazy and cached: the first 

125 successful read is served for ``refresh_seconds``, a refresh that fails 

126 keeps serving the last known name (a transient SSM error must not stall 

127 report writes), and a read that never succeeded raises 

128 :class:`CostReportBucketUnavailableError` so callers can back off. 

129 """ 

130 

131 def __init__( 

132 self, 

133 parameter_name: str, 

134 region: str, 

135 *, 

136 ssm_client: Any | None = None, 

137 refresh_seconds: float = DEFAULT_BUCKET_REFRESH_SECONDS, 

138 clock: Callable[[], float] = time.monotonic, 

139 ) -> None: 

140 if not parameter_name.strip(): 

141 raise ValueError("parameter_name must not be empty") 

142 if not region.strip(): 

143 raise ValueError("region must not be empty") 

144 self.parameter_name = parameter_name 

145 self.region = region 

146 self.refresh_seconds = max(float(refresh_seconds), 0.0) 

147 self._clock = clock 

148 self._ssm = ssm_client or boto3.client( 

149 "ssm", 

150 region_name=region, 

151 config=Config( 

152 connect_timeout=5, 

153 read_timeout=10, 

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

155 ), 

156 ) 

157 self._cached: str | None = None 

158 self._cached_at: float | None = None 

159 

160 @property 

161 def cached(self) -> str | None: 

162 """The last successfully resolved bucket name, or ``None`` before one.""" 

163 return self._cached 

164 

165 def resolve(self) -> str: 

166 """Return the bucket name, reading SSM when the cache is empty or stale.""" 

167 now = self._clock() 

168 if ( 

169 self._cached is not None 

170 and self._cached_at is not None 

171 and now - self._cached_at < self.refresh_seconds 

172 ): 

173 return self._cached 

174 try: 

175 response = self._ssm.get_parameter(Name=self.parameter_name) 

176 except Exception as exc: # absence and transport errors converge 

177 if self._cached is not None: 

178 logger.warning( 

179 "Refreshing cost report bucket from %s (%s) failed; keeping %s: %s", 

180 self.parameter_name, 

181 self.region, 

182 self._cached, 

183 exc, 

184 ) 

185 self._cached_at = now 

186 return self._cached 

187 raise CostReportBucketUnavailableError( 

188 f"Cost report bucket parameter {self.parameter_name} in {self.region} " 

189 f"is not readable yet: {exc}" 

190 ) from exc 

191 parameter = response.get("Parameter") if isinstance(response, dict) else None 

192 value = str((parameter or {}).get("Value") or "").strip() 

193 if not value: 

194 raise CostReportBucketUnavailableError( 

195 f"Cost report bucket parameter {self.parameter_name} in {self.region} is empty" 

196 ) 

197 if value != self._cached: 

198 logger.info( 

199 "Resolved cost report bucket %s from %s (%s)", 

200 value, 

201 self.parameter_name, 

202 self.region, 

203 ) 

204 self._cached = value 

205 self._cached_at = now 

206 return value 

207 

208 

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

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

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

212 

213 

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

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

216 

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

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

219 Athena aggregate over the table. 

220 """ 

221 try: 

222 result = float(value) 

223 except TypeError, ValueError: 

224 return 0.0 

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

226 

227 

228@dataclass(frozen=True) 

229class ReportResult: 

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

231 

232 s3_key: str 

233 row_count: int 

234 total_cost: float 

235 window_start: str 

236 window_end: str 

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

238 

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

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

241 return { 

242 "s3_key": self.s3_key, 

243 "row_count": self.row_count, 

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

245 "window_start": self.window_start, 

246 "window_end": self.window_end, 

247 } 

248 

249 

250class OpenCostClient: 

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

252 

253 Every call builds its own ``httpx2.Client`` and closes it before 

254 returning. The TLS trust is looked up then, through 

255 :func:`~gco.services.internal_tls.verify_for_url`, whose cache hands back 

256 the same context until the projected ``ca.crt`` changes and a fresh one 

257 afterwards: a rotated internal CA is trusted from the next call without 

258 restarting the pod, where a long-lived client would keep verifying 

259 against the bundle it loaded first and report OpenCost unavailable until 

260 a restart. Nothing is built at construction, so building the service 

261 never touches the CA bundle: a bundle that is not mounted yet surfaces as 

262 "OpenCost unhealthy" (and a failed allocation query) on each call until 

263 it appears, instead of a crash at startup. The calls are a probe per 

264 status request and one allocation query per report, so a connection per 

265 call costs nothing that matters, and the scheduled reporter and the API 

266 handlers can call in from worker threads without sharing any state. 

267 """ 

268 

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

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

271 self.timeout_seconds = timeout_seconds 

272 

273 def _http(self) -> httpx2.Client: 

274 """Build the client for one call, with the TLS trust current now. 

275 

276 The caller closes it. Raises 

277 :class:`~gco.services.internal_tls.InternalTLSError` while an 

278 ``https`` base URL has no usable internal CA bundle. The transport, 

279 not the client, carries ``verify`` because a client given 

280 ``transport=`` ignores its own TLS settings. 

281 """ 

282 transport = httpx2.HTTPTransport( 

283 verify=internal_tls.verify_for_url(self.base_url), trust_env=False 

284 ) 

285 return httpx2.Client( 

286 transport=tracing.wrap_sync_transport(transport), 

287 timeout=self.timeout_seconds, 

288 trust_env=False, 

289 ) 

290 

291 def _get(self, path: str, **kwargs: Any) -> httpx2.Response: 

292 """One GET on its own client; the body is read before the client closes.""" 

293 with self._http() as client: 

294 return client.get(f"{self.base_url}{path}", **kwargs) 

295 

296 def is_healthy(self) -> bool: 

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

298 try: 

299 response = self._get("/healthz") 

300 except internal_tls.InternalTLSError as exc: 

301 logger.warning("OpenCost TLS trust is unavailable: %s", exc) 

302 return False 

303 except httpx2.HTTPError: 

304 return False 

305 return response.status_code == 200 

306 

307 def get_allocation( 

308 self, 

309 window_start: datetime, 

310 window_end: datetime, 

311 *, 

312 aggregate: str = "namespace", 

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

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

315 

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

317 raw OpenCost allocation object. Raises 

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

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

320 fails a scheduled pass or an API request. 

321 """ 

322 window = ( 

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

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

325 ) 

326 try: 

327 response = self._get( 

328 "/allocation/compute", 

329 params={ 

330 "window": window, 

331 "aggregate": aggregate, 

332 "accumulate": "true", 

333 }, 

334 ) 

335 except internal_tls.InternalTLSError as exc: 

336 raise OpenCostUnavailableError(f"OpenCost TLS trust is unavailable: {exc}") from exc 

337 except httpx2.HTTPError as exc: 

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

339 if response.status_code != 200: 

340 raise OpenCostUnavailableError( 

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

342 ) 

343 try: 

344 payload = response.json() 

345 except ValueError as exc: 

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

347 data = payload.get("data") 

348 if not isinstance(data, list): 

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

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

351 for allocation_set in data: 

352 if not isinstance(allocation_set, dict): 

353 continue 

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

355 if isinstance(allocation, dict): 

356 merged[str(name)] = allocation 

357 return merged 

358 

359 

360def allocations_to_rows( 

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

362 *, 

363 cluster: str, 

364 window_start: datetime, 

365 window_end: datetime, 

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

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

368 

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

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

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

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

373 """ 

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

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

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

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

378 rows.append( 

379 { 

380 "window_start": start_iso, 

381 "window_end": end_iso, 

382 "cluster": cluster, 

383 "namespace": name, 

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

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

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

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

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

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

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

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

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

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

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

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

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

397 } 

398 ) 

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

400 return rows 

401 

402 

403def preload_report_writer() -> bool: 

404 """Load the Parquet writer now instead of on the first report. 

405 

406 :func:`rows_to_parquet_bytes` imports ``pyarrow`` lazily, so without this 

407 the first scheduled pass — a minute after start, outside the startup 

408 probe's window — maps pyarrow's native libraries (about 130 MB) and runs 

409 its module initialization under the GIL, in the process that answers the 

410 kubelet's probes. The service calls this during startup instead, where the 

411 startup probe budgets for a slow node, so every scheduled pass costs what 

412 a warm one does. Returns ``False`` (and logs) when pyarrow is missing; the 

413 report path then fails with :class:`ReportWriteError` as before. 

414 """ 

415 try: 

416 importlib.import_module("pyarrow.parquet") 

417 except ImportError: 

418 logger.warning("pyarrow is not installed; cost reports cannot be written") 

419 return False 

420 return True 

421 

422 

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

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

425 

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

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

428 consumer) do not need the dependency at import time; the service loads it 

429 at startup with :func:`preload_report_writer`. The window bound columns 

430 are stored as real timestamps so the Glue ``timestamp`` columns read them 

431 natively. 

432 """ 

433 try: 

434 import pyarrow as pa 

435 import pyarrow.parquet as pq 

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

437 raise ReportWriteError( 

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

439 "image-cost-monitor dependency group" 

440 ) from exc 

441 

442 schema = pa.schema( 

443 [ 

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

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

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

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

448 *[ 

449 (name, pa.float64()) 

450 for name in ALLOCATION_REPORT_FIELDS 

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

452 ], 

453 ] 

454 ) 

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

456 for row in rows: 

457 for name in ALLOCATION_REPORT_FIELDS: 

458 value: Any = row.get(name) 

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

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

461 columns[name].append(value) 

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

463 sink = io.BytesIO() 

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

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

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

467 write_table: Any = pq.write_table 

468 write_table(table, sink) 

469 return sink.getvalue() 

470 

471 

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

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

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

475 return ( 

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

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

478 ) 

479 

480 

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

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

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

484 return ( 

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

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

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

488 ) 

489 

490 

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

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

493 

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

495 aligning to interval boundaries makes the scheduled key deterministic 

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

497 idempotent. 

498 """ 

499 interval = timedelta(minutes=interval_minutes) 

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

501 elapsed = now.astimezone(UTC) - epoch 

502 completed_intervals = int(elapsed / interval) 

503 window_end = epoch + interval * completed_intervals 

504 return window_end - interval, window_end 

505 

506 

507class CostMonitor: 

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

509 

510 The destination bucket is either fixed (``bucket``, the kind/CI override) 

511 or discovered through a :class:`CostReportBucketLocator`; exactly one of 

512 the two must be supplied. 

513 """ 

514 

515 def __init__( 

516 self, 

517 *, 

518 region: str, 

519 cluster: str, 

520 opencost: OpenCostClient, 

521 bucket: str | None = None, 

522 bucket_locator: CostReportBucketLocator | None = None, 

523 report_interval_minutes: int = 60, 

524 s3_client: Any | None = None, 

525 ) -> None: 

526 if (bucket is None) == (bucket_locator is None): 

527 raise ValueError("exactly one of bucket or bucket_locator is required") 

528 self.region = region 

529 self.cluster = cluster 

530 self._bucket = bucket 

531 self._bucket_locator = bucket_locator 

532 self.opencost = opencost 

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

534 self._s3 = s3_client or boto3.client( 

535 "s3", 

536 config=Config( 

537 connect_timeout=5, 

538 read_timeout=60, 

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

540 ), 

541 ) 

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

543 self.last_error: str | None = None 

544 

545 # ------------------------------------------------------------------ 

546 # Bucket identity 

547 # ------------------------------------------------------------------ 

548 

549 @property 

550 def bucket(self) -> str | None: 

551 """The report bucket, or ``None`` until SSM discovery has succeeded.""" 

552 if self._bucket is not None: 

553 return self._bucket 

554 assert self._bucket_locator is not None 

555 return self._bucket_locator.cached 

556 

557 @property 

558 def bucket_source(self) -> str: 

559 """``"environment"`` for a fixed bucket, ``"ssm"`` for discovery.""" 

560 return "environment" if self._bucket is not None else "ssm" 

561 

562 @property 

563 def bucket_parameter(self) -> str | None: 

564 """The SSM parameter discovery reads, or ``None`` for a fixed bucket.""" 

565 if self._bucket_locator is None: 

566 return None 

567 return self._bucket_locator.parameter_name 

568 

569 def _resolve_bucket(self) -> str: 

570 """Return the bucket to use now, raising while it is undiscoverable.""" 

571 if self._bucket is not None: 

572 return self._bucket 

573 assert self._bucket_locator is not None 

574 return self._bucket_locator.resolve() 

575 

576 # ------------------------------------------------------------------ 

577 # Report generation 

578 # ------------------------------------------------------------------ 

579 

580 def generate_report( 

581 self, 

582 window_start: datetime, 

583 window_end: datetime, 

584 *, 

585 adhoc: bool, 

586 include_rows: bool = False, 

587 ) -> ReportResult: 

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

589 

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

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

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

593 """ 

594 if window_end <= window_start: 

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

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

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

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

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

600 

601 # Resolve the destination before querying OpenCost: an undiscoverable 

602 # bucket is a "not ready" signal and must not cost an allocation query. 

603 bucket = self._resolve_bucket() 

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

605 rows = allocations_to_rows( 

606 allocations, 

607 cluster=self.cluster, 

608 window_start=window_start, 

609 window_end=window_end, 

610 ) 

611 key = ( 

612 adhoc_report_key(self.region, window_start, window_end) 

613 if adhoc 

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

615 ) 

616 payload = rows_to_parquet_bytes(rows) 

617 try: 

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

619 except Exception as exc: # boto surfaces many shapes 

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

621 

622 return ReportResult( 

623 s3_key=key, 

624 row_count=len(rows), 

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

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

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

628 rows=rows if include_rows else [], 

629 ) 

630 

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

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

633 

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

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

636 persisted it — and when the bucket has not been published yet (the 

637 monitoring stack deploys after this region; ``last_error`` records 

638 the wait so ``status()`` explains the missing reports). Other failures 

639 update ``last_error`` and re-raise so the caller's loop logs and 

640 retries on the next tick. 

641 """ 

642 moment = now or datetime.now(UTC) 

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

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

645 try: 

646 bucket = self._resolve_bucket() 

647 except CostReportBucketUnavailableError as exc: 

648 self.last_error = str(exc) 

649 logger.info("Cost report bucket not published yet; skipping scheduled pass: %s", exc) 

650 return None 

651 if self._object_exists(bucket, key): 

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

653 return None 

654 try: 

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

656 except Exception as exc: 

657 self.last_error = str(exc) 

658 raise 

659 self.last_scheduled_report = result.summary() 

660 self.last_error = None 

661 logger.info( 

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

663 result.s3_key, 

664 result.row_count, 

665 result.total_cost, 

666 ) 

667 return result 

668 

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

670 try: 

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

672 except Exception: # 404 and transport errors both mean "write it" 

673 return False 

674 return True 

675 

676 # ------------------------------------------------------------------ 

677 # Introspection for the API surface 

678 # ------------------------------------------------------------------ 

679 

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

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

682 prefix = ( 

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

684 if adhoc 

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

686 ) 

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

688 bucket = self._resolve_bucket() 

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

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

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

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

693 objects.append( 

694 { 

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

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

697 "last_modified": ( 

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

699 if entry.get("LastModified") 

700 else None 

701 ), 

702 } 

703 ) 

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

705 return objects[:bounded_limit] 

706 

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

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

709 

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

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

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

713 rather than silently producing empty reports. 

714 """ 

715 opencost_healthy = self.opencost.is_healthy() 

716 returning_data = False 

717 allocation_names: list[str] = [] 

718 if opencost_healthy: 

719 try: 

720 now = datetime.now(UTC) 

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

722 allocation_names = sorted(allocations) 

723 returning_data = bool(allocations) 

724 except OpenCostUnavailableError as exc: 

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

726 return { 

727 "service": "cost-monitor", 

728 "region": self.region, 

729 "cluster": self.cluster, 

730 "bucket": self.bucket, 

731 "bucket_source": self.bucket_source, 

732 "bucket_parameter": self.bucket_parameter, 

733 "report_interval_minutes": self.report_interval_minutes, 

734 "opencost_healthy": opencost_healthy, 

735 "opencost_returning_data": returning_data, 

736 "allocation_names": allocation_names[:25], 

737 "last_scheduled_report": self.last_scheduled_report, 

738 "last_error": self.last_error, 

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

740 } 

741 

742 

743def create_cost_monitor_from_env() -> CostMonitor: 

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

745 

746 ``COST_REPORT_BUCKET`` (explicit name) wins when set; otherwise 

747 ``COST_REPORT_BUCKET_PARAMETER`` names the SSM parameter the monitoring 

748 stack publishes, read in ``COST_REPORT_BUCKET_PARAMETER_REGION`` (defaults 

749 to this region). Discovery is lazy, so constructing the monitor never 

750 touches AWS. 

751 """ 

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

753 if not region: 

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

755 bucket = os.getenv("COST_REPORT_BUCKET", "").strip() or None 

756 bucket_locator: CostReportBucketLocator | None = None 

757 if bucket is None: 

758 parameter_name = os.getenv("COST_REPORT_BUCKET_PARAMETER", "").strip() 

759 if not parameter_name: 

760 raise RuntimeError( 

761 "COST_REPORT_BUCKET or COST_REPORT_BUCKET_PARAMETER environment variable is required" 

762 ) 

763 parameter_region = os.getenv("COST_REPORT_BUCKET_PARAMETER_REGION", "").strip() or region 

764 bucket_locator = CostReportBucketLocator(parameter_name, parameter_region) 

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

766 base_url = os.getenv("OPENCOST_BASE_URL", DEFAULT_OPENCOST_BASE_URL) 

767 try: 

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

769 except ValueError: 

770 interval = 60 

771 return CostMonitor( 

772 region=region, 

773 cluster=cluster, 

774 bucket=bucket, 

775 bucket_locator=bucket_locator, 

776 opencost=OpenCostClient(base_url), 

777 report_interval_minutes=interval, 

778 )