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/main.py
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import html
|
|
7
|
+
import os
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response, status
|
|
15
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
|
16
|
+
from fastapi.staticfiles import StaticFiles
|
|
17
|
+
|
|
18
|
+
from backend.app.auth import (
|
|
19
|
+
LoginRequest,
|
|
20
|
+
ResendSignupOtpRequest,
|
|
21
|
+
SignupRequest,
|
|
22
|
+
VerifySignupRequest,
|
|
23
|
+
generate_otp_code,
|
|
24
|
+
hash_password,
|
|
25
|
+
new_session_token,
|
|
26
|
+
normalize_email,
|
|
27
|
+
public_user,
|
|
28
|
+
session_expires_at,
|
|
29
|
+
verify_password,
|
|
30
|
+
)
|
|
31
|
+
from backend.app.comments import format_error_comment, format_infracost_comment, format_no_plan_comment
|
|
32
|
+
from backend.app.config import Settings, get_settings
|
|
33
|
+
from backend.app.database import (
|
|
34
|
+
append_usage_event,
|
|
35
|
+
append_waitlist_record,
|
|
36
|
+
create_email_otp,
|
|
37
|
+
create_or_update_signup_user,
|
|
38
|
+
create_session,
|
|
39
|
+
delete_session,
|
|
40
|
+
get_user_by_email,
|
|
41
|
+
get_user_for_session,
|
|
42
|
+
init_app_database,
|
|
43
|
+
read_usage_event_records,
|
|
44
|
+
verify_email_otp,
|
|
45
|
+
)
|
|
46
|
+
from backend.app.emailer import send_signup_otp, send_waitlist_confirmation, waitlist_confirmation_available
|
|
47
|
+
from backend.app.github_client import (
|
|
48
|
+
GitHubAppClient,
|
|
49
|
+
GitHubInstallationClient,
|
|
50
|
+
PullRequestContext,
|
|
51
|
+
find_terraform_plan_file,
|
|
52
|
+
)
|
|
53
|
+
from backend.app.infracost import InfracostClient
|
|
54
|
+
from backend.app.litellm_admin import LiteLLMAdminClient, VirtualKeyRequest
|
|
55
|
+
from backend.app.model_pricing import get_model_rates
|
|
56
|
+
from backend.app.security import verify_github_signature
|
|
57
|
+
from backend.app.usage import UsageEvent, sanitize_usage_payload, summarize_usage
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
SUPPORTED_PULL_REQUEST_ACTIONS = {"opened", "reopened", "synchronize", "ready_for_review"}
|
|
61
|
+
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
62
|
+
WAITLIST_ALLOWED_FIELDS = {"email"}
|
|
63
|
+
logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@asynccontextmanager
|
|
67
|
+
async def lifespan(app: FastAPI):
|
|
68
|
+
init_app_database(get_settings())
|
|
69
|
+
yield
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
app = FastAPI(title="CloudCost AI", version="0.1.0", lifespan=lifespan)
|
|
73
|
+
app.mount("/assets", StaticFiles(directory="assets"), name="assets")
|
|
74
|
+
app.mount("/docs", StaticFiles(directory="docs"), name="docs")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def configured_services(settings: Settings) -> dict[str, bool]:
|
|
78
|
+
return {
|
|
79
|
+
"github_app": settings.github_app_configured,
|
|
80
|
+
"infracost": settings.infracost_configured,
|
|
81
|
+
"litellm": bool(settings.litellm_master_key),
|
|
82
|
+
"database": bool(settings.backend_database_url),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def github_manifest_state(settings: Settings) -> str:
|
|
87
|
+
secret = settings.require_manifest_state_secret().encode("utf-8")
|
|
88
|
+
message = f"cloudcost-github-manifest:{settings.public_base_url}".encode("utf-8")
|
|
89
|
+
return hmac.new(secret, message, hashlib.sha256).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def github_app_manifest(settings: Settings) -> dict[str, Any]:
|
|
93
|
+
base_url = settings.public_base_url.rstrip("/")
|
|
94
|
+
host = base_url.replace("https://", "").replace("http://", "").split("/", 1)[0]
|
|
95
|
+
return {
|
|
96
|
+
"name": f"CloudCost AI {host}",
|
|
97
|
+
"url": base_url,
|
|
98
|
+
"hook_attributes": {
|
|
99
|
+
"url": f"{base_url}/api/github/webhook",
|
|
100
|
+
"active": True,
|
|
101
|
+
},
|
|
102
|
+
"redirect_url": f"{base_url}/api/github/manifest/callback",
|
|
103
|
+
"callback_urls": [f"{base_url}/dashboard"],
|
|
104
|
+
"setup_url": f"{base_url}/dashboard",
|
|
105
|
+
"public": False,
|
|
106
|
+
"default_permissions": {
|
|
107
|
+
"contents": "read",
|
|
108
|
+
"metadata": "read",
|
|
109
|
+
"issues": "write",
|
|
110
|
+
"pull_requests": "write",
|
|
111
|
+
},
|
|
112
|
+
"default_events": ["pull_request"],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def store_github_manifest_config(settings: Settings, payload: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
config_path = Path(settings.github_manifest_config_path)
|
|
118
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
key_path = config_path.parent / "github-app-private-key.pem"
|
|
120
|
+
pem = str(payload.get("pem") or "")
|
|
121
|
+
if not pem:
|
|
122
|
+
raise RuntimeError("GitHub did not return a private key.")
|
|
123
|
+
|
|
124
|
+
key_path.write_text(pem, encoding="utf-8")
|
|
125
|
+
try:
|
|
126
|
+
os.chmod(key_path, 0o600)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
config = {
|
|
131
|
+
"id": payload.get("id"),
|
|
132
|
+
"client_id": payload.get("client_id"),
|
|
133
|
+
"slug": payload.get("slug"),
|
|
134
|
+
"name": payload.get("name"),
|
|
135
|
+
"html_url": payload.get("html_url"),
|
|
136
|
+
"webhook_secret": payload.get("webhook_secret"),
|
|
137
|
+
"private_key_path": str(key_path),
|
|
138
|
+
}
|
|
139
|
+
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
140
|
+
try:
|
|
141
|
+
os.chmod(config_path, 0o600)
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
get_settings.cache_clear()
|
|
145
|
+
return config
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def pricing_api_probe(settings: Settings) -> dict[str, Any]:
|
|
149
|
+
mode = settings.infracost_mode.strip().lower()
|
|
150
|
+
pricing_mode = settings.infracost_pricing_mode.strip().lower()
|
|
151
|
+
endpoint = settings.infracost_pricing_api_endpoint
|
|
152
|
+
if mode != "cli":
|
|
153
|
+
return {
|
|
154
|
+
"ready": bool(settings.infracost_api_key),
|
|
155
|
+
"detail": "Hosted Plan JSON API mode",
|
|
156
|
+
"endpoint": settings.infracost_api_url,
|
|
157
|
+
}
|
|
158
|
+
if pricing_mode == "cloudcost":
|
|
159
|
+
return {
|
|
160
|
+
"ready": bool(endpoint and settings.infracost_api_key),
|
|
161
|
+
"detail": "Using CloudCost hosted pricing service.",
|
|
162
|
+
"endpoint": endpoint,
|
|
163
|
+
}
|
|
164
|
+
if not endpoint:
|
|
165
|
+
return {
|
|
166
|
+
"ready": False,
|
|
167
|
+
"detail": "Set INFRACOST_PRICING_API_ENDPOINT for self-hosted CLI mode.",
|
|
168
|
+
"endpoint": None,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
health_url = endpoint.rstrip("/") + "/health"
|
|
172
|
+
try:
|
|
173
|
+
async with httpx.AsyncClient(timeout=2.0) as client:
|
|
174
|
+
response = await client.get(health_url)
|
|
175
|
+
ready = response.status_code == 200
|
|
176
|
+
detail = f"Health check returned HTTP {response.status_code}."
|
|
177
|
+
try:
|
|
178
|
+
payload = response.json()
|
|
179
|
+
if payload.get("status"):
|
|
180
|
+
detail = f"Health check returned {payload['status']}."
|
|
181
|
+
except ValueError:
|
|
182
|
+
pass
|
|
183
|
+
return {"ready": ready, "detail": detail, "endpoint": endpoint}
|
|
184
|
+
except httpx.HTTPError as exc:
|
|
185
|
+
detail = str(exc) or exc.__class__.__name__
|
|
186
|
+
return {"ready": False, "detail": detail, "endpoint": endpoint}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.get("/")
|
|
190
|
+
async def landing_page(
|
|
191
|
+
request: Request,
|
|
192
|
+
settings: Settings = Depends(get_settings),
|
|
193
|
+
):
|
|
194
|
+
if get_user_for_session(settings, request.cookies.get(settings.auth_session_cookie)):
|
|
195
|
+
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
|
196
|
+
return FileResponse("index.html")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.get("/login")
|
|
200
|
+
async def login_page() -> FileResponse:
|
|
201
|
+
return FileResponse("future_auth/auth.html")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.get("/signup")
|
|
205
|
+
async def signup_page() -> FileResponse:
|
|
206
|
+
return FileResponse("future_auth/auth.html")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.get("/dashboard")
|
|
210
|
+
async def dashboard_page() -> FileResponse:
|
|
211
|
+
return FileResponse("future_auth/dashboard.html")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.get("/get-started")
|
|
215
|
+
async def get_started_page() -> FileResponse:
|
|
216
|
+
return FileResponse("get-started.html")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.get("/tester-dashboard")
|
|
220
|
+
async def tester_dashboard_page() -> FileResponse:
|
|
221
|
+
return FileResponse("dashboard.html")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.get("/install/github")
|
|
225
|
+
async def github_manifest_install_page(settings: Settings = Depends(get_settings)) -> HTMLResponse:
|
|
226
|
+
state = github_manifest_state(settings)
|
|
227
|
+
manifest = json.dumps(github_app_manifest(settings), separators=(",", ":"))
|
|
228
|
+
manifest_attr = html.escape(manifest, quote=True)
|
|
229
|
+
state_attr = quote(state)
|
|
230
|
+
base_url = settings.public_base_url.rstrip("/")
|
|
231
|
+
personal_action = f"https://github.com/settings/apps/new?state={state_attr}"
|
|
232
|
+
org_action_base = f"https://github.com/organizations/__ORG__/settings/apps/new?state={state_attr}"
|
|
233
|
+
body = f"""<!doctype html>
|
|
234
|
+
<html lang="en">
|
|
235
|
+
<head>
|
|
236
|
+
<meta charset="utf-8">
|
|
237
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
238
|
+
<title>Install CloudCost AI on GitHub</title>
|
|
239
|
+
<style>
|
|
240
|
+
:root {{ --paper:#ffffff; --soft:#f6f7f3; --surface-soft:#f0f3ed; --ink:#172019; --muted:#68736b; --line:#dfe5dc; --green:#1f6f4a; --blue:#365f78; --gold:#b8842f; --shadow:0 22px 58px rgba(23,32,25,.10); --glow:rgba(31,111,74,.07); --radius:8px; }}
|
|
241
|
+
body.theme-dark {{ color-scheme:dark; --paper:#111315; --soft:#090a0c; --surface-soft:#181b1e; --ink:#f3f4f2; --muted:#9ca3a0; --line:#2a2f33; --green:#8bd7a0; --blue:#8fb8d0; --gold:#d6a853; --shadow:0 18px 46px rgba(0,0,0,.34); --glow:rgba(255,255,255,.035); }}
|
|
242
|
+
* {{ box-sizing:border-box; }}
|
|
243
|
+
body {{ margin:0; min-height:100vh; background:linear-gradient(135deg,#fbfcf8 0%,#f1f5ef 56%,#e8eef2 100%); color:var(--ink); font-family:Inter,system-ui,sans-serif; transition:background-color .18s ease,color .18s ease; }}
|
|
244
|
+
body.theme-dark {{ background:radial-gradient(circle at top left, var(--glow), transparent 320px),var(--soft); }}
|
|
245
|
+
.topbar {{ min-height:78px; display:flex; align-items:center; justify-content:space-between; padding:0 28px; border-bottom:1px solid var(--line); background:color-mix(in srgb, var(--paper) 82%, transparent); }}
|
|
246
|
+
body.theme-dark .topbar {{ background:color-mix(in srgb, var(--paper) 84%, transparent); }}
|
|
247
|
+
.brand {{ display:inline-flex; align-items:center; gap:10px; text-decoration:none; color:var(--ink); font-size:13px; font-weight:950; letter-spacing:.12em; text-transform:uppercase; }}
|
|
248
|
+
.brand-mark {{ width:32px; height:32px; display:grid; place-items:center; border:1px solid var(--line); border-radius:var(--radius); background:var(--ink); color:var(--paper); box-shadow:none; }}
|
|
249
|
+
body.theme-dark .brand-mark {{ color:#090a0c; background:var(--green); border-color:var(--green); }}
|
|
250
|
+
.top-actions {{ display:flex; align-items:center; gap:14px; font-size:12px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; }}
|
|
251
|
+
.top-actions a {{ color:var(--muted); text-decoration:none; }}
|
|
252
|
+
.theme-toggle {{ position:relative; width:64px; height:34px; display:inline-flex; align-items:center; justify-content:space-between; padding:0 9px; border:1px solid var(--line); border-radius:999px; background:var(--paper); color:var(--muted); cursor:pointer; box-shadow:inset 0 0 0 1px rgba(255,255,255,.08); }}
|
|
253
|
+
.theme-toggle .theme-icon {{ position:relative; z-index:1; width:14px; height:14px; display:block; opacity:.7; }}
|
|
254
|
+
.theme-toggle .theme-sun {{ border:2px solid currentColor; border-radius:999px; box-shadow:0 -6px 0 -5px currentColor,0 6px 0 -5px currentColor,6px 0 0 -5px currentColor,-6px 0 0 -5px currentColor; }}
|
|
255
|
+
.theme-toggle .theme-moon {{ border-radius:999px; box-shadow:inset 5px -2px 0 0 currentColor; }}
|
|
256
|
+
.theme-toggle .theme-knob {{ position:absolute; top:4px; left:4px; width:24px; height:24px; border-radius:999px; background:var(--green); box-shadow:0 4px 12px rgba(0,0,0,.18); transition:transform .18s ease,background .18s ease; }}
|
|
257
|
+
body.theme-dark .theme-toggle {{ background:var(--surface-soft); border-color:var(--line); color:var(--ink); box-shadow:0 0 0 3px rgba(139,215,160,.08); }}
|
|
258
|
+
body.theme-dark .theme-toggle .theme-knob {{ transform:translateX(30px); background:var(--green); }}
|
|
259
|
+
body.theme-dark .theme-toggle .theme-moon {{ opacity:1; }}
|
|
260
|
+
body:not(.theme-dark) .theme-toggle .theme-sun {{ opacity:1; }}
|
|
261
|
+
.shell {{ width:min(1060px, calc(100% - 40px)); margin:clamp(42px,7vh,76px) auto 80px; }}
|
|
262
|
+
.hero {{ display:grid; grid-template-columns:minmax(0,1fr) 290px; gap:0; overflow:hidden; border:1px solid var(--line); border-radius:var(--radius); background:var(--paper); box-shadow:var(--shadow); }}
|
|
263
|
+
.hero-main {{ padding:38px; background:linear-gradient(180deg,#ffffff 0%,#f2f6ef 100%); }}
|
|
264
|
+
body.theme-dark .hero-main {{ background:linear-gradient(180deg,#101214 0%,#0c0e10 100%); }}
|
|
265
|
+
.eyebrow {{ margin:0 0 12px; color:var(--muted); font-size:12px; font-weight:900; text-transform:uppercase; letter-spacing:.16em; }}
|
|
266
|
+
h1 {{ margin:0; font-size:clamp(40px,5vw,64px); line-height:1; font-weight:780; letter-spacing:0; }}
|
|
267
|
+
p {{ color:var(--muted); line-height:1.6; }}
|
|
268
|
+
.summary {{ padding:26px; border-left:1px solid var(--line); background:var(--surface-soft); }}
|
|
269
|
+
body.theme-dark .summary {{ background:var(--surface-soft); }}
|
|
270
|
+
.summary strong {{ display:block; margin-bottom:8px; }}
|
|
271
|
+
.panel {{ margin-top:18px; padding:24px; border:1px solid var(--line); border-radius:var(--radius); background:var(--paper); box-shadow:var(--shadow); }}
|
|
272
|
+
.grid {{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin:18px 0; }}
|
|
273
|
+
.step {{ padding:14px; border:1px solid var(--line); border-radius:var(--radius); background:var(--surface-soft); }}
|
|
274
|
+
.step span {{ display:block; color:var(--green); font-size:11px; font-weight:900; text-transform:uppercase; letter-spacing:.12em; }}
|
|
275
|
+
.step strong {{ display:block; margin-top:4px; }}
|
|
276
|
+
label {{ display:block; margin:22px 0 8px; font-weight:900; font-size:12px; text-transform:uppercase; color:var(--blue); letter-spacing:.12em; }}
|
|
277
|
+
input[type=text] {{ width:100%; min-height:48px; padding:0 12px; border:1px solid #cfd9d0; border-radius:var(--radius); background:#ffffff; color:var(--ink); font:inherit; }}
|
|
278
|
+
body.theme-dark input[type=text] {{ border-color:var(--line); background:#111315; }}
|
|
279
|
+
button {{ min-height:48px; padding:0 18px; border:1px solid var(--ink); border-radius:var(--radius); background:var(--ink); color:var(--paper); font-weight:900; text-transform:uppercase; letter-spacing:.08em; cursor:pointer; }}
|
|
280
|
+
body.theme-dark button {{ border-color:var(--green); background:var(--green); color:#090a0c; }}
|
|
281
|
+
code {{ font-family:Consolas,monospace; background:color-mix(in srgb, var(--soft) 76%, transparent); padding:2px 5px; }}
|
|
282
|
+
@media (max-width:760px) {{ .hero{{grid-template-columns:1fr}} .hero-main{{padding:26px}} .summary{{border-left:0;border-top:1px solid var(--line)}} .grid{{grid-template-columns:1fr}} }}
|
|
283
|
+
</style>
|
|
284
|
+
</head>
|
|
285
|
+
<body>
|
|
286
|
+
<header class="topbar"><a class="brand" href="/dashboard"><span class="brand-mark">$</span><span>CloudCost AI</span></a><nav class="top-actions" aria-label="Top navigation"><button class="theme-toggle" id="theme-toggle" type="button" aria-label="Switch to dark mode" aria-pressed="false"><span class="theme-icon theme-sun" aria-hidden="true"></span><span class="theme-icon theme-moon" aria-hidden="true"></span><span class="theme-knob" aria-hidden="true"></span></button><a href="/get-started">Get started</a></nav></header>
|
|
287
|
+
<main class="shell">
|
|
288
|
+
<section class="hero">
|
|
289
|
+
<div class="hero-main">
|
|
290
|
+
<p class="eyebrow">GitHub App install</p>
|
|
291
|
+
<h1>Create the app without manual settings.</h1>
|
|
292
|
+
<p>This creates a customer-owned GitHub App with the exact webhook, events, and permissions CloudCost needs. GitHub returns the App ID, webhook secret, and private key to this server automatically.</p>
|
|
293
|
+
</div>
|
|
294
|
+
<aside class="summary">
|
|
295
|
+
<strong>What GitHub will approve</strong>
|
|
296
|
+
<p>Pull request events, read access for repository contents, and write access for PR timeline comments.</p>
|
|
297
|
+
</aside>
|
|
298
|
+
</section>
|
|
299
|
+
<section class="panel">
|
|
300
|
+
<div class="grid">
|
|
301
|
+
<div class="step"><span>01</span><strong>Approve manifest</strong></div>
|
|
302
|
+
<div class="step"><span>02</span><strong>Choose repositories</strong></div>
|
|
303
|
+
<div class="step"><span>03</span><strong>Receive PR comments</strong></div>
|
|
304
|
+
</div>
|
|
305
|
+
<form id="manifest-form" action="{personal_action}" method="post">
|
|
306
|
+
<input type="hidden" name="manifest" value="{manifest_attr}">
|
|
307
|
+
<label for="org">Organization owner, optional</label>
|
|
308
|
+
<input id="org" type="text" placeholder="Leave blank for personal account">
|
|
309
|
+
<p><button type="submit">Install on GitHub</button></p>
|
|
310
|
+
</form>
|
|
311
|
+
<p>Webhook URL: <code>{html.escape(base_url)}/api/github/webhook</code></p>
|
|
312
|
+
</section>
|
|
313
|
+
</main>
|
|
314
|
+
<script>
|
|
315
|
+
const form = document.getElementById("manifest-form");
|
|
316
|
+
const org = document.getElementById("org");
|
|
317
|
+
const personalAction = {json.dumps(personal_action)};
|
|
318
|
+
const orgActionBase = {json.dumps(org_action_base)};
|
|
319
|
+
form.addEventListener("submit", () => {{
|
|
320
|
+
const value = org.value.trim();
|
|
321
|
+
form.action = value ? orgActionBase.replace("__ORG__", encodeURIComponent(value)) : personalAction;
|
|
322
|
+
}});
|
|
323
|
+
</script>
|
|
324
|
+
<script src="/assets/theme.js"></script>
|
|
325
|
+
</body>
|
|
326
|
+
</html>"""
|
|
327
|
+
return HTMLResponse(body)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@app.get("/api/github/manifest/callback")
|
|
331
|
+
async def github_manifest_callback(
|
|
332
|
+
code: str,
|
|
333
|
+
state: str | None = None,
|
|
334
|
+
settings: Settings = Depends(get_settings),
|
|
335
|
+
) -> HTMLResponse:
|
|
336
|
+
expected_state = github_manifest_state(settings)
|
|
337
|
+
if not state or not hmac.compare_digest(state, expected_state):
|
|
338
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid manifest state.")
|
|
339
|
+
|
|
340
|
+
url = f"{settings.github_api_url}/app-manifests/{code}/conversions"
|
|
341
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
342
|
+
response = await client.post(
|
|
343
|
+
url,
|
|
344
|
+
headers={
|
|
345
|
+
"Accept": "application/vnd.github+json",
|
|
346
|
+
"X-GitHub-Api-Version": settings.github_api_version,
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
response.raise_for_status()
|
|
350
|
+
config = store_github_manifest_config(settings, response.json())
|
|
351
|
+
slug = str(config.get("slug") or "")
|
|
352
|
+
install_url = f"https://github.com/apps/{quote(slug)}/installations/new" if slug else "/dashboard"
|
|
353
|
+
return HTMLResponse(
|
|
354
|
+
f"""<!doctype html>
|
|
355
|
+
<html lang="en">
|
|
356
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GitHub App Created</title>
|
|
357
|
+
<style>
|
|
358
|
+
:root {{ --paper:#ffffff; --soft:#f6f7f3; --surface-soft:#f0f3ed; --ink:#172019; --muted:#68736b; --line:#dfe5dc; --green:#1f6f4a; --blue:#365f78; --gold:#b8842f; --shadow:0 22px 58px rgba(23,32,25,.10); --glow:rgba(31,111,74,.07); --radius:8px; }}
|
|
359
|
+
body.theme-dark {{ color-scheme:dark; --paper:#111315; --soft:#090a0c; --surface-soft:#181b1e; --ink:#f3f4f2; --muted:#9ca3a0; --line:#2a2f33; --green:#8bd7a0; --blue:#8fb8d0; --gold:#d6a853; --shadow:0 18px 46px rgba(0,0,0,.34); --glow:rgba(255,255,255,.035); }}
|
|
360
|
+
body {{ margin:0; min-height:100vh; display:grid; place-items:center; background:linear-gradient(135deg,#fbfcf8 0%,#f1f5ef 56%,#e8eef2 100%); color:var(--ink); font-family:Inter,system-ui,sans-serif; transition:background-color .18s ease,color .18s ease; }}
|
|
361
|
+
body.theme-dark {{ background:radial-gradient(circle at top left, var(--glow), transparent 320px),var(--soft); }}
|
|
362
|
+
main {{ width:min(760px,calc(100% - 32px)); overflow:hidden; background:var(--paper); border:1px solid var(--line); border-radius:var(--radius); padding:0; box-shadow:var(--shadow); }}
|
|
363
|
+
.content {{ padding:34px; background:linear-gradient(180deg,#ffffff 0%,#f2f6ef 100%); }}
|
|
364
|
+
body.theme-dark .content {{ background:linear-gradient(180deg,#101214 0%,#0c0e10 100%); }}
|
|
365
|
+
.theme-toggle {{ position:fixed; top:18px; right:18px; width:64px; height:34px; display:inline-flex; align-items:center; justify-content:space-between; padding:0 9px; border:1px solid var(--line); border-radius:999px; background:var(--paper); color:var(--muted); cursor:pointer; box-shadow:inset 0 0 0 1px rgba(255,255,255,.08); }}
|
|
366
|
+
.theme-toggle .theme-icon {{ position:relative; z-index:1; width:14px; height:14px; display:block; opacity:.7; }}
|
|
367
|
+
.theme-toggle .theme-sun {{ border:2px solid currentColor; border-radius:999px; box-shadow:0 -6px 0 -5px currentColor,0 6px 0 -5px currentColor,6px 0 0 -5px currentColor,-6px 0 0 -5px currentColor; }}
|
|
368
|
+
.theme-toggle .theme-moon {{ border-radius:999px; box-shadow:inset 5px -2px 0 0 currentColor; }}
|
|
369
|
+
.theme-toggle .theme-knob {{ position:absolute; top:4px; left:4px; width:24px; height:24px; border-radius:999px; background:var(--green); box-shadow:0 4px 12px rgba(0,0,0,.18); transition:transform .18s ease,background .18s ease; }}
|
|
370
|
+
body.theme-dark .theme-toggle {{ background:var(--surface-soft); border-color:var(--line); color:var(--ink); box-shadow:0 0 0 3px rgba(139,215,160,.08); }}
|
|
371
|
+
body.theme-dark .theme-toggle .theme-knob {{ transform:translateX(30px); background:var(--green); }}
|
|
372
|
+
body.theme-dark .theme-toggle .theme-moon {{ opacity:1; }}
|
|
373
|
+
body:not(.theme-dark) .theme-toggle .theme-sun {{ opacity:1; }}
|
|
374
|
+
.eyebrow {{ margin:0 0 12px; color:var(--muted); font-size:12px; font-weight:900; text-transform:uppercase; letter-spacing:.16em; }}
|
|
375
|
+
h1 {{ margin:0 0 12px; font-size:clamp(40px,5vw,64px); line-height:1; font-weight:780; }}
|
|
376
|
+
p {{ color:var(--muted); line-height:1.6; }}
|
|
377
|
+
.actions {{ display:flex; gap:12px; flex-wrap:wrap; margin-top:20px; }}
|
|
378
|
+
a.button {{ display:inline-flex; align-items:center; min-height:46px; padding:0 18px; border:1px solid var(--ink); border-radius:var(--radius); background:var(--ink); color:var(--paper); text-decoration:none; font-weight:900; text-transform:uppercase; letter-spacing:.08em; }}
|
|
379
|
+
body.theme-dark a.button {{ border-color:var(--green); background:var(--green); color:#090a0c; }}
|
|
380
|
+
a.secondary {{ background:transparent; color:var(--ink); }}
|
|
381
|
+
</style></head>
|
|
382
|
+
<body>
|
|
383
|
+
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="Switch to dark mode" aria-pressed="false"><span class="theme-icon theme-sun" aria-hidden="true"></span><span class="theme-icon theme-moon" aria-hidden="true"></span><span class="theme-knob" aria-hidden="true"></span></button>
|
|
384
|
+
<main>
|
|
385
|
+
<div class="content">
|
|
386
|
+
<p class="eyebrow">GitHub App ready</p>
|
|
387
|
+
<h1>Now choose repositories.</h1>
|
|
388
|
+
<p>CloudCost saved the App ID, webhook secret, and private key on this server. The final step is choosing which repositories the app can access.</p>
|
|
389
|
+
<div class="actions"><a class="button" href="{html.escape(install_url, quote=True)}">Choose repositories</a><a class="button secondary" href="/dashboard">Return to dashboard</a></div>
|
|
390
|
+
</div>
|
|
391
|
+
</main>
|
|
392
|
+
<script src="/assets/theme.js"></script>
|
|
393
|
+
</body>
|
|
394
|
+
</html>"""
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@app.get("/og-image.png")
|
|
399
|
+
async def og_image() -> FileResponse:
|
|
400
|
+
return FileResponse("assets/cloudcost-hero.png")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.get("/healthz")
|
|
404
|
+
async def healthz(settings: Settings = Depends(get_settings)) -> dict[str, Any]:
|
|
405
|
+
return {
|
|
406
|
+
"ok": True,
|
|
407
|
+
"service": settings.app_name,
|
|
408
|
+
"environment": settings.environment,
|
|
409
|
+
"configured": configured_services(settings),
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def set_session_cookie(response: Response, settings: Settings, token: str) -> None:
|
|
414
|
+
response.set_cookie(
|
|
415
|
+
key=settings.auth_session_cookie,
|
|
416
|
+
value=token,
|
|
417
|
+
max_age=settings.auth_session_days * 24 * 60 * 60,
|
|
418
|
+
path="/",
|
|
419
|
+
httponly=True,
|
|
420
|
+
secure=settings.auth_cookie_secure,
|
|
421
|
+
samesite="lax",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def clear_session_cookie(response: Response, settings: Settings) -> None:
|
|
426
|
+
response.delete_cookie(
|
|
427
|
+
key=settings.auth_session_cookie,
|
|
428
|
+
path="/",
|
|
429
|
+
secure=settings.auth_cookie_secure,
|
|
430
|
+
httponly=True,
|
|
431
|
+
samesite="lax",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def require_current_user(
|
|
436
|
+
request: Request,
|
|
437
|
+
settings: Settings = Depends(get_settings),
|
|
438
|
+
) -> dict[str, Any]:
|
|
439
|
+
token = request.cookies.get(settings.auth_session_cookie)
|
|
440
|
+
user = get_user_for_session(settings, token)
|
|
441
|
+
if not user:
|
|
442
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sign in required.")
|
|
443
|
+
return user
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
async def create_verified_session(
|
|
447
|
+
response: Response,
|
|
448
|
+
settings: Settings,
|
|
449
|
+
user: dict[str, Any],
|
|
450
|
+
) -> dict[str, Any]:
|
|
451
|
+
token = new_session_token()
|
|
452
|
+
create_session(settings, user_id=user["id"], token=token, expires_at=session_expires_at(settings))
|
|
453
|
+
set_session_cookie(response, settings, token)
|
|
454
|
+
return {"ok": True, "user": public_user(user), "redirect": "/dashboard"}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@app.post("/api/auth/signup")
|
|
458
|
+
async def signup(
|
|
459
|
+
payload: SignupRequest,
|
|
460
|
+
settings: Settings = Depends(get_settings),
|
|
461
|
+
) -> dict[str, Any]:
|
|
462
|
+
try:
|
|
463
|
+
email = normalize_email(payload.email)
|
|
464
|
+
user = create_or_update_signup_user(
|
|
465
|
+
settings,
|
|
466
|
+
email=email,
|
|
467
|
+
password_hash=hash_password(payload.password),
|
|
468
|
+
full_name=(payload.full_name or "").strip() or None,
|
|
469
|
+
company=(payload.company or "").strip() or None,
|
|
470
|
+
)
|
|
471
|
+
code = generate_otp_code()
|
|
472
|
+
create_email_otp(settings, user_id=user["id"], email=email, code=code)
|
|
473
|
+
delivery = await send_signup_otp(settings, email, code)
|
|
474
|
+
except ValueError as exc:
|
|
475
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
|
476
|
+
except RuntimeError as exc:
|
|
477
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
478
|
+
|
|
479
|
+
return {"ok": True, "email": email, "delivery_mode": delivery["mode"]}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@app.post("/api/auth/verify-signup")
|
|
483
|
+
async def verify_signup(
|
|
484
|
+
payload: VerifySignupRequest,
|
|
485
|
+
response: Response,
|
|
486
|
+
settings: Settings = Depends(get_settings),
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
try:
|
|
489
|
+
email = normalize_email(payload.email)
|
|
490
|
+
except ValueError as exc:
|
|
491
|
+
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
492
|
+
|
|
493
|
+
user = verify_email_otp(settings, email=email, code=payload.code.strip())
|
|
494
|
+
if not user:
|
|
495
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired code.")
|
|
496
|
+
return await create_verified_session(response, settings, user)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@app.post("/api/auth/resend-signup-otp")
|
|
500
|
+
async def resend_signup_otp(
|
|
501
|
+
payload: ResendSignupOtpRequest,
|
|
502
|
+
settings: Settings = Depends(get_settings),
|
|
503
|
+
) -> dict[str, Any]:
|
|
504
|
+
try:
|
|
505
|
+
email = normalize_email(payload.email)
|
|
506
|
+
except ValueError as exc:
|
|
507
|
+
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
508
|
+
|
|
509
|
+
user = get_user_by_email(settings, email)
|
|
510
|
+
if not user or user.get("email_verified_at"):
|
|
511
|
+
return {"ok": True, "email": email, "delivery_mode": "skipped"}
|
|
512
|
+
|
|
513
|
+
code = generate_otp_code()
|
|
514
|
+
create_email_otp(settings, user_id=user["id"], email=email, code=code)
|
|
515
|
+
delivery = await send_signup_otp(settings, email, code)
|
|
516
|
+
return {"ok": True, "email": email, "delivery_mode": delivery["mode"]}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@app.post("/api/auth/login")
|
|
520
|
+
async def login(
|
|
521
|
+
payload: LoginRequest,
|
|
522
|
+
response: Response,
|
|
523
|
+
settings: Settings = Depends(get_settings),
|
|
524
|
+
) -> dict[str, Any]:
|
|
525
|
+
try:
|
|
526
|
+
email = normalize_email(payload.email)
|
|
527
|
+
except ValueError as exc:
|
|
528
|
+
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
529
|
+
|
|
530
|
+
user = get_user_by_email(settings, email)
|
|
531
|
+
if not user or not verify_password(payload.password, user.get("password_hash")):
|
|
532
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password.")
|
|
533
|
+
if not user.get("email_verified_at"):
|
|
534
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Verify your email before signing in.")
|
|
535
|
+
|
|
536
|
+
return await create_verified_session(response, settings, user)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@app.post("/api/auth/logout")
|
|
540
|
+
async def logout(
|
|
541
|
+
request: Request,
|
|
542
|
+
response: Response,
|
|
543
|
+
settings: Settings = Depends(get_settings),
|
|
544
|
+
) -> dict[str, Any]:
|
|
545
|
+
delete_session(settings, request.cookies.get(settings.auth_session_cookie))
|
|
546
|
+
clear_session_cookie(response, settings)
|
|
547
|
+
return {"ok": True}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@app.get("/api/auth/me")
|
|
551
|
+
async def auth_me(user: dict[str, Any] = Depends(require_current_user)) -> dict[str, Any]:
|
|
552
|
+
return {"ok": True, "user": public_user(user)}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@app.get("/api/dashboard/summary")
|
|
556
|
+
async def dashboard_summary(
|
|
557
|
+
user: dict[str, Any] = Depends(require_current_user),
|
|
558
|
+
settings: Settings = Depends(get_settings),
|
|
559
|
+
) -> dict[str, Any]:
|
|
560
|
+
configured = configured_services(settings)
|
|
561
|
+
usage_summary = summarize_usage(read_usage_event_records(settings, limit=1000))
|
|
562
|
+
return {
|
|
563
|
+
"ok": True,
|
|
564
|
+
"user": public_user(user),
|
|
565
|
+
"llm_usage": usage_summary,
|
|
566
|
+
"configured": configured,
|
|
567
|
+
"integrations": {
|
|
568
|
+
"github_webhook": f"{settings.public_base_url.rstrip('/')}/api/github/webhook",
|
|
569
|
+
"infracost_mode": settings.infracost_mode,
|
|
570
|
+
"litellm_base_url": settings.litellm_base_url,
|
|
571
|
+
},
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@app.get("/api/dashboard/tutorial-status")
|
|
576
|
+
async def dashboard_tutorial_status(settings: Settings = Depends(get_settings)) -> dict[str, Any]:
|
|
577
|
+
configured = configured_services(settings)
|
|
578
|
+
pricing = await pricing_api_probe(settings)
|
|
579
|
+
webhook_url = f"{settings.public_base_url.rstrip('/')}/api/github/webhook"
|
|
580
|
+
systems = [
|
|
581
|
+
{
|
|
582
|
+
"id": "backend",
|
|
583
|
+
"label": "FastAPI backend",
|
|
584
|
+
"ready": True,
|
|
585
|
+
"detail": "Serving the dashboard and webhook.",
|
|
586
|
+
"next_step": "Keep the backend process running on port 8000.",
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
"id": "github",
|
|
590
|
+
"label": "GitHub App",
|
|
591
|
+
"ready": configured["github_app"],
|
|
592
|
+
"detail": f"Webhook URL: {webhook_url}",
|
|
593
|
+
"next_step": "Install the app, subscribe to pull_request, and allow Issues/Pull requests write access.",
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
"id": "infracost",
|
|
597
|
+
"label": "Infracost CLI",
|
|
598
|
+
"ready": configured["infracost"],
|
|
599
|
+
"detail": f"Mode: {settings.infracost_mode}; CLI: {settings.infracost_cli_path}",
|
|
600
|
+
"next_step": "Install the CLI or set INFRACOST_CLI_PATH to a working binary.",
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
"id": "pricing",
|
|
604
|
+
"label": "Pricing API",
|
|
605
|
+
"ready": pricing["ready"],
|
|
606
|
+
"detail": pricing["detail"],
|
|
607
|
+
"endpoint": pricing["endpoint"],
|
|
608
|
+
"next_step": "Use CloudCost hosted pricing or start the self-hosted pricing API.",
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
"id": "database",
|
|
612
|
+
"label": "Database",
|
|
613
|
+
"ready": configured["database"],
|
|
614
|
+
"detail": "Neon/Postgres configured." if configured["database"] else "JSONL fallback or DATABASE_URL missing.",
|
|
615
|
+
"next_step": "Set DATABASE_URL or APP_DATABASE_URL for persistent shared state.",
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
"id": "litellm",
|
|
619
|
+
"label": "LiteLLM",
|
|
620
|
+
"ready": configured["litellm"],
|
|
621
|
+
"detail": f"Base URL: {settings.litellm_base_url}",
|
|
622
|
+
"next_step": "Start LiteLLM and set LITELLM_MASTER_KEY.",
|
|
623
|
+
},
|
|
624
|
+
]
|
|
625
|
+
ready_count = sum(1 for item in systems if item["ready"])
|
|
626
|
+
return {
|
|
627
|
+
"ok": True,
|
|
628
|
+
"ready_count": ready_count,
|
|
629
|
+
"total_count": len(systems),
|
|
630
|
+
"systems": systems,
|
|
631
|
+
"trial_goal": "Open or update a GitHub pull request containing Terraform plan JSON and confirm CloudCost posts one PR comment.",
|
|
632
|
+
"install": {
|
|
633
|
+
"github_manifest_url": "/install/github",
|
|
634
|
+
"webhook_url": webhook_url,
|
|
635
|
+
},
|
|
636
|
+
"docs": {
|
|
637
|
+
"tester_guide": "/docs/new-user-tutorial.md",
|
|
638
|
+
"self_hosted_infracost": "/docs/pricing-api.html",
|
|
639
|
+
"vps_one_click": "/docs/vps-install.html",
|
|
640
|
+
},
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@app.get("/api/mechanism")
|
|
645
|
+
async def mechanism(settings: Settings = Depends(get_settings)) -> dict[str, Any]:
|
|
646
|
+
return {
|
|
647
|
+
"ai_spend_proxy": {
|
|
648
|
+
"proxy": "LiteLLM",
|
|
649
|
+
"base_url": settings.litellm_base_url,
|
|
650
|
+
"virtual_key_endpoint": "/api/llm/keys",
|
|
651
|
+
"usage_ingest_endpoint": "/api/llm/usage",
|
|
652
|
+
"privacy": "Only metadata, token counts, tags, and cost are accepted by the backend usage store.",
|
|
653
|
+
},
|
|
654
|
+
"github_app": {
|
|
655
|
+
"webhook_endpoint": "/api/github/webhook",
|
|
656
|
+
"events": ["pull_request"],
|
|
657
|
+
"actions": sorted(SUPPORTED_PULL_REQUEST_ACTIONS),
|
|
658
|
+
"costing": "Find Terraform plan JSON in PR files, call Infracost, and upsert a PR timeline comment.",
|
|
659
|
+
},
|
|
660
|
+
"database": {
|
|
661
|
+
"backend_store": "postgres" if settings.backend_database_url else "jsonl",
|
|
662
|
+
"litellm_key_store": "postgres via DATABASE_URL",
|
|
663
|
+
"tables": ["cloudcost_waitlist", "cloudcost_usage_events"],
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@app.get("/api/pricing/models")
|
|
669
|
+
async def pricing_models() -> dict[str, Any]:
|
|
670
|
+
rates, source = get_model_rates()
|
|
671
|
+
return {
|
|
672
|
+
"unit": "USD per 1M tokens",
|
|
673
|
+
"source": source,
|
|
674
|
+
"note": "Calculator rates are read from the local LiteLLM pricing map when available.",
|
|
675
|
+
"models": rates,
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@app.post("/api/waitlist")
|
|
680
|
+
async def join_waitlist(
|
|
681
|
+
request: Request,
|
|
682
|
+
settings: Settings = Depends(get_settings),
|
|
683
|
+
):
|
|
684
|
+
content_type = request.headers.get("content-type", "")
|
|
685
|
+
if "application/json" in content_type:
|
|
686
|
+
payload = await request.json()
|
|
687
|
+
else:
|
|
688
|
+
form = await request.form()
|
|
689
|
+
payload = dict(form)
|
|
690
|
+
|
|
691
|
+
if not isinstance(payload, dict):
|
|
692
|
+
raise HTTPException(status_code=422, detail="Submit only an email address.")
|
|
693
|
+
|
|
694
|
+
extra_fields = set(payload) - WAITLIST_ALLOWED_FIELDS
|
|
695
|
+
if extra_fields:
|
|
696
|
+
raise HTTPException(status_code=422, detail="Submit only an email address.")
|
|
697
|
+
|
|
698
|
+
email = str(payload.get("email", "")).strip().lower()
|
|
699
|
+
if len(email) > 254 or not EMAIL_PATTERN.fullmatch(email):
|
|
700
|
+
raise HTTPException(status_code=422, detail="Enter a valid work email.")
|
|
701
|
+
|
|
702
|
+
inserted = append_waitlist_record(
|
|
703
|
+
settings,
|
|
704
|
+
{
|
|
705
|
+
"email": email,
|
|
706
|
+
"source": "landing_page",
|
|
707
|
+
"plan": "early_access",
|
|
708
|
+
},
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
if inserted:
|
|
712
|
+
try:
|
|
713
|
+
await send_waitlist_confirmation(settings, email)
|
|
714
|
+
except Exception as exc:
|
|
715
|
+
logger.warning("Waitlist confirmation email failed: %s", exc.__class__.__name__)
|
|
716
|
+
|
|
717
|
+
wants_json = "application/json" in request.headers.get("accept", "")
|
|
718
|
+
if wants_json:
|
|
719
|
+
message = (
|
|
720
|
+
"Your early access request is in. If this address is new, a confirmation email is on the way."
|
|
721
|
+
if waitlist_confirmation_available(settings)
|
|
722
|
+
else "Your early access request is in."
|
|
723
|
+
)
|
|
724
|
+
return JSONResponse({"ok": True, "email": email, "message": message})
|
|
725
|
+
|
|
726
|
+
return RedirectResponse(url="/?waitlist=joined#waitlist", status_code=status.HTTP_303_SEE_OTHER)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@app.post("/api/github/webhook")
|
|
730
|
+
async def github_webhook(
|
|
731
|
+
request: Request,
|
|
732
|
+
x_github_event: str | None = Header(default=None),
|
|
733
|
+
x_hub_signature_256: str | None = Header(default=None),
|
|
734
|
+
settings: Settings = Depends(get_settings),
|
|
735
|
+
) -> dict[str, Any]:
|
|
736
|
+
raw_body = await request.body()
|
|
737
|
+
if not verify_github_signature(
|
|
738
|
+
raw_body,
|
|
739
|
+
settings.require_github_webhook_secret(),
|
|
740
|
+
x_hub_signature_256,
|
|
741
|
+
):
|
|
742
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid GitHub signature.")
|
|
743
|
+
|
|
744
|
+
payload = json.loads(raw_body.decode("utf-8"))
|
|
745
|
+
if x_github_event == "ping":
|
|
746
|
+
return {"ok": True, "event": "ping"}
|
|
747
|
+
if x_github_event != "pull_request":
|
|
748
|
+
return {"ok": True, "ignored": True, "reason": f"Unsupported event: {x_github_event}"}
|
|
749
|
+
|
|
750
|
+
action = payload.get("action")
|
|
751
|
+
if action not in SUPPORTED_PULL_REQUEST_ACTIONS:
|
|
752
|
+
return {"ok": True, "ignored": True, "reason": f"Unsupported pull_request action: {action}"}
|
|
753
|
+
|
|
754
|
+
pr = PullRequestContext.from_payload(payload)
|
|
755
|
+
if pr.is_draft:
|
|
756
|
+
return {"ok": True, "ignored": True, "reason": "Draft pull request."}
|
|
757
|
+
|
|
758
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
759
|
+
app_client = GitHubAppClient(settings, client=client)
|
|
760
|
+
token = await app_client.create_installation_token(pr.installation_id)
|
|
761
|
+
gh = GitHubInstallationClient(settings, token=token, client=client)
|
|
762
|
+
files = await gh.list_pull_files(pr.owner, pr.repo, pr.number)
|
|
763
|
+
plan_file = find_terraform_plan_file(files, settings)
|
|
764
|
+
plan_source = "pull_request_files"
|
|
765
|
+
if plan_file is None:
|
|
766
|
+
repository_files = await gh.list_repository_tree_files(pr.owner, pr.repo, pr.head_sha)
|
|
767
|
+
plan_file = find_terraform_plan_file(repository_files, settings)
|
|
768
|
+
plan_source = "repository_tree"
|
|
769
|
+
|
|
770
|
+
if plan_file is None:
|
|
771
|
+
if settings.comment_when_no_plan:
|
|
772
|
+
await gh.upsert_pr_comment(pr.owner, pr.repo, pr.number, format_no_plan_comment(pr.title))
|
|
773
|
+
return {"ok": True, "estimated": False, "reason": "No Terraform plan JSON found."}
|
|
774
|
+
|
|
775
|
+
try:
|
|
776
|
+
plan_json = await gh.fetch_blob(pr.owner, pr.repo, plan_file.sha)
|
|
777
|
+
estimate = await InfracostClient(settings, client=client).estimate_plan_json(
|
|
778
|
+
plan_json,
|
|
779
|
+
filename=plan_file.path.rsplit("/", 1)[-1],
|
|
780
|
+
)
|
|
781
|
+
body = format_infracost_comment(
|
|
782
|
+
plan_path=plan_file.path,
|
|
783
|
+
diff_total_monthly_cost=estimate.diff_total_monthly_cost,
|
|
784
|
+
total_monthly_cost=estimate.total_monthly_cost,
|
|
785
|
+
past_total_monthly_cost=estimate.past_total_monthly_cost,
|
|
786
|
+
pr_title=pr.title,
|
|
787
|
+
)
|
|
788
|
+
await gh.upsert_pr_comment(pr.owner, pr.repo, pr.number, body)
|
|
789
|
+
return {
|
|
790
|
+
"ok": True,
|
|
791
|
+
"estimated": True,
|
|
792
|
+
"repo": pr.full_name,
|
|
793
|
+
"pull_request": pr.number,
|
|
794
|
+
"plan_path": plan_file.path,
|
|
795
|
+
"plan_source": plan_source,
|
|
796
|
+
"diff_total_monthly_cost": estimate.diff_total_monthly_cost,
|
|
797
|
+
}
|
|
798
|
+
except Exception as exc:
|
|
799
|
+
await gh.upsert_pr_comment(pr.owner, pr.repo, pr.number, format_error_comment(pr.title, str(exc)))
|
|
800
|
+
raise
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@app.post("/api/llm/keys")
|
|
804
|
+
async def create_llm_virtual_key(
|
|
805
|
+
payload: VirtualKeyRequest,
|
|
806
|
+
settings: Settings = Depends(get_settings),
|
|
807
|
+
) -> dict[str, Any]:
|
|
808
|
+
return await LiteLLMAdminClient(settings).create_virtual_key(payload)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@app.post("/api/llm/usage")
|
|
812
|
+
async def ingest_llm_usage(
|
|
813
|
+
request: Request,
|
|
814
|
+
x_cloudcost_token: str | None = Header(default=None),
|
|
815
|
+
settings: Settings = Depends(get_settings),
|
|
816
|
+
) -> dict[str, Any]:
|
|
817
|
+
if settings.usage_ingest_token and x_cloudcost_token != settings.usage_ingest_token:
|
|
818
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid usage ingest token.")
|
|
819
|
+
|
|
820
|
+
raw_payload = await request.json()
|
|
821
|
+
clean_payload = sanitize_usage_payload(raw_payload)
|
|
822
|
+
event = UsageEvent.model_validate(clean_payload)
|
|
823
|
+
append_usage_event(settings, event.model_dump(mode="json"))
|
|
824
|
+
return {"ok": True}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
@app.get("/api/llm/usage/summary")
|
|
828
|
+
async def llm_usage_summary(
|
|
829
|
+
limit: int = 1000,
|
|
830
|
+
settings: Settings = Depends(get_settings),
|
|
831
|
+
) -> dict[str, Any]:
|
|
832
|
+
events = read_usage_event_records(settings, limit=limit)
|
|
833
|
+
return summarize_usage(events)
|