Coverage for cli/cost_analytics.py: 95.76%
131 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""Athena-backed cost analytics for the GCO CLI.
3Queries the cost allocation data the per-region cost-monitor services write
4to the central cost report bucket (Hive-partitioned Parquet), through the
5Glue table + Athena workgroup the monitoring stack provisions. Query results
6land in the workgroup-enforced ``athena-results/`` prefix, KMS-encrypted and
7lifecycle-expired.
9All SQL is assembled from validated identifiers and integer bounds only —
10user-supplied strings (namespace filters) are passed through Athena
11``ExecutionParameters``, never interpolated.
12"""
14from __future__ import annotations
16import logging
17import re
18import time
19from dataclasses import dataclass
20from typing import Any
22import boto3
24from .config import GCOConfig, get_config
26logger = logging.getLogger(__name__)
28_DEFAULT_POLL_SECONDS = 1.0
29_DEFAULT_TIMEOUT_SECONDS = 120.0
30_MAX_RESULT_ROWS = 1_000
32_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
34_GRANULARITY_EXPRESSIONS = {
35 "daily": "date",
36 "hourly": "date_format(window_start, '%Y-%m-%dT%H:00Z')",
37}
39_TOP_GROUPINGS = {
40 "namespace": "namespace",
41 "region": "region",
42 "cluster": "cluster",
43}
46class AthenaQueryError(RuntimeError):
47 """Raised when an Athena query fails, is cancelled, or times out."""
50def _database_name(project_name: str) -> str:
51 """Glue database name — must mirror gco.stacks.constants.cost_glue_database_name."""
52 return f"{project_name.replace('-', '_')}_cost"
55def _workgroup_name(project_name: str) -> str:
56 """Athena workgroup — must mirror gco.stacks.constants.cost_athena_workgroup_name."""
57 return f"{project_name}-cost"
60@dataclass
61class QueryResult:
62 """Column names plus row dictionaries for one executed query."""
64 columns: list[str]
65 rows: list[dict[str, Any]]
66 query_execution_id: str
69class CostAnalytics:
70 """Runs canned cost aggregation queries through Athena."""
72 def __init__(
73 self,
74 config: GCOConfig | None = None,
75 *,
76 athena_client: Any | None = None,
77 region: str | None = None,
78 poll_seconds: float = _DEFAULT_POLL_SECONDS,
79 timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
80 ) -> None:
81 self._config = config or get_config()
82 self.project_name = self._config.project_name
83 # Athena + Glue live in the monitoring region.
84 self.region = region or self._monitoring_region()
85 self.database = _database_name(self.project_name)
86 self.workgroup = _workgroup_name(self.project_name)
87 self.table = "allocation_reports"
88 self.poll_seconds = poll_seconds
89 self.timeout_seconds = timeout_seconds
90 self._athena = athena_client or boto3.client("athena", region_name=self.region)
92 def _monitoring_region(self) -> str:
93 from .config import _load_cdk_json
95 regions = _load_cdk_json() or {}
96 monitoring = regions.get("monitoring")
97 if isinstance(monitoring, str) and monitoring:
98 return monitoring
99 return str(self._config.default_region)
101 # ------------------------------------------------------------------
102 # Query execution plumbing
103 # ------------------------------------------------------------------
105 def run_query(self, sql: str, parameters: list[str] | None = None) -> QueryResult:
106 """Execute one query in the cost workgroup and return decoded rows."""
107 start_kwargs: dict[str, Any] = {
108 "QueryString": sql,
109 "QueryExecutionContext": {"Database": self.database},
110 "WorkGroup": self.workgroup,
111 }
112 if parameters:
113 start_kwargs["ExecutionParameters"] = parameters
114 try:
115 start = self._athena.start_query_execution(**start_kwargs)
116 except Exception as exc: # noqa: BLE001 - boto error shapes vary
117 raise AthenaQueryError(
118 f"Failed to start Athena query (workgroup {self.workgroup}, "
119 f"database {self.database}): {exc}"
120 ) from exc
122 execution_id = str(start["QueryExecutionId"])
123 deadline = time.monotonic() + self.timeout_seconds
124 while True:
125 execution = self._athena.get_query_execution(QueryExecutionId=execution_id)
126 state = execution["QueryExecution"]["Status"]["State"]
127 if state == "SUCCEEDED":
128 break
129 if state in {"FAILED", "CANCELLED"}:
130 reason = execution["QueryExecution"]["Status"].get(
131 "StateChangeReason", "no reason provided"
132 )
133 raise AthenaQueryError(f"Athena query {state.lower()}: {reason}")
134 if time.monotonic() >= deadline:
135 self._athena.stop_query_execution(QueryExecutionId=execution_id)
136 raise AthenaQueryError(f"Athena query timed out after {self.timeout_seconds:.0f}s")
137 time.sleep(self.poll_seconds)
139 return self._collect_results(execution_id)
141 def _collect_results(self, execution_id: str) -> QueryResult:
142 paginator = self._athena.get_paginator("get_query_results")
143 columns: list[str] = []
144 rows: list[dict[str, Any]] = []
145 header_skipped = False
146 for page in paginator.paginate(QueryExecutionId=execution_id):
147 metadata = page.get("ResultSet", {}).get("ResultSetMetadata", {})
148 if not columns: 148 ↛ 150line 148 didn't jump to line 150 because the condition on line 148 was always true
149 columns = [str(column["Name"]) for column in metadata.get("ColumnInfo", [])]
150 for record in page.get("ResultSet", {}).get("Rows", []):
151 values = [entry.get("VarCharValue") for entry in record.get("Data", [])]
152 if not header_skipped:
153 # The first row of the first page repeats the header.
154 header_skipped = True
155 if values == columns: 155 ↛ 157line 155 didn't jump to line 157 because the condition on line 155 was always true
156 continue
157 rows.append(dict(zip(columns, values, strict=False)))
158 if len(rows) >= _MAX_RESULT_ROWS: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 return QueryResult(columns, rows, execution_id)
160 return QueryResult(columns, rows, execution_id)
162 def _qualified_table(self) -> str:
163 for identifier in (self.database, self.table):
164 if not _IDENTIFIER_PATTERN.fullmatch(identifier): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 raise AthenaQueryError(f"Invalid Athena identifier: {identifier!r}")
166 return f'"{self.database}"."{self.table}"'
168 @staticmethod
169 def _days_clause(days: int) -> str:
170 bounded = min(max(int(days), 1), 3_650)
171 return f"date >= date_format(date_add('day', -{bounded}, current_date), '%Y-%m-%d')"
173 # ------------------------------------------------------------------
174 # Canned aggregations
175 # ------------------------------------------------------------------
177 def cost_by_namespace(self, days: int = 7, region: str | None = None) -> QueryResult:
178 """Total cost per namespace across all regions (optionally one region)."""
179 table = self._qualified_table()
180 clauses = [self._days_clause(days)]
181 parameters: list[str] = []
182 if region:
183 clauses.append("region = ?")
184 parameters.append(region)
185 where = " AND ".join(clauses)
186 sql = (
187 "SELECT namespace, "
188 "round(sum(total_cost), 4) AS total_cost, "
189 "round(sum(cpu_cost), 4) AS cpu_cost, "
190 "round(sum(ram_cost), 4) AS ram_cost, "
191 "round(sum(gpu_cost), 4) AS gpu_cost, "
192 "round(sum(pv_cost), 4) AS pv_cost "
193 f"FROM {table} WHERE {where} " # nosec B608 - identifiers regex-validated, values via ExecutionParameters
194 "GROUP BY namespace ORDER BY total_cost DESC"
195 )
196 return self.run_query(sql, parameters or None)
198 def cost_by_region(self, days: int = 7) -> QueryResult:
199 """Total Kubernetes allocation cost per deployment region."""
200 table = self._qualified_table()
201 sql = (
202 "SELECT region, "
203 "round(sum(total_cost), 4) AS total_cost, "
204 "round(sum(cpu_cost), 4) AS cpu_cost, "
205 "round(sum(ram_cost), 4) AS ram_cost, "
206 "round(sum(gpu_cost), 4) AS gpu_cost "
207 f"FROM {table} WHERE {self._days_clause(days)} " # nosec B608 - identifiers regex-validated, days bounded int
208 "GROUP BY region ORDER BY total_cost DESC"
209 )
210 return self.run_query(sql)
212 def cost_over_time(
213 self,
214 days: int = 14,
215 granularity: str = "daily",
216 namespace: str | None = None,
217 ) -> QueryResult:
218 """Cost trend bucketed by day or hour, optionally for one namespace."""
219 bucket = _GRANULARITY_EXPRESSIONS.get(granularity)
220 if bucket is None:
221 raise AthenaQueryError(f"granularity must be one of {sorted(_GRANULARITY_EXPRESSIONS)}")
222 table = self._qualified_table()
223 clauses = [self._days_clause(days)]
224 parameters: list[str] = []
225 if namespace:
226 clauses.append("namespace = ?")
227 parameters.append(namespace)
228 where = " AND ".join(clauses)
229 sql = (
230 f"SELECT {bucket} AS period, "
231 "round(sum(total_cost), 4) AS total_cost "
232 f"FROM {table} WHERE {where} " # nosec B608 - bucket from a fixed mapping, identifiers regex-validated, values via ExecutionParameters
233 "GROUP BY 1 ORDER BY 1"
234 )
235 return self.run_query(sql, parameters or None)
237 def top_spenders(self, n: int = 10, by: str = "namespace", days: int = 7) -> QueryResult:
238 """Top-N spenders grouped by namespace, region, or cluster."""
239 grouping = _TOP_GROUPINGS.get(by)
240 if grouping is None:
241 raise AthenaQueryError(f"by must be one of {sorted(_TOP_GROUPINGS)}")
242 bounded_n = min(max(int(n), 1), 100)
243 table = self._qualified_table()
244 sql = (
245 f"SELECT {grouping}, "
246 "round(sum(total_cost), 4) AS total_cost "
247 f"FROM {table} WHERE {self._days_clause(days)} " # nosec B608 - grouping from a fixed mapping, identifiers regex-validated, n/days bounded ints
248 f"GROUP BY {grouping} ORDER BY total_cost DESC LIMIT {bounded_n}"
249 )
250 return self.run_query(sql)
253def get_cost_analytics(config: GCOConfig | None = None) -> CostAnalytics:
254 """Factory function for CostAnalytics."""
255 return CostAnalytics(config=config)