cloudshellgpt 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cloudshellgpt/cost.py ADDED
@@ -0,0 +1,443 @@
1
+ """Cost tracker — tracks estimated costs of resources created during a session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import UTC, datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ import boto3
11
+ import yaml
12
+ from botocore.config import Config as BotoConfig
13
+ from botocore.exceptions import ClientError
14
+ from pydantic import BaseModel, Field
15
+ from rich.console import Console
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Custom exception
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ class CostError(Exception):
25
+ """Raised when cost estimation fails or cannot be completed.
26
+
27
+ Attributes:
28
+ message: Human-readable description of the cost estimation failure.
29
+ service: The AWS service that was being estimated, if applicable.
30
+ """
31
+
32
+ def __init__(self, message: str, *, service: str | None = None) -> None:
33
+ self.message = message
34
+ self.service = service
35
+ super().__init__(message)
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Data model
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ class CostEstimate(BaseModel):
44
+ """Result of a cost estimation for an AWS command.
45
+
46
+ Contains all information needed by the safety layer to decide whether to
47
+ alert the user based on the max_cost_alert threshold.
48
+ """
49
+
50
+ status: Literal["estimated", "unknown"] = Field(
51
+ description="Estimation status — 'unknown' when Cost Explorer API fails"
52
+ )
53
+ estimated_monthly_cost: float = Field(description="Estimated monthly cost in USD")
54
+ currency: str = Field(default="USD")
55
+ cost_breakdown: dict[str, float] = Field(
56
+ default_factory=dict,
57
+ description="Breakdown by component (e.g., {'EC2 hourly': 45.0, 'EBS storage': 12.0})",
58
+ )
59
+ warnings: list[str] = Field(
60
+ default_factory=list,
61
+ description="Warnings such as 'exceeds max_cost_alert threshold'",
62
+ )
63
+ confidence: Literal["high", "medium", "low"] = Field(
64
+ description="How confident the estimate is"
65
+ )
66
+ service: str = Field(description="The AWS service being estimated")
67
+ command: str = Field(description="The command that triggered the estimate")
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Constants
72
+ # ---------------------------------------------------------------------------
73
+
74
+ # Maps AWS CLI service names to Cost Explorer SERVICE dimension values
75
+ _CLI_TO_CE_SERVICE: dict[str, str] = {
76
+ "ec2": "Amazon Elastic Compute Cloud - Compute",
77
+ "rds": "Amazon Relational Database Service",
78
+ "s3": "Amazon Simple Storage Service",
79
+ "s3api": "Amazon Simple Storage Service",
80
+ "lambda": "AWS Lambda",
81
+ "dynamodb": "Amazon DynamoDB",
82
+ "ecs": "Amazon Elastic Container Service",
83
+ "eks": "Amazon Elastic Kubernetes Service",
84
+ "elb": "Elastic Load Balancing",
85
+ "elbv2": "Elastic Load Balancing",
86
+ "cloudfront": "Amazon CloudFront",
87
+ "sqs": "Amazon Simple Queue Service",
88
+ "sns": "Amazon Simple Notification Service",
89
+ "kinesis": "Amazon Kinesis",
90
+ "redshift": "Amazon Redshift",
91
+ "elasticache": "Amazon ElastiCache",
92
+ "opensearch": "Amazon OpenSearch Service",
93
+ "sagemaker": "Amazon SageMaker",
94
+ "bedrock": "Amazon Bedrock",
95
+ "glue": "AWS Glue",
96
+ "emr": "Amazon Elastic MapReduce",
97
+ "kms": "AWS Key Management Service",
98
+ "secretsmanager": "AWS Secrets Manager",
99
+ "route53": "Amazon Route 53",
100
+ "cloudwatch": "Amazon CloudWatch",
101
+ "logs": "Amazon CloudWatch",
102
+ "iam": "AWS Identity and Access Management",
103
+ "vpc": "Amazon Virtual Private Cloud",
104
+ }
105
+
106
+ # Verbs that indicate resource creation (billable operations)
107
+ _RESOURCE_CREATING_VERBS: set[str] = {
108
+ "create",
109
+ "run",
110
+ "launch",
111
+ "put",
112
+ "start",
113
+ "allocate",
114
+ "provision",
115
+ "deploy",
116
+ "enable",
117
+ "register",
118
+ }
119
+
120
+ # Read-only verbs that cost $0
121
+ _READ_ONLY_VERBS: set[str] = {
122
+ "list",
123
+ "ls",
124
+ "describe",
125
+ "get",
126
+ "head",
127
+ "wait",
128
+ "show",
129
+ "check",
130
+ "lookup",
131
+ "search",
132
+ "scan",
133
+ }
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # CostEstimator
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ class CostEstimator:
142
+ """Estimates costs of AWS CLI commands using the AWS Cost Explorer API.
143
+
144
+ Queries historical and forecasted costs from Cost Explorer to provide
145
+ a pre-execution cost estimate. Works independently and can be consumed
146
+ by the safety layer.
147
+
148
+ Args:
149
+ region: AWS region for the Cost Explorer client.
150
+ max_cost_alert: USD threshold above which a budget warning is added.
151
+ """
152
+
153
+ def __init__(self, region: str = "us-east-1", max_cost_alert: int = 100) -> None:
154
+ """Initialize the CostEstimator.
155
+
156
+ Args:
157
+ region: AWS region for the Cost Explorer client.
158
+ max_cost_alert: USD threshold for cost alert warnings.
159
+ """
160
+ self.region = region
161
+ self.max_cost_alert = max_cost_alert
162
+ self._client = boto3.client(
163
+ "ce",
164
+ region_name=self.region,
165
+ config=BotoConfig(
166
+ connect_timeout=5,
167
+ read_timeout=10,
168
+ retries={"max_attempts": 2, "mode": "standard"},
169
+ ),
170
+ )
171
+
172
+ def estimate(self, command: str) -> CostEstimate:
173
+ """Estimate the monthly cost of an AWS CLI command before execution.
174
+
175
+ Args:
176
+ command: The full AWS CLI command string (e.g., "aws ec2 run-instances ...").
177
+
178
+ Returns:
179
+ CostEstimate with populated cost data and warnings.
180
+ """
181
+ service = self._detect_service(command)
182
+
183
+ # Read-only commands cost nothing
184
+ if not self._is_resource_creating(command):
185
+ return CostEstimate(
186
+ status="estimated",
187
+ estimated_monthly_cost=0.0,
188
+ currency="USD",
189
+ cost_breakdown={},
190
+ warnings=[],
191
+ confidence="high",
192
+ service=service,
193
+ command=command,
194
+ )
195
+
196
+ # Query Cost Explorer for resource-creating commands
197
+ try:
198
+ historical = self._get_historical_cost(service)
199
+ forecast = self._get_cost_forecast(service)
200
+
201
+ # Use forecast if available, fall back to historical
202
+ estimated_cost = forecast if forecast > 0.0 else historical
203
+ confidence: Literal["high", "medium", "low"] = (
204
+ "medium" if estimated_cost > 0.0 else "low"
205
+ )
206
+
207
+ cost_breakdown: dict[str, float] = {}
208
+ if historical > 0.0:
209
+ cost_breakdown[f"{service} (historical avg)"] = historical
210
+ if forecast > 0.0:
211
+ cost_breakdown[f"{service} (forecast)"] = forecast
212
+
213
+ warnings: list[str] = []
214
+ if estimated_cost > self.max_cost_alert:
215
+ warnings.append(
216
+ f"Estimated cost ${estimated_cost:.2f}/month exceeds "
217
+ f"max_cost_alert threshold (${self.max_cost_alert})"
218
+ )
219
+
220
+ return CostEstimate(
221
+ status="estimated",
222
+ estimated_monthly_cost=round(estimated_cost, 2),
223
+ currency="USD",
224
+ cost_breakdown=cost_breakdown,
225
+ warnings=warnings,
226
+ confidence=confidence,
227
+ service=service,
228
+ command=command,
229
+ )
230
+
231
+ except Exception as exc:
232
+ logger.debug("Cost Explorer API failed: %s", exc)
233
+ return CostEstimate(
234
+ status="unknown",
235
+ estimated_monthly_cost=0.0,
236
+ currency="USD",
237
+ cost_breakdown={},
238
+ warnings=[f"Cost estimation unavailable: {exc}"],
239
+ confidence="low",
240
+ service=service,
241
+ command=command,
242
+ )
243
+
244
+ def _detect_service(self, command: str) -> str:
245
+ """Extract the AWS service name from an AWS CLI command.
246
+
247
+ Args:
248
+ command: The full AWS CLI command string.
249
+
250
+ Returns:
251
+ The detected service name (e.g., "ec2", "s3").
252
+ """
253
+ parts = command.strip().split()
254
+ # Expected format: aws <service> <subcommand> [args...]
255
+ for i, part in enumerate(parts):
256
+ if part == "aws" and i + 1 < len(parts):
257
+ return parts[i + 1]
258
+ # Fallback: return "unknown" if we can't detect the service
259
+ return "unknown"
260
+
261
+ def _is_resource_creating(self, command: str) -> bool:
262
+ """Check if a command creates billable resources.
263
+
264
+ Args:
265
+ command: The full AWS CLI command string.
266
+
267
+ Returns:
268
+ True if the command is expected to create billable resources.
269
+ """
270
+ parts = command.strip().lower().split()
271
+ # Expected format: aws <service> <subcommand> [args...]
272
+ # Extract subcommand parts (skip "aws" and the service name)
273
+ subcommand_parts = [p for p in parts[2:] if not p.startswith("-")]
274
+
275
+ if not subcommand_parts:
276
+ return False
277
+
278
+ # Check the subcommand (e.g., "run-instances", "create-bucket", "ls")
279
+ subcommand = subcommand_parts[0]
280
+
281
+ # Check read-only verbs first (more specific)
282
+ for verb in _READ_ONLY_VERBS:
283
+ if subcommand == verb or subcommand.startswith(f"{verb}-"):
284
+ return False
285
+
286
+ # Check resource-creating verbs
287
+ for verb in _RESOURCE_CREATING_VERBS:
288
+ if subcommand == verb or subcommand.startswith(f"{verb}-"):
289
+ return True
290
+
291
+ # Default: not creating if verb is unrecognized
292
+ return False
293
+
294
+ def _get_historical_cost(self, service: str) -> float:
295
+ """Query Cost Explorer for historical costs of a service over the last 30 days.
296
+
297
+ Args:
298
+ service: The AWS CLI service name (e.g., "ec2").
299
+
300
+ Returns:
301
+ The average monthly cost for the service.
302
+
303
+ Raises:
304
+ CostError: If the API call fails with a client error.
305
+ """
306
+ ce_service = self._map_cli_service_to_ce_service(service)
307
+ now = datetime.now(tz=UTC)
308
+ start = (now - timedelta(days=30)).strftime("%Y-%m-%d")
309
+ end = now.strftime("%Y-%m-%d")
310
+
311
+ try:
312
+ response = self._client.get_cost_and_usage(
313
+ TimePeriod={"Start": start, "End": end},
314
+ Granularity="MONTHLY",
315
+ Metrics=["UnblendedCost"],
316
+ Filter={
317
+ "Dimensions": {
318
+ "Key": "SERVICE",
319
+ "Values": [ce_service],
320
+ }
321
+ },
322
+ )
323
+ except ClientError as exc:
324
+ raise CostError(
325
+ f"Failed to get historical cost for {service}: {exc}",
326
+ service=service,
327
+ ) from exc
328
+
329
+ # Sum costs from all result periods
330
+ total = 0.0
331
+ for result in response.get("ResultsByTime", []):
332
+ amount_str = result.get("Total", {}).get("UnblendedCost", {}).get("Amount", "0")
333
+ total += float(amount_str)
334
+
335
+ return round(total, 2)
336
+
337
+ def _get_cost_forecast(self, service: str) -> float:
338
+ """Query Cost Explorer for forecasted costs of a service for the next 30 days.
339
+
340
+ Args:
341
+ service: The AWS CLI service name (e.g., "ec2").
342
+
343
+ Returns:
344
+ The forecasted monthly cost for the service.
345
+
346
+ Raises:
347
+ CostError: If the API call fails with a client error.
348
+ """
349
+ ce_service = self._map_cli_service_to_ce_service(service)
350
+ now = datetime.now(tz=UTC)
351
+ # Forecast starts tomorrow (CE requires future dates)
352
+ start = (now + timedelta(days=1)).strftime("%Y-%m-%d")
353
+ end = (now + timedelta(days=30)).strftime("%Y-%m-%d")
354
+
355
+ try:
356
+ response = self._client.get_cost_forecast(
357
+ TimePeriod={"Start": start, "End": end},
358
+ Metric="UNBLENDED_COST",
359
+ Granularity="MONTHLY",
360
+ Filter={
361
+ "Dimensions": {
362
+ "Key": "SERVICE",
363
+ "Values": [ce_service],
364
+ }
365
+ },
366
+ )
367
+ except ClientError as exc:
368
+ # Forecast may not be available for all services
369
+ logger.warning("Cost forecast unavailable for %s: %s", service, exc)
370
+ return 0.0
371
+
372
+ # Extract total forecast amount
373
+ total_str = response.get("Total", {}).get("Amount", "0")
374
+ return round(float(total_str), 2)
375
+
376
+ def _map_cli_service_to_ce_service(self, cli_service: str) -> str:
377
+ """Map an AWS CLI service name to a Cost Explorer SERVICE dimension value.
378
+
379
+ Args:
380
+ cli_service: The CLI service name (e.g., "ec2", "s3").
381
+
382
+ Returns:
383
+ The Cost Explorer service dimension value.
384
+ """
385
+ return _CLI_TO_CE_SERVICE.get(cli_service, cli_service)
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # CostTracker
390
+ # ---------------------------------------------------------------------------
391
+
392
+
393
+ class CostTracker:
394
+ """Tracks estimated AWS costs of resources created in a session."""
395
+
396
+ DEFAULT_PATH = Path.home() / ".csgpt" / "session_costs.yaml"
397
+
398
+ def __init__(self, session_path: Path | None = None) -> None:
399
+ self.session_path = session_path or self.DEFAULT_PATH
400
+ self.session_path.parent.mkdir(parents=True, exist_ok=True)
401
+ self.console = Console()
402
+
403
+ def track(self, command: str, estimated_cost: str) -> None:
404
+ """Track a new cost item."""
405
+ costs = self._load()
406
+ costs.append(
407
+ {
408
+ "timestamp": datetime.utcnow().isoformat(),
409
+ "command": command[:200], # Truncate
410
+ "estimated_cost": estimated_cost,
411
+ }
412
+ )
413
+ self._save(costs)
414
+
415
+ def session_summary(self) -> str:
416
+ """Return a human-readable summary of session costs."""
417
+ costs = self._load()
418
+ if not costs:
419
+ return "[dim]No costs tracked in this session.[/dim]"
420
+
421
+ lines = [f"[bold]Session costs ({len(costs)} operations):[/bold]\n"]
422
+ for c in costs:
423
+ lines.append(f" • {c['estimated_cost']:>10} — {c['command'][:80]}")
424
+
425
+ return "\n".join(lines)
426
+
427
+ def _load(self) -> list[dict[str, Any]]:
428
+ """Load costs from disk."""
429
+ if not self.session_path.exists():
430
+ return []
431
+ with self.session_path.open() as f:
432
+ data = yaml.safe_load(f) or []
433
+ return data
434
+
435
+ def _save(self, costs: list[dict[str, Any]]) -> None:
436
+ """Save costs to disk."""
437
+ with self.session_path.open("w") as f:
438
+ yaml.safe_dump(costs, f, default_flow_style=False)
439
+
440
+ def clear(self) -> None:
441
+ """Clear the session cost log."""
442
+ if self.session_path.exists():
443
+ self.session_path.unlink()