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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import jwt
|
|
8
|
+
|
|
9
|
+
from backend.app.comments import CLOUDCOST_COMMENT_MARKER
|
|
10
|
+
from backend.app.config import Settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _raise_for_github_status(response: httpx.Response) -> None:
|
|
14
|
+
try:
|
|
15
|
+
response.raise_for_status()
|
|
16
|
+
except httpx.HTTPStatusError as exc:
|
|
17
|
+
detail = response.text.strip() or response.reason_phrase
|
|
18
|
+
accepted_permissions = response.headers.get("x-accepted-github-permissions")
|
|
19
|
+
permission_hint = f" Accepted permissions: {accepted_permissions}." if accepted_permissions else ""
|
|
20
|
+
raise RuntimeError(
|
|
21
|
+
f"GitHub API returned {response.status_code} for "
|
|
22
|
+
f"{response.request.method} {response.request.url}: {detail[:1000]}{permission_hint}"
|
|
23
|
+
) from exc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PullRequestContext:
|
|
28
|
+
owner: str
|
|
29
|
+
repo: str
|
|
30
|
+
full_name: str
|
|
31
|
+
number: int
|
|
32
|
+
title: str
|
|
33
|
+
head_sha: str
|
|
34
|
+
installation_id: int
|
|
35
|
+
is_draft: bool
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_payload(cls, payload: dict[str, Any]) -> "PullRequestContext":
|
|
39
|
+
repository = payload["repository"]
|
|
40
|
+
pull_request = payload["pull_request"]
|
|
41
|
+
return cls(
|
|
42
|
+
owner=repository["owner"]["login"],
|
|
43
|
+
repo=repository["name"],
|
|
44
|
+
full_name=repository["full_name"],
|
|
45
|
+
number=int(pull_request["number"]),
|
|
46
|
+
title=pull_request.get("title") or f"PR #{pull_request['number']}",
|
|
47
|
+
head_sha=pull_request["head"]["sha"],
|
|
48
|
+
installation_id=int(payload["installation"]["id"]),
|
|
49
|
+
is_draft=bool(pull_request.get("draft")),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class PlanFile:
|
|
55
|
+
path: str
|
|
56
|
+
sha: str
|
|
57
|
+
size: int | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GitHubAppClient:
|
|
61
|
+
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
|
|
62
|
+
self.settings = settings
|
|
63
|
+
self.client = client or httpx.AsyncClient(timeout=30.0)
|
|
64
|
+
|
|
65
|
+
def _headers(self, token: str) -> dict[str, str]:
|
|
66
|
+
return {
|
|
67
|
+
"Accept": "application/vnd.github+json",
|
|
68
|
+
"Authorization": f"Bearer {token}",
|
|
69
|
+
"X-GitHub-Api-Version": self.settings.github_api_version,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def app_jwt(self) -> str:
|
|
73
|
+
now = int(time.time())
|
|
74
|
+
payload = {
|
|
75
|
+
"iat": now - 60,
|
|
76
|
+
"exp": now + (9 * 60),
|
|
77
|
+
"iss": self.settings.github_jwt_issuer,
|
|
78
|
+
}
|
|
79
|
+
return jwt.encode(payload, self.settings.load_github_private_key(), algorithm="RS256")
|
|
80
|
+
|
|
81
|
+
async def create_installation_token(self, installation_id: int) -> str:
|
|
82
|
+
url = f"{self.settings.github_api_url}/app/installations/{installation_id}/access_tokens"
|
|
83
|
+
response = await self.client.post(url, headers=self._headers(self.app_jwt()))
|
|
84
|
+
_raise_for_github_status(response)
|
|
85
|
+
return response.json()["token"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class GitHubInstallationClient:
|
|
89
|
+
def __init__(self, settings: Settings, token: str, client: httpx.AsyncClient | None = None) -> None:
|
|
90
|
+
self.settings = settings
|
|
91
|
+
self.token = token
|
|
92
|
+
self.client = client or httpx.AsyncClient(timeout=30.0)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def headers(self) -> dict[str, str]:
|
|
96
|
+
return {
|
|
97
|
+
"Accept": "application/vnd.github+json",
|
|
98
|
+
"Authorization": f"Bearer {self.token}",
|
|
99
|
+
"X-GitHub-Api-Version": self.settings.github_api_version,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async def list_pull_files(self, owner: str, repo: str, pull_number: int) -> list[dict[str, Any]]:
|
|
103
|
+
files: list[dict[str, Any]] = []
|
|
104
|
+
page = 1
|
|
105
|
+
while page <= 30:
|
|
106
|
+
url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/pulls/{pull_number}/files"
|
|
107
|
+
response = await self.client.get(
|
|
108
|
+
url,
|
|
109
|
+
headers=self.headers,
|
|
110
|
+
params={"per_page": 100, "page": page},
|
|
111
|
+
)
|
|
112
|
+
_raise_for_github_status(response)
|
|
113
|
+
batch = response.json()
|
|
114
|
+
files.extend(batch)
|
|
115
|
+
if len(batch) < 100:
|
|
116
|
+
break
|
|
117
|
+
page += 1
|
|
118
|
+
return files
|
|
119
|
+
|
|
120
|
+
async def fetch_blob(self, owner: str, repo: str, sha: str) -> bytes:
|
|
121
|
+
url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/git/blobs/{sha}"
|
|
122
|
+
response = await self.client.get(url, headers=self.headers)
|
|
123
|
+
_raise_for_github_status(response)
|
|
124
|
+
payload = response.json()
|
|
125
|
+
if payload.get("encoding") != "base64":
|
|
126
|
+
raise RuntimeError(f"Unsupported GitHub blob encoding: {payload.get('encoding')}")
|
|
127
|
+
compact = "".join(str(payload["content"]).splitlines())
|
|
128
|
+
return base64.b64decode(compact)
|
|
129
|
+
|
|
130
|
+
async def list_repository_tree_files(self, owner: str, repo: str, commit_sha: str) -> list[dict[str, Any]]:
|
|
131
|
+
commit_url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/git/commits/{commit_sha}"
|
|
132
|
+
commit_response = await self.client.get(commit_url, headers=self.headers)
|
|
133
|
+
_raise_for_github_status(commit_response)
|
|
134
|
+
tree_sha = commit_response.json()["tree"]["sha"]
|
|
135
|
+
|
|
136
|
+
tree_url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/git/trees/{tree_sha}"
|
|
137
|
+
tree_response = await self.client.get(
|
|
138
|
+
tree_url,
|
|
139
|
+
headers=self.headers,
|
|
140
|
+
params={"recursive": "1"},
|
|
141
|
+
)
|
|
142
|
+
_raise_for_github_status(tree_response)
|
|
143
|
+
tree = tree_response.json()
|
|
144
|
+
return [
|
|
145
|
+
{"filename": item["path"], "sha": item["sha"], "changes": item.get("size")}
|
|
146
|
+
for item in tree.get("tree", [])
|
|
147
|
+
if item.get("type") == "blob"
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
async def list_issue_comments(self, owner: str, repo: str, issue_number: int) -> list[dict[str, Any]]:
|
|
151
|
+
url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
|
152
|
+
response = await self.client.get(url, headers=self.headers, params={"per_page": 100})
|
|
153
|
+
_raise_for_github_status(response)
|
|
154
|
+
return response.json()
|
|
155
|
+
|
|
156
|
+
async def upsert_pr_comment(self, owner: str, repo: str, pull_number: int, body: str) -> dict[str, Any]:
|
|
157
|
+
comments = await self.list_issue_comments(owner, repo, pull_number)
|
|
158
|
+
existing = next(
|
|
159
|
+
(comment for comment in comments if CLOUDCOST_COMMENT_MARKER in str(comment.get("body", ""))),
|
|
160
|
+
None,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if existing:
|
|
164
|
+
url = existing["url"]
|
|
165
|
+
response = await self.client.patch(url, headers=self.headers, json={"body": body})
|
|
166
|
+
else:
|
|
167
|
+
url = f"{self.settings.github_api_url}/repos/{owner}/{repo}/issues/{pull_number}/comments"
|
|
168
|
+
response = await self.client.post(url, headers=self.headers, json={"body": body})
|
|
169
|
+
|
|
170
|
+
_raise_for_github_status(response)
|
|
171
|
+
return response.json()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def find_terraform_plan_file(files: list[dict[str, Any]], settings: Settings) -> PlanFile | None:
|
|
175
|
+
candidates: list[PlanFile] = []
|
|
176
|
+
for file in files:
|
|
177
|
+
path = str(file.get("filename", ""))
|
|
178
|
+
name = path.rsplit("/", 1)[-1].lower()
|
|
179
|
+
lowered_path = path.lower()
|
|
180
|
+
if name in settings.plan_names or lowered_path.endswith(settings.plan_suffixes):
|
|
181
|
+
sha = file.get("sha")
|
|
182
|
+
if sha:
|
|
183
|
+
candidates.append(PlanFile(path=path, sha=str(sha), size=file.get("changes")))
|
|
184
|
+
|
|
185
|
+
if not candidates:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def priority(item: PlanFile) -> tuple[int, str]:
|
|
189
|
+
name = item.path.rsplit("/", 1)[-1].lower()
|
|
190
|
+
if name == "plan.json":
|
|
191
|
+
return (0, item.path)
|
|
192
|
+
if name in settings.plan_names:
|
|
193
|
+
return (1, item.path)
|
|
194
|
+
return (2, item.path)
|
|
195
|
+
|
|
196
|
+
candidates.sort(key=priority)
|
|
197
|
+
return candidates[0]
|
backend/app/infracost.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from backend.app.config import Settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class InfracostEstimate:
|
|
16
|
+
raw: dict[str, Any]
|
|
17
|
+
diff_total_monthly_cost: Any
|
|
18
|
+
total_monthly_cost: Any
|
|
19
|
+
past_total_monthly_cost: Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InfracostClient:
|
|
23
|
+
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
|
|
24
|
+
self.settings = settings
|
|
25
|
+
self.client = client or httpx.AsyncClient(timeout=60.0)
|
|
26
|
+
|
|
27
|
+
async def estimate_plan_json(self, plan_json: bytes, filename: str) -> InfracostEstimate:
|
|
28
|
+
mode = self.settings.infracost_mode.strip().lower()
|
|
29
|
+
if mode == "api":
|
|
30
|
+
return await self._estimate_plan_json_api(plan_json, filename)
|
|
31
|
+
if mode == "cli":
|
|
32
|
+
return await self._estimate_plan_json_cli(plan_json, filename)
|
|
33
|
+
raise RuntimeError("Set INFRACOST_MODE to either 'cli' or 'api'.")
|
|
34
|
+
|
|
35
|
+
async def _estimate_plan_json_api(self, plan_json: bytes, filename: str) -> InfracostEstimate:
|
|
36
|
+
response = await self.client.post(
|
|
37
|
+
self.settings.infracost_api_url,
|
|
38
|
+
headers={"x-api-key": self.settings.require_infracost_api_key()},
|
|
39
|
+
data={
|
|
40
|
+
"format": "json",
|
|
41
|
+
"no-color": "true",
|
|
42
|
+
"ci-platform": self.settings.infracost_ci_platform,
|
|
43
|
+
},
|
|
44
|
+
files={
|
|
45
|
+
"path": (filename, plan_json, "application/json"),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
payload = response.json()
|
|
50
|
+
return self._estimate_from_payload(payload)
|
|
51
|
+
|
|
52
|
+
async def _estimate_plan_json_cli(self, plan_json: bytes, filename: str) -> InfracostEstimate:
|
|
53
|
+
with tempfile.TemporaryDirectory(prefix="cloudcost-infracost-") as temp_dir:
|
|
54
|
+
safe_name = Path(filename).name or "plan.json"
|
|
55
|
+
plan_path = Path(temp_dir) / safe_name
|
|
56
|
+
output_path = Path(temp_dir) / "infracost.json"
|
|
57
|
+
plan_path.write_bytes(plan_json)
|
|
58
|
+
|
|
59
|
+
env = self._cli_environment()
|
|
60
|
+
command = [
|
|
61
|
+
self.settings.infracost_cli_path,
|
|
62
|
+
"breakdown",
|
|
63
|
+
"--path",
|
|
64
|
+
str(plan_path),
|
|
65
|
+
"--format",
|
|
66
|
+
"json",
|
|
67
|
+
"--out-file",
|
|
68
|
+
str(output_path),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
process = await asyncio.create_subprocess_exec(
|
|
73
|
+
*command,
|
|
74
|
+
stdout=asyncio.subprocess.PIPE,
|
|
75
|
+
stderr=asyncio.subprocess.PIPE,
|
|
76
|
+
env=env,
|
|
77
|
+
)
|
|
78
|
+
except FileNotFoundError as exc:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"Infracost CLI was not found. Install it and set INFRACOST_CLI_PATH if it is not on PATH."
|
|
81
|
+
) from exc
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
stdout, stderr = await asyncio.wait_for(
|
|
85
|
+
process.communicate(),
|
|
86
|
+
timeout=self.settings.infracost_timeout_seconds,
|
|
87
|
+
)
|
|
88
|
+
except asyncio.TimeoutError as exc:
|
|
89
|
+
process.kill()
|
|
90
|
+
await process.communicate()
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"Infracost CLI timed out after {self.settings.infracost_timeout_seconds} seconds."
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
if process.returncode != 0:
|
|
96
|
+
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
|
97
|
+
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
|
98
|
+
detail = stderr_text or stdout_text or f"exit code {process.returncode}"
|
|
99
|
+
raise RuntimeError(f"Infracost CLI failed: {detail[:2000]}")
|
|
100
|
+
|
|
101
|
+
if output_path.exists():
|
|
102
|
+
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
|
103
|
+
else:
|
|
104
|
+
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
|
105
|
+
if not stdout_text:
|
|
106
|
+
raise RuntimeError("Infracost CLI did not produce JSON output.")
|
|
107
|
+
payload = json.loads(stdout_text)
|
|
108
|
+
|
|
109
|
+
return self._estimate_from_payload(payload)
|
|
110
|
+
|
|
111
|
+
def _cli_environment(self) -> dict[str, str]:
|
|
112
|
+
env = os.environ.copy()
|
|
113
|
+
env["INFRACOST_CI_PLATFORM"] = self.settings.infracost_ci_platform
|
|
114
|
+
env["INFRACOST_SKIP_UPDATE_CHECK"] = "true"
|
|
115
|
+
env["INFRACOST_LOG_LEVEL"] = "warn"
|
|
116
|
+
env["DISABLE_TELEMETRY"] = "true"
|
|
117
|
+
if self.settings.infracost_api_key:
|
|
118
|
+
env["INFRACOST_API_KEY"] = self.settings.infracost_api_key
|
|
119
|
+
if self.settings.infracost_pricing_api_endpoint:
|
|
120
|
+
env["INFRACOST_PRICING_API_ENDPOINT"] = self.settings.infracost_pricing_api_endpoint
|
|
121
|
+
return env
|
|
122
|
+
|
|
123
|
+
def _estimate_from_payload(self, payload: dict[str, Any]) -> InfracostEstimate:
|
|
124
|
+
return InfracostEstimate(
|
|
125
|
+
raw=payload,
|
|
126
|
+
diff_total_monthly_cost=payload.get("diffTotalMonthlyCost"),
|
|
127
|
+
total_monthly_cost=payload.get("totalMonthlyCost"),
|
|
128
|
+
past_total_monthly_cost=payload.get("pastTotalMonthlyCost"),
|
|
129
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from backend.app.config import Settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VirtualKeyRequest(BaseModel):
|
|
10
|
+
user_id: str | None = None
|
|
11
|
+
team_id: str | None = None
|
|
12
|
+
key_alias: str | None = None
|
|
13
|
+
models: list[str] = Field(default_factory=list)
|
|
14
|
+
max_budget: float | None = None
|
|
15
|
+
budget_duration: str | None = "30d"
|
|
16
|
+
tpm_limit: int | None = None
|
|
17
|
+
rpm_limit: int | None = None
|
|
18
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LiteLLMAdminClient:
|
|
22
|
+
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
|
|
23
|
+
self.settings = settings
|
|
24
|
+
self.client = client or httpx.AsyncClient(timeout=30.0)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def headers(self) -> dict[str, str]:
|
|
28
|
+
return {
|
|
29
|
+
"Authorization": f"Bearer {self.settings.require_litellm_master_key()}",
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async def create_virtual_key(self, request: VirtualKeyRequest) -> dict[str, Any]:
|
|
34
|
+
body = request.model_dump(exclude_none=True)
|
|
35
|
+
response = await self.client.post(
|
|
36
|
+
f"{self.settings.litellm_base_url.rstrip('/')}/key/generate",
|
|
37
|
+
headers=self.headers,
|
|
38
|
+
json=body,
|
|
39
|
+
)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
return response.json()
|