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

271 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +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""" 

28 

29from __future__ import annotations 

30 

31import io 

32import logging 

33import math 

34import os 

35import time 

36import uuid 

37from collections.abc import Callable 

38from dataclasses import dataclass, field 

39from datetime import UTC, datetime, timedelta 

40from typing import Any 

41 

42import boto3 

43import httpx 

44from botocore.config import Config 

45 

46logger = logging.getLogger(__name__) 

47 

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

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

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

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

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

53 "window_start", 

54 "window_end", 

55 "cluster", 

56 "namespace", 

57 "cpu_core_hours", 

58 "cpu_cost", 

59 "ram_gib_hours", 

60 "ram_cost", 

61 "gpu_hours", 

62 "gpu_cost", 

63 "pv_cost", 

64 "network_cost", 

65 "load_balancer_cost", 

66 "shared_cost", 

67 "external_cost", 

68 "total_cost", 

69 "total_efficiency", 

70) 

71 

72_GIB = 1024.0**3 

73 

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

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

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

77SCHEDULED_PREFIX = "reports" 

78ADHOC_PREFIX = "adhoc" 

79 

80_MIN_WINDOW_MINUTES = 5 

81_MAX_WINDOW_HOURS = 7 * 24 

82 

83 

84class OpenCostUnavailableError(RuntimeError): 

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

86 

87 

88class ReportWriteError(RuntimeError): 

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

90 

91 

92class CostReportBucketUnavailableError(RuntimeError): 

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

94 

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

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

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

98 """ 

99 

100 

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

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

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

104DEFAULT_BUCKET_REFRESH_SECONDS = 900.0 

105 

106 

107class CostReportBucketLocator: 

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

109 

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

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

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

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

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

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

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

117 """ 

118 

119 def __init__( 

120 self, 

121 parameter_name: str, 

122 region: str, 

123 *, 

124 ssm_client: Any | None = None, 

125 refresh_seconds: float = DEFAULT_BUCKET_REFRESH_SECONDS, 

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

127 ) -> None: 

128 if not parameter_name.strip(): 

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

130 if not region.strip(): 

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

132 self.parameter_name = parameter_name 

133 self.region = region 

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

135 self._clock = clock 

136 self._ssm = ssm_client or boto3.client( 

137 "ssm", 

138 region_name=region, 

139 config=Config( 

140 connect_timeout=5, 

141 read_timeout=10, 

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

143 ), 

144 ) 

145 self._cached: str | None = None 

146 self._cached_at: float | None = None 

147 

148 @property 

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

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

151 return self._cached 

152 

153 def resolve(self) -> str: 

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

155 now = self._clock() 

156 if ( 

157 self._cached is not None 

158 and self._cached_at is not None 

159 and now - self._cached_at < self.refresh_seconds 

160 ): 

161 return self._cached 

162 try: 

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

164 except Exception as exc: # noqa: BLE001 - absence and transport errors converge 

165 if self._cached is not None: 

166 logger.warning( 

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

168 self.parameter_name, 

169 self.region, 

170 self._cached, 

171 exc, 

172 ) 

173 self._cached_at = now 

174 return self._cached 

175 raise CostReportBucketUnavailableError( 

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

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

178 ) from exc 

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

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

181 if not value: 

182 raise CostReportBucketUnavailableError( 

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

184 ) 

185 if value != self._cached: 

186 logger.info( 

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

188 value, 

189 self.parameter_name, 

190 self.region, 

191 ) 

192 self._cached = value 

193 self._cached_at = now 

194 return value 

195 

196 

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

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

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

200 

201 

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

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

204 

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

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

207 Athena aggregate over the table. 

208 """ 

209 try: 

210 result = float(value) 

211 except TypeError, ValueError: 

212 return 0.0 

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

214 

215 

216@dataclass(frozen=True) 

217class ReportResult: 

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

219 

220 s3_key: str 

221 row_count: int 

222 total_cost: float 

223 window_start: str 

224 window_end: str 

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

226 

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

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

229 return { 

230 "s3_key": self.s3_key, 

231 "row_count": self.row_count, 

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

233 "window_start": self.window_start, 

234 "window_end": self.window_end, 

235 } 

236 

237 

238class OpenCostClient: 

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

240 

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

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

243 self.timeout_seconds = timeout_seconds 

244 

245 def is_healthy(self) -> bool: 

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

247 try: 

248 response = httpx.get( 

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

250 timeout=self.timeout_seconds, 

251 ) 

252 except httpx.HTTPError: 

253 return False 

254 return response.status_code == 200 

255 

