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/comments.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from decimal import Decimal, InvalidOperation
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
CLOUDCOST_COMMENT_MARKER = "<!-- cloudcost-ai:infracost -->"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_decimal(value: Any) -> Decimal | None:
|
|
9
|
+
if value is None or value == "":
|
|
10
|
+
return None
|
|
11
|
+
try:
|
|
12
|
+
return Decimal(str(value))
|
|
13
|
+
except (InvalidOperation, ValueError):
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_money(value: Any, signed: bool = False) -> str:
|
|
18
|
+
amount = to_decimal(value)
|
|
19
|
+
if amount is None:
|
|
20
|
+
return "unknown"
|
|
21
|
+
|
|
22
|
+
sign = ""
|
|
23
|
+
if signed and amount > 0:
|
|
24
|
+
sign = "+"
|
|
25
|
+
quantized = amount.quantize(Decimal("0.01"))
|
|
26
|
+
return f"{sign}${quantized:,.2f}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_infracost_comment(
|
|
30
|
+
*,
|
|
31
|
+
plan_path: str,
|
|
32
|
+
diff_total_monthly_cost: Any,
|
|
33
|
+
total_monthly_cost: Any,
|
|
34
|
+
past_total_monthly_cost: Any,
|
|
35
|
+
pr_title: str,
|
|
36
|
+
) -> str:
|
|
37
|
+
diff = to_decimal(diff_total_monthly_cost)
|
|
38
|
+
if diff is None:
|
|
39
|
+
verdict = "Infracost returned a result, but the monthly delta was not available."
|
|
40
|
+
elif diff > 0:
|
|
41
|
+
verdict = f"This pull request increases estimated monthly infrastructure cost by {format_money(diff, signed=True)}."
|
|
42
|
+
elif diff < 0:
|
|
43
|
+
verdict = f"This pull request reduces estimated monthly infrastructure cost by {format_money(diff, signed=True)}."
|
|
44
|
+
else:
|
|
45
|
+
verdict = "This pull request has no estimated monthly infrastructure cost change."
|
|
46
|
+
|
|
47
|
+
return "\n".join(
|
|
48
|
+
[
|
|
49
|
+
CLOUDCOST_COMMENT_MARKER,
|
|
50
|
+
"## CloudCost AI estimate",
|
|
51
|
+
"",
|
|
52
|
+
verdict,
|
|
53
|
+
"",
|
|
54
|
+
"| Metric | Estimate |",
|
|
55
|
+
"| --- | ---: |",
|
|
56
|
+
f"| Previous monthly cost | {format_money(past_total_monthly_cost)} |",
|
|
57
|
+
f"| Proposed monthly cost | {format_money(total_monthly_cost)} |",
|
|
58
|
+
f"| Diff total monthly cost | {format_money(diff_total_monthly_cost, signed=True)} |",
|
|
59
|
+
"",
|
|
60
|
+
f"Terraform plan JSON: `{plan_path}`",
|
|
61
|
+
f"Pull request: `{pr_title}`",
|
|
62
|
+
"",
|
|
63
|
+
"This comment was generated from Terraform plan JSON via the Infracost Plan JSON API.",
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def format_no_plan_comment(pr_title: str) -> str:
|
|
69
|
+
return "\n".join(
|
|
70
|
+
[
|
|
71
|
+
CLOUDCOST_COMMENT_MARKER,
|
|
72
|
+
"## CloudCost AI estimate",
|
|
73
|
+
"",
|
|
74
|
+
"No Terraform plan JSON file was found in this pull request.",
|
|
75
|
+
"",
|
|
76
|
+
"Add a generated plan file such as `plan.json`, `tfplan.json`, or `terraform-plan.json` to let CloudCost AI calculate the monthly infrastructure cost delta.",
|
|
77
|
+
"",
|
|
78
|
+
f"Pull request: `{pr_title}`",
|
|
79
|
+
]
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def format_error_comment(pr_title: str, message: str) -> str:
|
|
84
|
+
return "\n".join(
|
|
85
|
+
[
|
|
86
|
+
CLOUDCOST_COMMENT_MARKER,
|
|
87
|
+
"## CloudCost AI estimate",
|
|
88
|
+
"",
|
|
89
|
+
"CloudCost AI could not complete the cost estimate for this pull request.",
|
|
90
|
+
"",
|
|
91
|
+
f"Reason: `{message}`",
|
|
92
|
+
f"Pull request: `{pr_title}`",
|
|
93
|
+
]
|
|
94
|
+
)
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from shutil import which
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Settings(BaseSettings):
|
|
12
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
|
13
|
+
|
|
14
|
+
app_name: str = "CloudCost AI"
|
|
15
|
+
environment: str = "local"
|
|
16
|
+
public_base_url: str = "http://localhost:8000"
|
|
17
|
+
database_url: str | None = None
|
|
18
|
+
app_database_url: str | None = None
|
|
19
|
+
|
|
20
|
+
auth_secret: str | None = None
|
|
21
|
+
auth_session_cookie: str = "cloudcost_session"
|
|
22
|
+
auth_session_days: int = 14
|
|
23
|
+
auth_cookie_secure: bool = False
|
|
24
|
+
auth_otp_ttl_minutes: int = 10
|
|
25
|
+
auth_otp_max_attempts: int = 5
|
|
26
|
+
auth_dev_otp_log_path: str = "logs/auth-otp.log"
|
|
27
|
+
|
|
28
|
+
smtp_host: str | None = None
|
|
29
|
+
smtp_port: int = 587
|
|
30
|
+
smtp_username: str | None = None
|
|
31
|
+
smtp_password: str | None = None
|
|
32
|
+
smtp_from_email: str | None = None
|
|
33
|
+
smtp_use_tls: bool = True
|
|
34
|
+
resend_api_key: str | None = None
|
|
35
|
+
from_email: str | None = None
|
|
36
|
+
waitlist_confirmation_enabled: bool = True
|
|
37
|
+
|
|
38
|
+
github_api_url: str = "https://api.github.com"
|
|
39
|
+
github_api_version: str = "2026-03-10"
|
|
40
|
+
github_app_id: str | None = None
|
|
41
|
+
github_client_id: str | None = None
|
|
42
|
+
github_private_key: str | None = None
|
|
43
|
+
github_private_key_path: str | None = None
|
|
44
|
+
github_webhook_secret: str | None = None
|
|
45
|
+
github_manifest_config_path: str = "data/github-app-manifest.json"
|
|
46
|
+
comment_when_no_plan: bool = True
|
|
47
|
+
|
|
48
|
+
infracost_api_key: str | None = None
|
|
49
|
+
infracost_api_url: str = "https://pricing.api.infracost.io/breakdown"
|
|
50
|
+
infracost_ci_platform: str = "cloudcost-ai"
|
|
51
|
+
infracost_mode: str = "cli"
|
|
52
|
+
infracost_pricing_mode: str = "self_hosted"
|
|
53
|
+
infracost_cli_path: str = "infracost"
|
|
54
|
+
infracost_pricing_api_endpoint: str | None = None
|
|
55
|
+
infracost_timeout_seconds: int = 120
|
|
56
|
+
|
|
57
|
+
litellm_base_url: str = "http://127.0.0.1:4000"
|
|
58
|
+
litellm_master_key: str | None = None
|
|
59
|
+
|
|
60
|
+
usage_ingest_token: str | None = None
|
|
61
|
+
waitlist_path: str = "data/waitlist.jsonl"
|
|
62
|
+
usage_events_path: str = "data/usage-events.jsonl"
|
|
63
|
+
|
|
64
|
+
terraform_plan_names: str = Field(
|
|
65
|
+
default="plan.json,tfplan.json,terraform-plan.json,infracost-plan.json"
|
|
66
|
+
)
|
|
67
|
+
terraform_plan_suffixes: str = Field(default=".tfplan.json,.plan.json")
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def github_jwt_issuer(self) -> str:
|
|
71
|
+
manifest = self.load_github_manifest_config()
|
|
72
|
+
issuer = (
|
|
73
|
+
self.github_client_id
|
|
74
|
+
or self.github_app_id
|
|
75
|
+
or manifest.get("client_id")
|
|
76
|
+
or manifest.get("id")
|
|
77
|
+
)
|
|
78
|
+
if not issuer:
|
|
79
|
+
raise RuntimeError("Set GITHUB_CLIENT_ID or GITHUB_APP_ID for GitHub App auth.")
|
|
80
|
+
return str(issuer)
|
|
81
|
+
|
|
82
|
+
def load_github_manifest_config(self) -> dict[str, Any]:
|
|
83
|
+
path = Path(self.github_manifest_config_path)
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return {}
|
|
86
|
+
try:
|
|
87
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
except (OSError, json.JSONDecodeError):
|
|
89
|
+
return {}
|
|
90
|
+
return payload if isinstance(payload, dict) else {}
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def github_app_configured(self) -> bool:
|
|
94
|
+
manifest = self.load_github_manifest_config()
|
|
95
|
+
has_id = bool(
|
|
96
|
+
self.github_app_id
|
|
97
|
+
or self.github_client_id
|
|
98
|
+
or manifest.get("id")
|
|
99
|
+
or manifest.get("client_id")
|
|
100
|
+
)
|
|
101
|
+
has_key = bool(
|
|
102
|
+
self.github_private_key
|
|
103
|
+
or self.github_private_key_path
|
|
104
|
+
or manifest.get("private_key_path")
|
|
105
|
+
or manifest.get("pem")
|
|
106
|
+
)
|
|
107
|
+
has_secret = bool(self.github_webhook_secret or manifest.get("webhook_secret"))
|
|
108
|
+
return has_id and has_key and has_secret
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def backend_database_url(self) -> str | None:
|
|
112
|
+
return self.app_database_url or self.database_url
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def plan_names(self) -> set[str]:
|
|
116
|
+
return {item.strip().lower() for item in self.terraform_plan_names.split(",") if item.strip()}
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def plan_suffixes(self) -> tuple[str, ...]:
|
|
120
|
+
return tuple(
|
|
121
|
+
item.strip().lower() for item in self.terraform_plan_suffixes.split(",") if item.strip()
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def load_github_private_key(self) -> str:
|
|
125
|
+
manifest = self.load_github_manifest_config()
|
|
126
|
+
if self.github_private_key:
|
|
127
|
+
return self.github_private_key.replace("\\n", "\n")
|
|
128
|
+
if self.github_private_key_path:
|
|
129
|
+
return Path(self.github_private_key_path).read_text(encoding="utf-8")
|
|
130
|
+
if manifest.get("private_key_path"):
|
|
131
|
+
return Path(str(manifest["private_key_path"])).read_text(encoding="utf-8")
|
|
132
|
+
if manifest.get("pem"):
|
|
133
|
+
return str(manifest["pem"]).replace("\\n", "\n")
|
|
134
|
+
raise RuntimeError("Set GITHUB_PRIVATE_KEY or GITHUB_PRIVATE_KEY_PATH.")
|
|
135
|
+
|
|
136
|
+
def require_github_webhook_secret(self) -> str:
|
|
137
|
+
manifest = self.load_github_manifest_config()
|
|
138
|
+
if not self.github_webhook_secret:
|
|
139
|
+
if manifest.get("webhook_secret"):
|
|
140
|
+
return str(manifest["webhook_secret"])
|
|
141
|
+
raise RuntimeError("Set GITHUB_WEBHOOK_SECRET.")
|
|
142
|
+
return self.github_webhook_secret
|
|
143
|
+
|
|
144
|
+
def require_manifest_state_secret(self) -> str:
|
|
145
|
+
secret = (
|
|
146
|
+
self.auth_secret
|
|
147
|
+
or self.litellm_master_key
|
|
148
|
+
or self.usage_ingest_token
|
|
149
|
+
or self.github_webhook_secret
|
|
150
|
+
)
|
|
151
|
+
if not secret:
|
|
152
|
+
raise RuntimeError("Set LITELLM_MASTER_KEY, USAGE_INGEST_TOKEN, or AUTH_SECRET.")
|
|
153
|
+
return secret
|
|
154
|
+
|
|
155
|
+
def require_infracost_api_key(self) -> str:
|
|
156
|
+
if not self.infracost_api_key:
|
|
157
|
+
raise RuntimeError("Set INFRACOST_API_KEY.")
|
|
158
|
+
return self.infracost_api_key
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def infracost_configured(self) -> bool:
|
|
162
|
+
mode = self.infracost_mode.lower()
|
|
163
|
+
if mode == "api":
|
|
164
|
+
return bool(self.infracost_api_key)
|
|
165
|
+
if mode == "cli":
|
|
166
|
+
if not self.infracost_cli_path:
|
|
167
|
+
return False
|
|
168
|
+
cli_path = Path(self.infracost_cli_path)
|
|
169
|
+
has_cli = cli_path.exists() or which(self.infracost_cli_path) is not None
|
|
170
|
+
if not has_cli:
|
|
171
|
+
return False
|
|
172
|
+
if self.infracost_pricing_mode.lower() == "cloudcost":
|
|
173
|
+
return bool(self.infracost_api_key and self.infracost_pricing_api_endpoint)
|
|
174
|
+
return bool(self.infracost_pricing_api_endpoint)
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
def require_litellm_master_key(self) -> str:
|
|
178
|
+
if not self.litellm_master_key:
|
|
179
|
+
raise RuntimeError("Set LITELLM_MASTER_KEY.")
|
|
180
|
+
return self.litellm_master_key
|
|
181
|
+
|
|
182
|
+
def require_auth_secret(self) -> str:
|
|
183
|
+
secret = self.auth_secret or self.litellm_master_key or self.github_webhook_secret
|
|
184
|
+
if not secret:
|
|
185
|
+
raise RuntimeError("Set AUTH_SECRET or LITELLM_MASTER_KEY.")
|
|
186
|
+
return secret
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@lru_cache
|
|
190
|
+
def get_settings() -> Settings:
|
|
191
|
+
return Settings()
|