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/__init__.py +11 -0
- cloudshellgpt/audit.py +175 -0
- cloudshellgpt/bedrock_translator.py +525 -0
- cloudshellgpt/cli.py +612 -0
- cloudshellgpt/config.py +296 -0
- cloudshellgpt/cost.py +443 -0
- cloudshellgpt/executor.py +382 -0
- cloudshellgpt/formatter.py +328 -0
- cloudshellgpt/i18n.py +203 -0
- cloudshellgpt/intent.py +1080 -0
- cloudshellgpt/learning.py +969 -0
- cloudshellgpt/mcp_server.py +264 -0
- cloudshellgpt/safety.py +952 -0
- cloudshellgpt-1.0.0.dist-info/METADATA +426 -0
- cloudshellgpt-1.0.0.dist-info/RECORD +18 -0
- cloudshellgpt-1.0.0.dist-info/WHEEL +4 -0
- cloudshellgpt-1.0.0.dist-info/entry_points.txt +2 -0
- cloudshellgpt-1.0.0.dist-info/licenses/LICENSE +198 -0
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()
|