256 def get_allocation( 

257 self, 

258 window_start: datetime, 

259 window_end: datetime, 

260 *, 

261 aggregate: str = "namespace", 

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

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

264 

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

266 raw OpenCost allocation object. Raises 

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

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

269 fails a scheduled pass or an API request. 

270 """ 

271 window = ( 

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

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

274 ) 

275 try: 

276 response = httpx.get( 

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

278 params={ 

279 "window": window, 

280 "aggregate": aggregate, 

281 "accumulate": "true", 

282 }, 

283 timeout=self.timeout_seconds, 

284 ) 

285 except httpx.HTTPError as exc: 

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

287 if response.status_code != 200: 

288 raise OpenCostUnavailableError( 

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

290 ) 

291 try: 

292 payload = response.json() 

293 except ValueError as exc: 

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

295 data = payload.get("data") 

296 if not isinstance(data, list): 

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

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

299 for allocation_set in data: 

300 if not isinstance(allocation_set, dict): 

301 continue 

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

303 if isinstance(allocation, dict): 

304 merged[str(name)] = allocation 

305 return merged 

306 

307 

308def allocations_to_rows( 

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

310 *, 

311 cluster: str, 

312 window_start: datetime, 

313 window_end: datetime, 

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

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

316 

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

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

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

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

321 """ 

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

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

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

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

326 rows.append( 

327 { 

328 "window_start": start_iso, 

329 "window_end": end_iso, 

330 "cluster": cluster, 

331 "namespace": name, 

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

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

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

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

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

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

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

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

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

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

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

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

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

345 } 

346 ) 

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

348 return rows 

349 

350 

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

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

353 

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

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

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

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

358 read them natively. 

359 """ 

360 try: 

361 import pyarrow as pa 

362 import pyarrow.parquet as pq 

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

364 raise ReportWriteError( 

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

366 "image-cost-monitor dependency group" 

367 ) from exc 

368 

369 schema = pa.schema( 

370 [ 

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

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

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

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

375 *[ 

376 (name, pa.float64()) 

377 for name in ALLOCATION_REPORT_FIELDS 

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

379 ], 

380 ] 

381 ) 

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

383 for row in rows: 

384 for name in ALLOCATION_REPORT_FIELDS: 

385 value: Any = row.get(name) 

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

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

388 columns[name].append(value) 

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

390 sink = io.BytesIO() 

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

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

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

394 write_table: Any = pq.write_table 

395 write_table(table, sink) 

396 return sink.getvalue() 

397 

398 

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

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

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

402 return ( 

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

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

405 ) 

406 

407 

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

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

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

411 return ( 

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

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

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

415 ) 

416 

417 

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

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

420 

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

422 aligning to interval boundaries makes the scheduled key deterministic 

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

424 idempotent. 

425 """ 

426 interval = timedelta(minutes=interval_minutes) 

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

428 elapsed = now.astimezone(UTC) - epoch 

429 completed_intervals = int(elapsed / interval) 

430 window_end = epoch + interval * completed_intervals 

431 return window_end - interval, window_end 

432 

433 

434class CostMonitor: 

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

436 

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

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

439 the two must be supplied. 

440 """ 

441 

442 def __init__( 

443 self, 

444 *, 

445 region: str, 

446 cluster: str, 

447 opencost: OpenCostClient, 

448 bucket: str | None = None, 

449 bucket_locator: CostReportBucketLocator | None = None, 

450 report_interval_minutes: int = 60, 

451 s3_client: Any | None = None, 

452 ) -> None: 

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

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

455 self.region = region 

456 self.cluster = cluster 

457 self._bucket = bucket 

458 self._bucket_locator = bucket_locator 

459 self.opencost = opencost 

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

461 self._s3 = s3_client or boto3.client( 

462 "s3", 

463 config=Config( 

464 connect_timeout=5, 

465 read_timeout=60, 

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

467 ), 

468 ) 

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

470 self.last_error: str | None = None 

471 

472 # ------------------------------------------------------------------ 

473 # Bucket identity 

474 # ------------------------------------------------------------------ 

475 

476 @property 

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

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

479 if self._bucket is not None: 

480 return self._bucket 

481 assert self._bucket_locator is not None 

482 return self._bucket_locator.cached 

483 

484 @property 

485 def bucket_source(self) -> str: 

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

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

488 

489 @property 

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

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

492 if self._bucket_locator is None: 

493 return None 

494 return self._bucket_locator.parameter_name 

495 

496 def _resolve_bucket(self) -> str: 

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

498 if self._bucket is not None: 

499 return self._bucket 

500 assert self._bucket_locator is not None 

501 return self._bucket_locator.resolve() 

502 

503 # ------------------------------------------------------------------ 

504 # Report generation 

505 # ------------------------------------------------------------------ 

506 

507 def generate_report( 

508 self, 

509 window_start: datetime, 

510 window_end: datetime, 

511 *, 

512 adhoc: bool, 

513 include_rows: bool = False, 

514 ) -> ReportResult: 

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

516 

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

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

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

520 """ 

521 if window_end <= window_start: 

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

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

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

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

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

527 

528 # Resolve the destination before querying OpenCost: an undiscoverable 

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

530 bucket = self._resolve_bucket() 

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

532 rows = allocations_to_rows( 

533 allocations, 

534 cluster=self.cluster, 

535 window_start=window_start, 

536 window_end=window_end, 

537 ) 

538 key = ( 

539 adhoc_report_key(self.region, window_start, window_end) 

540 if adhoc 

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

542 ) 

543 payload = rows_to_parquet_bytes(rows) 

544 try: 

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

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

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

548 

549 return ReportResult( 

550 s3_key=key, 

551 row_count=len(rows), 

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

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

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

555 rows=rows if include_rows else [], 

556 ) 

557 

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

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

560 

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

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

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

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

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

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

567 retries on the next tick. 

568 """ 

569 moment = now or datetime.now(UTC) 

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

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

572 try: 

573 bucket = self._resolve_bucket() 

574 except CostReportBucketUnavailableError as exc: 

575 self.last_error = str(exc) 

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

577 return None 

578 if self._object_exists(bucket, key): 

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

580 return None 

581 try: 

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

583 except Exception as exc: 

584 self.last_error = str(exc) 

585 raise 

586 self.last_scheduled_report = result.summary() 

587 self.last_error = None 

588 logger.info( 

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

590 result.s3_key, 

591 result.row_count, 

592 result.total_cost, 

593 ) 

594 return result 

595 

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

597 try: 

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

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

600 return False 

601 return True 

602 

603 # ------------------------------------------------------------------ 

604 # Introspection for the API surface 

605 # ------------------------------------------------------------------ 

606 

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

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

609 prefix = ( 

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

611 if adhoc 

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

613 ) 

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

615 bucket = self._resolve_bucket() 

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

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

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

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

620 objects.append( 

621 { 

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

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

624 "last_modified": ( 

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

626 if entry.get("LastModified") 

627 else None 

628 ), 

629 } 

630 ) 

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

632 return objects[:bounded_limit] 

633 

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

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

636 

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

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

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

640 rather than silently producing empty reports. 

641 """ 

642 opencost_healthy = self.opencost.is_healthy() 

643 returning_data = False 

644 allocation_names: list[str] = [] 

645 if opencost_healthy: 

646 try: 

647 now = datetime.now(UTC) 

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

649 allocation_names = sorted(allocations) 

650 returning_data = bool(allocations) 

651 except OpenCostUnavailableError as exc: 

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

653 return { 

654 "service": "cost-monitor", 

655 "region": self.region, 

656 "cluster": self.cluster, 

657 "bucket": self.bucket, 

658 "bucket_source": self.bucket_source, 

659 "bucket_parameter": self.bucket_parameter, 

660 "report_interval_minutes": self.report_interval_minutes, 

661 "opencost_healthy": opencost_healthy, 

662 "opencost_returning_data": returning_data, 

663 "allocation_names": allocation_names[:25], 

664 "last_scheduled_report": self.last_scheduled_report, 

665 "last_error": self.last_error, 

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

667 } 

668 

669 

670def create_cost_monitor_from_env() -> CostMonitor: 

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

672 

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

674 ``COST_REPORT_BUCKET_PARAMETER`` names the SSM parameter the monitoring 

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

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

677 touches AWS. 

678 """ 

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

680 if not region: 

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

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

683 bucket_locator: CostReportBucketLocator | None = None 

684 if bucket is None: 

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

686 if not parameter_name: 

687 raise RuntimeError( 

688 "COST_REPORT_BUCKET or COST_REPORT_BUCKET_PARAMETER environment variable is required" 

689 ) 

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

691 bucket_locator = CostReportBucketLocator(parameter_name, parameter_region) 

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

693 base_url = os.getenv( 

694 "OPENCOST_BASE_URL", 

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

696 ) 

697 try: 

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

699 except ValueError: 

700 interval = 60 

701 return CostMonitor( 

702 region=region, 

703 cluster=cluster, 

704 bucket=bucket, 

705 bucket_locator=bucket_locator, 

706 opencost=OpenCostClient(base_url), 

707 report_interval_minutes=interval, 

708 )