cloudcost-cli 0.1.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.
- backend/__init__.py +1 -0
- backend/app/__init__.py +1 -0
- backend/app/auth.py +104 -0
- backend/app/cli.py +726 -0
- backend/app/comments.py +94 -0
- backend/app/config.py +191 -0
- backend/app/database.py +470 -0
- backend/app/emailer.py +157 -0
- backend/app/github_client.py +197 -0
- backend/app/infracost.py +129 -0
- backend/app/litellm_admin.py +41 -0
- backend/app/main.py +833 -0
- backend/app/model_pricing.py +80 -0
- backend/app/security.py +15 -0
- backend/app/storage.py +31 -0
- backend/app/usage.py +73 -0
- cloudcost_cli-0.1.0.dist-info/METADATA +340 -0
- cloudcost_cli-0.1.0.dist-info/RECORD +21 -0
- cloudcost_cli-0.1.0.dist-info/WHEEL +5 -0
- cloudcost_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cloudcost_cli-0.1.0.dist-info/top_level.txt +1 -0
backend/app/cli.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import webbrowser
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from backend.app.comments import format_money, to_decimal
|
|
16
|
+
from backend.app.config import Settings
|
|
17
|
+
from backend.app.infracost import InfracostClient, InfracostEstimate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
VERSION = "0.1.0"
|
|
21
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".cloudcost" / "config.json"
|
|
22
|
+
DEFAULT_BACKEND_URL = "https://cloudcost.live"
|
|
23
|
+
DEFAULT_PLAN_NAMES = ("tfplan.json", "plan.json", "terraform-plan.json", "infracost-plan.json")
|
|
24
|
+
SKIPPED_DISCOVERY_DIRS = {
|
|
25
|
+
".cloudcost",
|
|
26
|
+
".git",
|
|
27
|
+
".terraform",
|
|
28
|
+
".venv",
|
|
29
|
+
"__pycache__",
|
|
30
|
+
"dist",
|
|
31
|
+
"node_modules",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ResourceCost:
|
|
37
|
+
name: str
|
|
38
|
+
resource_type: str
|
|
39
|
+
monthly_cost: Any
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def config_path() -> Path:
|
|
43
|
+
raw_path = os.environ.get("CLOUDCOST_CONFIG")
|
|
44
|
+
return Path(raw_path).expanduser() if raw_path else DEFAULT_CONFIG_PATH
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_config() -> dict[str, Any]:
|
|
48
|
+
path = config_path()
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return {}
|
|
51
|
+
try:
|
|
52
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
53
|
+
except (OSError, json.JSONDecodeError):
|
|
54
|
+
return {}
|
|
55
|
+
return payload if isinstance(payload, dict) else {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_config(payload: dict[str, Any]) -> Path:
|
|
59
|
+
path = config_path()
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
62
|
+
return path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_backend_url(value: str) -> str:
|
|
66
|
+
value = value.strip()
|
|
67
|
+
if not value:
|
|
68
|
+
return DEFAULT_BACKEND_URL
|
|
69
|
+
if not value.startswith(("http://", "https://")):
|
|
70
|
+
value = "https://" + value
|
|
71
|
+
return value.rstrip("/")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def backend_url_from_args(args: argparse.Namespace) -> str:
|
|
75
|
+
config = read_config()
|
|
76
|
+
raw_url = (
|
|
77
|
+
getattr(args, "backend_url", None)
|
|
78
|
+
or config.get("backend_url")
|
|
79
|
+
or os.environ.get("CLOUDCOST_BACKEND_URL")
|
|
80
|
+
or os.environ.get("PUBLIC_BASE_URL")
|
|
81
|
+
or DEFAULT_BACKEND_URL
|
|
82
|
+
)
|
|
83
|
+
return normalize_backend_url(str(raw_url))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def github_install_url(args: argparse.Namespace) -> str:
|
|
87
|
+
return f"{backend_url_from_args(args)}/install/github"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def settings_from_args(args: argparse.Namespace) -> Settings:
|
|
91
|
+
config = read_config()
|
|
92
|
+
base = Settings()
|
|
93
|
+
updates: dict[str, Any] = {}
|
|
94
|
+
|
|
95
|
+
infracost_mode = getattr(args, "infracost_mode", None) or config.get("infracost_mode")
|
|
96
|
+
infracost_cli_path = getattr(args, "infracost_cli_path", None) or config.get("infracost_cli_path")
|
|
97
|
+
pricing_api_endpoint = getattr(args, "pricing_api_endpoint", None) or config.get("pricing_api_endpoint")
|
|
98
|
+
infracost_api_key = getattr(args, "infracost_api_key", None) or config.get("infracost_api_key")
|
|
99
|
+
|
|
100
|
+
if infracost_mode:
|
|
101
|
+
updates["infracost_mode"] = infracost_mode
|
|
102
|
+
if infracost_cli_path:
|
|
103
|
+
updates["infracost_cli_path"] = infracost_cli_path
|
|
104
|
+
if pricing_api_endpoint:
|
|
105
|
+
updates["infracost_pricing_api_endpoint"] = pricing_api_endpoint
|
|
106
|
+
if infracost_api_key:
|
|
107
|
+
updates["infracost_api_key"] = infracost_api_key
|
|
108
|
+
|
|
109
|
+
return base.model_copy(update=updates)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ordered_plan_names(settings: Settings) -> list[str]:
|
|
113
|
+
names: list[str] = []
|
|
114
|
+
for name in (*DEFAULT_PLAN_NAMES, *sorted(settings.plan_names)):
|
|
115
|
+
normalized = name.strip().lower()
|
|
116
|
+
if normalized and normalized not in names:
|
|
117
|
+
names.append(normalized)
|
|
118
|
+
return names
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_plan_json(path: Path, settings: Settings) -> bool:
|
|
122
|
+
name = path.name.lower()
|
|
123
|
+
return name in ordered_plan_names(settings) or name.endswith(settings.plan_suffixes)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def discover_plan_path(root: Path, settings: Settings, *, recursive: bool = False) -> Path | None:
|
|
127
|
+
root = root.expanduser().resolve()
|
|
128
|
+
if not root.exists() or not root.is_dir():
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
for name in ordered_plan_names(settings):
|
|
132
|
+
candidate = root / name
|
|
133
|
+
if candidate.is_file():
|
|
134
|
+
return candidate
|
|
135
|
+
|
|
136
|
+
direct_matches = sorted(
|
|
137
|
+
path for path in root.iterdir() if path.is_file() and is_plan_json(path, settings)
|
|
138
|
+
)
|
|
139
|
+
if direct_matches:
|
|
140
|
+
return direct_matches[0]
|
|
141
|
+
|
|
142
|
+
if not recursive:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
146
|
+
dirnames[:] = [
|
|
147
|
+
dirname
|
|
148
|
+
for dirname in dirnames
|
|
149
|
+
if dirname not in SKIPPED_DISCOVERY_DIRS and not dirname.startswith(".terraform")
|
|
150
|
+
]
|
|
151
|
+
current = Path(dirpath)
|
|
152
|
+
for filename in sorted(filenames):
|
|
153
|
+
candidate = current / filename
|
|
154
|
+
if is_plan_json(candidate, settings):
|
|
155
|
+
return candidate
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_process(command: list[str], *, cwd: Path, timeout: int) -> subprocess.CompletedProcess[bytes]:
|
|
160
|
+
try:
|
|
161
|
+
return subprocess.run(
|
|
162
|
+
command,
|
|
163
|
+
cwd=str(cwd),
|
|
164
|
+
stdout=subprocess.PIPE,
|
|
165
|
+
stderr=subprocess.PIPE,
|
|
166
|
+
timeout=timeout,
|
|
167
|
+
check=False,
|
|
168
|
+
)
|
|
169
|
+
except FileNotFoundError as exc:
|
|
170
|
+
raise RuntimeError(f"Command not found: {command[0]}") from exc
|
|
171
|
+
except subprocess.TimeoutExpired as exc:
|
|
172
|
+
raise RuntimeError(f"Command timed out after {timeout} seconds: {' '.join(command)}") from exc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_github_remote_url(remote_url: str) -> str | None:
|
|
176
|
+
remote_url = remote_url.strip()
|
|
177
|
+
patterns = [
|
|
178
|
+
r"^git@github\.com:(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
|
|
179
|
+
r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$",
|
|
180
|
+
r"^ssh://git@github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$",
|
|
181
|
+
]
|
|
182
|
+
for pattern in patterns:
|
|
183
|
+
match = re.match(pattern, remote_url)
|
|
184
|
+
if match:
|
|
185
|
+
return f"{match.group('owner')}/{match.group('repo')}"
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def detect_github_repo(root: Path) -> str | None:
|
|
190
|
+
try:
|
|
191
|
+
process = run_process(
|
|
192
|
+
["git", "remote", "get-url", "origin"],
|
|
193
|
+
cwd=root.expanduser().resolve(),
|
|
194
|
+
timeout=10,
|
|
195
|
+
)
|
|
196
|
+
except RuntimeError:
|
|
197
|
+
return None
|
|
198
|
+
if process.returncode != 0:
|
|
199
|
+
return None
|
|
200
|
+
remote_url = process.stdout.decode("utf-8", errors="replace").strip()
|
|
201
|
+
return parse_github_remote_url(remote_url)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def raise_for_process(process: subprocess.CompletedProcess[bytes], label: str) -> None:
|
|
205
|
+
if process.returncode == 0:
|
|
206
|
+
return
|
|
207
|
+
stderr = process.stderr.decode("utf-8", errors="replace").strip()
|
|
208
|
+
stdout = process.stdout.decode("utf-8", errors="replace").strip()
|
|
209
|
+
detail = stderr or stdout or f"exit code {process.returncode}"
|
|
210
|
+
raise RuntimeError(f"{label} failed: {detail[:2000]}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def generate_terraform_plan_json(args: argparse.Namespace, root: Path) -> Path | None:
|
|
214
|
+
terraform_bin = getattr(args, "terraform_bin", None) or "terraform"
|
|
215
|
+
terraform_path = Path(terraform_bin)
|
|
216
|
+
if not terraform_path.exists() and shutil.which(terraform_bin) is None:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
root = root.expanduser().resolve()
|
|
220
|
+
output_dir = root / ".cloudcost"
|
|
221
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
binary_path = output_dir / "tfplan.binary"
|
|
223
|
+
json_path = output_dir / "tfplan.json"
|
|
224
|
+
timeout = int(getattr(args, "terraform_timeout", 600) or 600)
|
|
225
|
+
|
|
226
|
+
if not getattr(args, "skip_terraform_init", False) and not (root / ".terraform").exists():
|
|
227
|
+
print("No .terraform directory found. Running terraform init...", file=sys.stderr)
|
|
228
|
+
init_process = run_process(
|
|
229
|
+
[terraform_bin, "init", "-input=false"],
|
|
230
|
+
cwd=root,
|
|
231
|
+
timeout=timeout,
|
|
232
|
+
)
|
|
233
|
+
raise_for_process(init_process, "terraform init")
|
|
234
|
+
|
|
235
|
+
print("Generating Terraform plan JSON...", file=sys.stderr)
|
|
236
|
+
plan_process = run_process(
|
|
237
|
+
[terraform_bin, "plan", "-input=false", "-out", str(binary_path)],
|
|
238
|
+
cwd=root,
|
|
239
|
+
timeout=timeout,
|
|
240
|
+
)
|
|
241
|
+
raise_for_process(plan_process, "terraform plan")
|
|
242
|
+
|
|
243
|
+
show_process = run_process(
|
|
244
|
+
[terraform_bin, "show", "-json", str(binary_path)],
|
|
245
|
+
cwd=root,
|
|
246
|
+
timeout=timeout,
|
|
247
|
+
)
|
|
248
|
+
raise_for_process(show_process, "terraform show")
|
|
249
|
+
json_path.write_bytes(show_process.stdout)
|
|
250
|
+
return json_path
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def extract_resource_costs(payload: dict[str, Any]) -> list[ResourceCost]:
|
|
254
|
+
resources: list[ResourceCost] = []
|
|
255
|
+
projects = payload.get("projects")
|
|
256
|
+
if not isinstance(projects, list):
|
|
257
|
+
projects = [payload]
|
|
258
|
+
|
|
259
|
+
for project in projects:
|
|
260
|
+
if not isinstance(project, dict):
|
|
261
|
+
continue
|
|
262
|
+
breakdown = project.get("breakdown")
|
|
263
|
+
candidates = []
|
|
264
|
+
if isinstance(breakdown, dict):
|
|
265
|
+
candidates.extend(breakdown.get("resources") or [])
|
|
266
|
+
candidates.extend(project.get("resources") or [])
|
|
267
|
+
|
|
268
|
+
for resource in candidates:
|
|
269
|
+
if not isinstance(resource, dict):
|
|
270
|
+
continue
|
|
271
|
+
name = str(
|
|
272
|
+
resource.get("name")
|
|
273
|
+
or resource.get("resourceName")
|
|
274
|
+
or resource.get("address")
|
|
275
|
+
or resource.get("resourceType")
|
|
276
|
+
or "unknown"
|
|
277
|
+
)
|
|
278
|
+
resource_type = str(resource.get("resourceType") or resource.get("type") or "")
|
|
279
|
+
monthly_cost = (
|
|
280
|
+
resource.get("monthlyCost")
|
|
281
|
+
or resource.get("totalMonthlyCost")
|
|
282
|
+
or resource.get("cost")
|
|
283
|
+
or resource.get("monthly_cost")
|
|
284
|
+
)
|
|
285
|
+
resources.append(
|
|
286
|
+
ResourceCost(
|
|
287
|
+
name=name,
|
|
288
|
+
resource_type=resource_type,
|
|
289
|
+
monthly_cost=monthly_cost,
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return resources
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def sort_resources(resources: list[ResourceCost]) -> list[ResourceCost]:
|
|
297
|
+
return sorted(
|
|
298
|
+
resources,
|
|
299
|
+
key=lambda item: abs(to_decimal(item.monthly_cost) or Decimal("0")),
|
|
300
|
+
reverse=True,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def estimate_summary(estimate: InfracostEstimate, plan_path: Path) -> dict[str, Any]:
|
|
305
|
+
resources = extract_resource_costs(estimate.raw)
|
|
306
|
+
return {
|
|
307
|
+
"plan": str(plan_path),
|
|
308
|
+
"previous_monthly_cost": estimate.past_total_monthly_cost,
|
|
309
|
+
"proposed_monthly_cost": estimate.total_monthly_cost,
|
|
310
|
+
"diff_total_monthly_cost": estimate.diff_total_monthly_cost,
|
|
311
|
+
"resources": [
|
|
312
|
+
{
|
|
313
|
+
"name": item.name,
|
|
314
|
+
"resource_type": item.resource_type,
|
|
315
|
+
"monthly_cost": item.monthly_cost,
|
|
316
|
+
}
|
|
317
|
+
for item in sort_resources(resources)
|
|
318
|
+
],
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def terminal_report(summary: dict[str, Any], limit: int = 12) -> str:
|
|
323
|
+
rows = summary["resources"][:limit]
|
|
324
|
+
lines = [
|
|
325
|
+
"CloudCost AI analyze",
|
|
326
|
+
"",
|
|
327
|
+
f"Plan: {summary['plan']}",
|
|
328
|
+
"",
|
|
329
|
+
"Monthly estimate",
|
|
330
|
+
"----------------",
|
|
331
|
+
f"Previous: {format_money(summary['previous_monthly_cost'])}",
|
|
332
|
+
f"Proposed: {format_money(summary['proposed_monthly_cost'])}",
|
|
333
|
+
f"Delta: {format_money(summary['diff_total_monthly_cost'], signed=True)}",
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
if rows:
|
|
337
|
+
lines.extend(["", "Largest resources", "-----------------"])
|
|
338
|
+
width = min(max(len(item["name"]) for item in rows), 56)
|
|
339
|
+
for item in rows:
|
|
340
|
+
name = item["name"]
|
|
341
|
+
if len(name) > width:
|
|
342
|
+
name = name[: width - 1] + "."
|
|
343
|
+
lines.append(f"{name:<{width}} {format_money(item['monthly_cost'])}")
|
|
344
|
+
else:
|
|
345
|
+
lines.extend(["", "No resource-level costs were returned."])
|
|
346
|
+
|
|
347
|
+
lines.extend(
|
|
348
|
+
[
|
|
349
|
+
"",
|
|
350
|
+
"Next steps",
|
|
351
|
+
"----------",
|
|
352
|
+
"Use this in CI, or run `cloudcost connect-github` so CloudCost comments on pull requests automatically.",
|
|
353
|
+
]
|
|
354
|
+
)
|
|
355
|
+
return "\n".join(lines)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def markdown_report(summary: dict[str, Any], limit: int = 12) -> str:
|
|
359
|
+
lines = [
|
|
360
|
+
"## CloudCost AI estimate",
|
|
361
|
+
"",
|
|
362
|
+
"| Metric | Estimate |",
|
|
363
|
+
"| --- | ---: |",
|
|
364
|
+
f"| Previous monthly cost | {format_money(summary['previous_monthly_cost'])} |",
|
|
365
|
+
f"| Proposed monthly cost | {format_money(summary['proposed_monthly_cost'])} |",
|
|
366
|
+
f"| Diff total monthly cost | {format_money(summary['diff_total_monthly_cost'], signed=True)} |",
|
|
367
|
+
"",
|
|
368
|
+
]
|
|
369
|
+
rows = summary["resources"][:limit]
|
|
370
|
+
if rows:
|
|
371
|
+
lines.extend(["| Resource | Monthly |", "| --- | ---: |"])
|
|
372
|
+
for item in rows:
|
|
373
|
+
lines.append(f"| `{item['name']}` | {format_money(item['monthly_cost'])} |")
|
|
374
|
+
lines.append("")
|
|
375
|
+
lines.append(f"Terraform plan JSON: `{summary['plan']}`")
|
|
376
|
+
return "\n".join(lines)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
async def run_analyze(args: argparse.Namespace) -> int:
|
|
380
|
+
settings = settings_from_args(args)
|
|
381
|
+
plan_value = getattr(args, "plan", None)
|
|
382
|
+
if plan_value:
|
|
383
|
+
plan_path = Path(plan_value).expanduser()
|
|
384
|
+
else:
|
|
385
|
+
plan_path = discover_plan_path(
|
|
386
|
+
Path(getattr(args, "path", ".")).expanduser(),
|
|
387
|
+
settings,
|
|
388
|
+
recursive=bool(getattr(args, "recursive", False)),
|
|
389
|
+
)
|
|
390
|
+
if plan_path is None:
|
|
391
|
+
print(
|
|
392
|
+
"No Terraform plan JSON found. Pass --plan, or run `cloudcost` to generate one automatically.",
|
|
393
|
+
file=sys.stderr,
|
|
394
|
+
)
|
|
395
|
+
return 1
|
|
396
|
+
|
|
397
|
+
if not plan_path.exists():
|
|
398
|
+
print(f"Plan file not found: {plan_path}", file=sys.stderr)
|
|
399
|
+
return 1
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
estimate = await InfracostClient(settings).estimate_plan_json(
|
|
403
|
+
plan_path.read_bytes(),
|
|
404
|
+
filename=plan_path.name,
|
|
405
|
+
)
|
|
406
|
+
except Exception as exc:
|
|
407
|
+
print(f"CloudCost could not estimate the plan: {exc}", file=sys.stderr)
|
|
408
|
+
return 1
|
|
409
|
+
|
|
410
|
+
summary = estimate_summary(estimate, plan_path)
|
|
411
|
+
|
|
412
|
+
if args.format == "json":
|
|
413
|
+
output = json.dumps(summary, indent=2)
|
|
414
|
+
elif args.format == "markdown":
|
|
415
|
+
output = markdown_report(summary, limit=args.limit)
|
|
416
|
+
else:
|
|
417
|
+
output = terminal_report(summary, limit=args.limit)
|
|
418
|
+
|
|
419
|
+
if args.out:
|
|
420
|
+
Path(args.out).expanduser().write_text(output + "\n", encoding="utf-8")
|
|
421
|
+
else:
|
|
422
|
+
print(output)
|
|
423
|
+
|
|
424
|
+
if args.fail_on_increase is not None:
|
|
425
|
+
diff = to_decimal(summary["diff_total_monthly_cost"]) or Decimal("0")
|
|
426
|
+
threshold = Decimal(str(args.fail_on_increase))
|
|
427
|
+
if diff > threshold:
|
|
428
|
+
print(
|
|
429
|
+
f"Cost increase {format_money(diff, signed=True)} exceeds threshold {format_money(threshold)}.",
|
|
430
|
+
file=sys.stderr,
|
|
431
|
+
)
|
|
432
|
+
return 2
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def run_one_command(args: argparse.Namespace) -> int:
|
|
437
|
+
settings = settings_from_args(args)
|
|
438
|
+
root = Path(getattr(args, "path", ".")).expanduser()
|
|
439
|
+
plan_value = getattr(args, "plan", None)
|
|
440
|
+
plan_path = Path(plan_value).expanduser() if plan_value else None
|
|
441
|
+
|
|
442
|
+
if plan_path is None:
|
|
443
|
+
plan_path = discover_plan_path(
|
|
444
|
+
root,
|
|
445
|
+
settings,
|
|
446
|
+
recursive=bool(getattr(args, "recursive", False)),
|
|
447
|
+
)
|
|
448
|
+
if plan_path:
|
|
449
|
+
print(f"Using Terraform plan JSON: {plan_path}", file=sys.stderr)
|
|
450
|
+
|
|
451
|
+
if plan_path is None and not getattr(args, "no_terraform", False):
|
|
452
|
+
try:
|
|
453
|
+
plan_path = generate_terraform_plan_json(args, root)
|
|
454
|
+
if plan_path:
|
|
455
|
+
print(f"Generated Terraform plan JSON: {plan_path}", file=sys.stderr)
|
|
456
|
+
except Exception as exc:
|
|
457
|
+
print(f"CloudCost could not generate a Terraform plan: {exc}", file=sys.stderr)
|
|
458
|
+
return 1
|
|
459
|
+
|
|
460
|
+
if plan_path is None:
|
|
461
|
+
print(
|
|
462
|
+
"No Terraform plan JSON found and Terraform could not be auto-run.",
|
|
463
|
+
file=sys.stderr,
|
|
464
|
+
)
|
|
465
|
+
print(
|
|
466
|
+
"From a Terraform project, run `cloudcost`, or pass an existing file with `cloudcost --plan tfplan.json`.",
|
|
467
|
+
file=sys.stderr,
|
|
468
|
+
)
|
|
469
|
+
return 1
|
|
470
|
+
|
|
471
|
+
args.plan = str(plan_path)
|
|
472
|
+
return await run_analyze(args)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def run_connect_github(args: argparse.Namespace) -> int:
|
|
476
|
+
install_url = github_install_url(args)
|
|
477
|
+
repo = getattr(args, "repo", None) or detect_github_repo(Path(getattr(args, "path", ".")))
|
|
478
|
+
|
|
479
|
+
print("CloudCost GitHub automation")
|
|
480
|
+
print("")
|
|
481
|
+
print(f"Install URL: {install_url}")
|
|
482
|
+
if repo:
|
|
483
|
+
print(f"Detected repository: {repo}")
|
|
484
|
+
print("When GitHub opens, choose that repository for installation.")
|
|
485
|
+
else:
|
|
486
|
+
print("When GitHub opens, choose the repository that should receive CloudCost PR comments.")
|
|
487
|
+
print("")
|
|
488
|
+
print("Permissions GitHub will ask for:")
|
|
489
|
+
print(" contents: read")
|
|
490
|
+
print(" metadata: read")
|
|
491
|
+
print(" issues: write")
|
|
492
|
+
print(" pull_requests: write")
|
|
493
|
+
print(" events: pull_request")
|
|
494
|
+
|
|
495
|
+
if getattr(args, "no_open", False):
|
|
496
|
+
return 0
|
|
497
|
+
|
|
498
|
+
if webbrowser.open(install_url):
|
|
499
|
+
print("")
|
|
500
|
+
print("Opened the GitHub App install flow in your browser.")
|
|
501
|
+
return 0
|
|
502
|
+
|
|
503
|
+
print("")
|
|
504
|
+
print("Could not open a browser automatically. Open the Install URL above.")
|
|
505
|
+
return 0
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
async def run_go(args: argparse.Namespace) -> int:
|
|
509
|
+
print("CloudCost go")
|
|
510
|
+
print("")
|
|
511
|
+
estimate_code = 0
|
|
512
|
+
if not getattr(args, "skip_estimate", False):
|
|
513
|
+
estimate_code = await run_one_command(args)
|
|
514
|
+
print("")
|
|
515
|
+
if estimate_code != 0:
|
|
516
|
+
print("Local estimate did not complete. You can still connect GitHub automation.")
|
|
517
|
+
if getattr(args, "require_estimate", False):
|
|
518
|
+
return estimate_code
|
|
519
|
+
|
|
520
|
+
return run_connect_github(args)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def run_setup(args: argparse.Namespace) -> int:
|
|
524
|
+
payload = read_config()
|
|
525
|
+
if args.backend_url:
|
|
526
|
+
payload["backend_url"] = args.backend_url.rstrip("/")
|
|
527
|
+
if args.pricing_api_endpoint:
|
|
528
|
+
payload["pricing_api_endpoint"] = args.pricing_api_endpoint.rstrip("/")
|
|
529
|
+
if args.infracost_cli_path:
|
|
530
|
+
payload["infracost_cli_path"] = args.infracost_cli_path
|
|
531
|
+
if args.infracost_mode:
|
|
532
|
+
payload["infracost_mode"] = args.infracost_mode
|
|
533
|
+
|
|
534
|
+
path = write_config(payload)
|
|
535
|
+
print(f"CloudCost config written to {path}")
|
|
536
|
+
print("")
|
|
537
|
+
print("Try your first estimate:")
|
|
538
|
+
print(" cd path/to/terraform-project")
|
|
539
|
+
print(" cloudcost")
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def run_doctor(args: argparse.Namespace) -> int:
|
|
544
|
+
settings = settings_from_args(args)
|
|
545
|
+
config = read_config()
|
|
546
|
+
cli_path = Path(settings.infracost_cli_path)
|
|
547
|
+
cli_found = cli_path.exists() or shutil.which(settings.infracost_cli_path) is not None
|
|
548
|
+
|
|
549
|
+
checks = [
|
|
550
|
+
("config", "found" if config else "missing", str(config_path())),
|
|
551
|
+
("infracost_cli", "ready" if cli_found else "missing", settings.infracost_cli_path),
|
|
552
|
+
(
|
|
553
|
+
"pricing_api",
|
|
554
|
+
"configured" if settings.infracost_pricing_api_endpoint else "missing",
|
|
555
|
+
settings.infracost_pricing_api_endpoint or "set INFRACOST_PRICING_API_ENDPOINT",
|
|
556
|
+
),
|
|
557
|
+
("mode", settings.infracost_mode, "cli is recommended for local plan JSON"),
|
|
558
|
+
]
|
|
559
|
+
|
|
560
|
+
print("CloudCost doctor")
|
|
561
|
+
print("")
|
|
562
|
+
for label, state, detail in checks:
|
|
563
|
+
print(f"{label:<14} {state:<12} {detail}")
|
|
564
|
+
return 0 if cli_found else 1
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def add_estimate_options(parser: argparse.ArgumentParser, *, plan_required: bool) -> None:
|
|
568
|
+
parser.add_argument("--plan", required=plan_required, help="Path to Terraform plan JSON.")
|
|
569
|
+
parser.add_argument("--format", choices=["terminal", "json", "markdown"], default="terminal")
|
|
570
|
+
parser.add_argument("--out", help="Write output to a file instead of stdout.")
|
|
571
|
+
parser.add_argument("--limit", type=int, default=12, help="Number of resources to show.")
|
|
572
|
+
parser.add_argument(
|
|
573
|
+
"--fail-on-increase",
|
|
574
|
+
type=Decimal,
|
|
575
|
+
help="Exit 2 if monthly increase is above this amount.",
|
|
576
|
+
)
|
|
577
|
+
parser.add_argument("--infracost-cli-path", help="Path to the infracost binary.")
|
|
578
|
+
parser.add_argument("--pricing-api-endpoint", help="Infracost pricing API endpoint.")
|
|
579
|
+
parser.add_argument("--infracost-mode", choices=["cli", "api"])
|
|
580
|
+
parser.add_argument("--infracost-api-key", help="Infracost or CloudCost pricing API key.")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def add_one_command_options(parser: argparse.ArgumentParser) -> None:
|
|
584
|
+
add_estimate_options(parser, plan_required=False)
|
|
585
|
+
parser.add_argument(
|
|
586
|
+
"--path",
|
|
587
|
+
default=".",
|
|
588
|
+
help="Terraform project directory to scan or run from. Defaults to the current directory.",
|
|
589
|
+
)
|
|
590
|
+
parser.add_argument(
|
|
591
|
+
"--recursive",
|
|
592
|
+
action="store_true",
|
|
593
|
+
help="Search child directories for an existing Terraform plan JSON.",
|
|
594
|
+
)
|
|
595
|
+
parser.add_argument(
|
|
596
|
+
"--no-terraform",
|
|
597
|
+
action="store_true",
|
|
598
|
+
help="Do not auto-run terraform when no plan JSON is found.",
|
|
599
|
+
)
|
|
600
|
+
parser.add_argument("--terraform-bin", default="terraform", help="Terraform executable path.")
|
|
601
|
+
parser.add_argument(
|
|
602
|
+
"--skip-terraform-init",
|
|
603
|
+
action="store_true",
|
|
604
|
+
help="Skip automatic terraform init when .terraform is missing.",
|
|
605
|
+
)
|
|
606
|
+
parser.add_argument(
|
|
607
|
+
"--terraform-timeout",
|
|
608
|
+
type=int,
|
|
609
|
+
default=600,
|
|
610
|
+
help="Seconds to allow each terraform command.",
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def add_github_connect_options(parser: argparse.ArgumentParser) -> None:
|
|
615
|
+
parser.add_argument(
|
|
616
|
+
"--backend-url",
|
|
617
|
+
help=(
|
|
618
|
+
"CloudCost backend URL that hosts /install/github. Defaults to saved config, "
|
|
619
|
+
"CLOUDCOST_BACKEND_URL, PUBLIC_BASE_URL, or https://cloudcost.live."
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
parser.add_argument(
|
|
623
|
+
"--path",
|
|
624
|
+
default=".",
|
|
625
|
+
help="Repository directory used to detect the GitHub remote. Defaults to the current directory.",
|
|
626
|
+
)
|
|
627
|
+
parser.add_argument("--repo", help="Repository hint to show after opening GitHub, for example acme/infra.")
|
|
628
|
+
parser.add_argument("--no-open", action="store_true", help="Print the install URL without opening a browser.")
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def add_go_options(parser: argparse.ArgumentParser) -> None:
|
|
632
|
+
add_one_command_options(parser)
|
|
633
|
+
parser.add_argument(
|
|
634
|
+
"--backend-url",
|
|
635
|
+
help=(
|
|
636
|
+
"CloudCost backend URL that hosts /install/github. Defaults to saved config, "
|
|
637
|
+
"CLOUDCOST_BACKEND_URL, PUBLIC_BASE_URL, or https://cloudcost.live."
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
parser.add_argument("--repo", help="Repository hint to show after opening GitHub, for example acme/infra.")
|
|
641
|
+
parser.add_argument("--no-open", action="store_true", help="Print the install URL without opening a browser.")
|
|
642
|
+
parser.add_argument(
|
|
643
|
+
"--skip-estimate",
|
|
644
|
+
action="store_true",
|
|
645
|
+
help="Skip the local estimate and only open the GitHub App install flow.",
|
|
646
|
+
)
|
|
647
|
+
parser.add_argument(
|
|
648
|
+
"--require-estimate",
|
|
649
|
+
action="store_true",
|
|
650
|
+
help="Stop before GitHub install if the local estimate fails.",
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
655
|
+
parser = argparse.ArgumentParser(
|
|
656
|
+
prog="cloudcost",
|
|
657
|
+
description=(
|
|
658
|
+
"CloudCost AI CLI. Run `cloudcost` in a Terraform project for the one-command "
|
|
659
|
+
"cost estimate path."
|
|
660
|
+
),
|
|
661
|
+
)
|
|
662
|
+
parser.add_argument("--version", action="version", version=f"cloudcost {VERSION}")
|
|
663
|
+
add_one_command_options(parser)
|
|
664
|
+
parser.set_defaults(func=lambda parsed: asyncio.run(run_one_command(parsed)))
|
|
665
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
666
|
+
|
|
667
|
+
run_parser = subparsers.add_parser(
|
|
668
|
+
"run",
|
|
669
|
+
help="One-command path: find or generate Terraform plan JSON, then estimate it.",
|
|
670
|
+
)
|
|
671
|
+
add_one_command_options(run_parser)
|
|
672
|
+
run_parser.set_defaults(func=lambda parsed: asyncio.run(run_one_command(parsed)))
|
|
673
|
+
|
|
674
|
+
go_parser = subparsers.add_parser(
|
|
675
|
+
"go",
|
|
676
|
+
help="Run the local estimate, then open the GitHub App install flow.",
|
|
677
|
+
)
|
|
678
|
+
add_go_options(go_parser)
|
|
679
|
+
go_parser.set_defaults(func=lambda parsed: asyncio.run(run_go(parsed)))
|
|
680
|
+
|
|
681
|
+
connect_parser = subparsers.add_parser(
|
|
682
|
+
"connect-github",
|
|
683
|
+
help="Open the GitHub App manifest install flow for this CloudCost backend.",
|
|
684
|
+
)
|
|
685
|
+
add_github_connect_options(connect_parser)
|
|
686
|
+
connect_parser.set_defaults(func=run_connect_github)
|
|
687
|
+
|
|
688
|
+
setup_parser = subparsers.add_parser("setup", help="Write local CloudCost CLI configuration.")
|
|
689
|
+
setup_parser.add_argument("--backend-url", help="CloudCost backend URL, for example https://cloudcost.live.")
|
|
690
|
+
setup_parser.add_argument("--pricing-api-endpoint", help="Infracost pricing API endpoint.")
|
|
691
|
+
setup_parser.add_argument("--infracost-cli-path", help="Path to the infracost binary.")
|
|
692
|
+
setup_parser.add_argument("--infracost-mode", choices=["cli", "api"], default="cli")
|
|
693
|
+
setup_parser.set_defaults(func=run_setup)
|
|
694
|
+
|
|
695
|
+
doctor_parser = subparsers.add_parser("doctor", help="Check local CLI prerequisites.")
|
|
696
|
+
doctor_parser.add_argument("--infracost-cli-path", help="Path to the infracost binary.")
|
|
697
|
+
doctor_parser.add_argument("--pricing-api-endpoint", help="Infracost pricing API endpoint.")
|
|
698
|
+
doctor_parser.add_argument("--infracost-mode", choices=["cli", "api"])
|
|
699
|
+
doctor_parser.add_argument("--infracost-api-key", help="Infracost or CloudCost pricing API key.")
|
|
700
|
+
doctor_parser.set_defaults(func=run_doctor)
|
|
701
|
+
|
|
702
|
+
analyze_parser = subparsers.add_parser("analyze", help="Estimate a Terraform plan JSON file.")
|
|
703
|
+
add_estimate_options(analyze_parser, plan_required=False)
|
|
704
|
+
analyze_parser.add_argument(
|
|
705
|
+
"--path",
|
|
706
|
+
default=".",
|
|
707
|
+
help="Directory to scan when --plan is omitted. Defaults to the current directory.",
|
|
708
|
+
)
|
|
709
|
+
analyze_parser.add_argument(
|
|
710
|
+
"--recursive",
|
|
711
|
+
action="store_true",
|
|
712
|
+
help="Search child directories when --plan is omitted.",
|
|
713
|
+
)
|
|
714
|
+
analyze_parser.set_defaults(func=lambda parsed: asyncio.run(run_analyze(parsed)))
|
|
715
|
+
|
|
716
|
+
return parser
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def main(argv: list[str] | None = None) -> int:
|
|
720
|
+
parser = build_parser()
|
|
721
|
+
args = parser.parse_args(argv)
|
|
722
|
+
return int(args.func(args))
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
if __name__ == "__main__":
|
|
726
|
+
raise SystemExit(main())
|