feat(CG5-onboard): token-basert invite-system + offentlig onboard-flow
This commit is contained in:
parent
099c1cceab
commit
4f3a802883
44
agents/invite_store.py
Normal file
44
agents/invite_store.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""
|
||||
invite_store.py — Enkel Firestore-basert invite-token store.
|
||||
Brukes av POST /onboard/invite og GET /onboard/invite/{token}.
|
||||
"""
|
||||
import secrets
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
TOKEN_TTL_HOURS = 72
|
||||
|
||||
|
||||
class InviteStore:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
self._col = "onboard_invites"
|
||||
|
||||
def create(self, company: str, email: str, tier: str, created_by: str = "system") -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=TOKEN_TTL_HOURS)
|
||||
self.db.collection(self._col).document(token).set({
|
||||
"company": company,
|
||||
"email": email,
|
||||
"tier": tier,
|
||||
"created_by": created_by,
|
||||
"created_at": datetime.datetime.utcnow().isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"used": False,
|
||||
})
|
||||
return token
|
||||
|
||||
def get(self, token: str) -> Optional[dict]:
|
||||
doc = self.db.collection(self._col).document(token).get()
|
||||
if not doc.exists:
|
||||
return None
|
||||
data = doc.to_dict()
|
||||
if data.get("used"):
|
||||
return None
|
||||
expires_at = datetime.datetime.fromisoformat(data["expires_at"])
|
||||
if datetime.datetime.utcnow() > expires_at:
|
||||
return None
|
||||
return data
|
||||
|
||||
def mark_used(self, token: str):
|
||||
self.db.collection(self._col).document(token).update({"used": True})
|
||||
436
main.py
436
main.py
|
|
@ -10,14 +10,15 @@ CG5a: POST /notify/webhook — push til Slack/Teams/Chat/Discord/etc.
|
|||
CG5b: POST /notify/email — SendGrid digest til kunde-e-post.
|
||||
CG5c: POST /notify/sms — Twilio SMS, Guard+-tier, spike-varsler.
|
||||
CG5-onboard: GET /onboard + POST /onboard/complete — klient-onboarding wizard.
|
||||
CG5-invite: POST /onboard/invite — opprett token-link (intern).
|
||||
GET /onboard/invite/{token} — offentlig onboard-inngang (IAP-exempt).
|
||||
DOCS: GET /docs/{path} — proxy til privat GitHub-repo via ADC/PAT.
|
||||
OQ-29: GET /notify/channels — list konfigurerte delivery-kanaler (env-var-basert). ✅
|
||||
OQ-31: GET /opax/build-status — ekte Cloud Build-status via REST API. ✅
|
||||
CG4-credits: GET /billing/credits — kreditt-saldo, burn rate og tom-dato.
|
||||
Krever GOOGLE_CREDIT_TOTAL_USD i Cloud Run env for runway-beregning.
|
||||
TERMINAL: POST /terminal/exec — whitelisted kommandoer: health, billing, build, logs, help. ✅
|
||||
TERMINAL: POST /terminal/exec — whitelisted kommandoer. ✅
|
||||
VM: GET /vm/ssh-key — henter public key fra Compute Engine metadata. ✅
|
||||
ML-3a: POST /emma — intern agent (Emma Vauger). EMMA_BACKEND=vertex (nå) | local (VM-trigger). ✅
|
||||
ML-3a: POST /emma — intern agent (Emma Vauger). EMMA_BACKEND=vertex|local. ✅
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -45,7 +46,7 @@ from ml.billing_agent import BillingAgent
|
|||
from agents.aws_billing_agent import AWSBillingAgent
|
||||
from ml.anomaly_detector import AnomalyDetector
|
||||
from auth.token_store import save_token
|
||||
|
||||
from agents.invite_store import InviteStore
|
||||
|
||||
from google.cloud import firestore
|
||||
import firebase_admin
|
||||
|
|
@ -61,19 +62,15 @@ from sendgrid.helpers.mail import Mail
|
|||
|
||||
AGENT_ID = "osvauco-opax"
|
||||
PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
|
||||
BASE_URL = os.environ.get("BASE_URL", "https://opax.vauco.no")
|
||||
|
||||
# Guard+-tiers som får tilgang til SMS-varsler
|
||||
SMS_ALLOWED_TIERS = {"guard", "shield", "enterprise"}
|
||||
|
||||
# ── EMMA: backend-flagg ───────────────────────────────────────────────────────
|
||||
# EMMA_BACKEND=vertex → Vertex AI (nå, bruker eksisterende kreditter)
|
||||
# EMMA_BACKEND=local → lokal Gemma 4 på g2-standard-4 Spot VM (trigger: første kunde)
|
||||
# EMMA_LOCAL_URL → http://<VM-IP>:8000/generate (kun brukt når EMMA_BACKEND=local)
|
||||
EMMA_BACKEND = os.environ.get("EMMA_BACKEND", "vertex")
|
||||
EMMA_LOCAL_URL = os.environ.get("EMMA_LOCAL_URL", "http://localhost:8000/generate")
|
||||
EMMA_MODEL = os.environ.get("EMMA_MODEL", "google/gemma-3-12b-it") # Vertex model ID
|
||||
EMMA_MODEL = os.environ.get("EMMA_MODEL", "google/gemma-3-12b-it")
|
||||
|
||||
# Last Emma sitt system-prompt fra world.md ved oppstart
|
||||
_WORLD_MD_PATH = pathlib.Path(__file__).parent / "docs" / "gemma" / "world.md"
|
||||
try:
|
||||
_EMMA_SYSTEM_PROMPT = _WORLD_MD_PATH.read_text(encoding="utf-8")
|
||||
|
|
@ -86,12 +83,16 @@ app = FastAPI(
|
|||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Paths exempt from IAP enforcement
|
||||
# IAP-exempt: helsepunkter + offentlig onboarding-inngang
|
||||
IAP_EXEMPT_PATHS = {"/health", "/healthz", "/readiness", "/liveness", "/onboard"}
|
||||
IAP_EXEMPT_PREFIXES = ("/onboard/invite/",)
|
||||
|
||||
@app.middleware("http")
|
||||
async def require_iap(request: Request, call_next):
|
||||
if request.url.path in IAP_EXEMPT_PATHS:
|
||||
path = request.url.path
|
||||
if path in IAP_EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
if any(path.startswith(p) for p in IAP_EXEMPT_PREFIXES):
|
||||
return await call_next(request)
|
||||
if not request.headers.get("x-goog-authenticated-user-email"):
|
||||
return Response(status_code=401, content="Unauthorized")
|
||||
|
|
@ -119,7 +120,6 @@ oauth.register(
|
|||
client_kwargs={'scope': 'openid email profile'}
|
||||
)
|
||||
|
||||
|
||||
@app.get('/auth/login')
|
||||
async def login(request: Request):
|
||||
redirect_uri = "https://opax.vauco.no/auth/callback"
|
||||
|
|
@ -133,7 +133,6 @@ async def auth(request: Request):
|
|||
request.session['user'] = dict(user)
|
||||
return RedirectResponse(url='/static/billing-dashboard.html')
|
||||
|
||||
|
||||
@app.get("/auth/me")
|
||||
async def me(request: Request):
|
||||
user = request.session.get("user")
|
||||
|
|
@ -141,7 +140,6 @@ async def auth(request: Request):
|
|||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return JSONResponse(user)
|
||||
|
||||
|
||||
@app.get('/auth/logout')
|
||||
async def logout(request: Request):
|
||||
request.session.pop('user', None)
|
||||
|
|
@ -153,7 +151,6 @@ ALERT_EMAIL = os.environ.get("ALERT_EMAIL")
|
|||
|
||||
|
||||
def require_auth(func):
|
||||
"""Decorator to protect endpoints that require authentication."""
|
||||
@wraps(func)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
user = request.session.get('user')
|
||||
|
|
@ -171,7 +168,7 @@ if _static_dir.is_dir():
|
|||
app.mount("/static", StaticFiles(directory=str(_static_dir), html=True), name="static")
|
||||
|
||||
|
||||
# ── DOCS PROXY (MD-filer fra privat GitHub-repo) ──────────────────────────────
|
||||
# ── DOCS PROXY ─────────────────────────────────────────────────────────────────
|
||||
_GH_REPO = "vauco-saas/OSVauco"
|
||||
_GH_BRANCH = os.environ.get("DOCS_BRANCH", "main")
|
||||
|
||||
|
|
@ -188,13 +185,13 @@ async def proxy_doc(path: str):
|
|||
if resp.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail=f"Fil ikke funnet: {path}")
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(status_code=503, detail="GitHub auth feilet — sjekk GITHUB_PAT i Secret Manager")
|
||||
raise HTTPException(status_code=503, detail="GitHub auth feilet")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="GitHub feil")
|
||||
return Response(content=resp.text, media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
# ── OQ-31: Cloud Build status (REST API) ─────────────────────────────────────
|
||||
# ── OQ-31: Cloud Build status ────────────────────────────────────────────────────
|
||||
@app.get("/opax/build-status")
|
||||
async def build_status():
|
||||
try:
|
||||
|
|
@ -214,7 +211,7 @@ async def build_status():
|
|||
return JSONResponse({"status": "error", "message": str(e)}, status_code=200)
|
||||
|
||||
|
||||
# ── TERMINAL: POST /terminal/exec ─────────────────────────────────────────────
|
||||
# ── TERMINAL ───────────────────────────────────────────────────────────────────
|
||||
TERMINAL_WHITELIST = {"health", "billing", "build", "logs", "help"}
|
||||
|
||||
class TerminalExecRequest(BaseModel):
|
||||
|
|
@ -224,32 +221,18 @@ class TerminalExecRequest(BaseModel):
|
|||
async def terminal_exec(req: TerminalExecRequest):
|
||||
cmd = req.cmd.strip().lower().split()[0] if req.cmd.strip() else ""
|
||||
if cmd not in TERMINAL_WHITELIST:
|
||||
return JSONResponse(
|
||||
{"output": f"Ukjent kommando: '{req.cmd}'. Lov: {', '.join(sorted(TERMINAL_WHITELIST))}", "exit_code": 1}
|
||||
)
|
||||
|
||||
return JSONResponse({"output": f"Ukjent kommando: '{req.cmd}'. Lov: {', '.join(sorted(TERMINAL_WHITELIST))}", "exit_code": 1})
|
||||
try:
|
||||
if cmd == "help":
|
||||
lines = [
|
||||
"Tilgjengelige kommandoer:",
|
||||
" health — sjekk /health",
|
||||
" billing — hent billing summary",
|
||||
" build — hent siste build-status",
|
||||
" logs — siste 20 linjer fra Cloud Logging",
|
||||
" help — vis denne listen",
|
||||
]
|
||||
return {"output": "\n".join(lines), "exit_code": 0}
|
||||
|
||||
return {"output": "Tilgjengelige kommandoer:\n health \u2014 sjekk /health\n billing \u2014 hent billing summary\n build \u2014 hent siste build-status\n logs \u2014 siste 20 linjer fra Cloud Logging\n help \u2014 vis denne listen", "exit_code": 0}
|
||||
if cmd == "health":
|
||||
return {"output": json.dumps({"status": "ok"}, ensure_ascii=False), "exit_code": 0}
|
||||
|
||||
if cmd == "billing":
|
||||
try:
|
||||
summary = BillingAgent().get_summary()
|
||||
return {"output": json.dumps(summary, ensure_ascii=False, indent=2, default=str), "exit_code": 0}
|
||||
except Exception as e:
|
||||
return {"output": f"billing feilet: {e}", "exit_code": 1}
|
||||
|
||||
if cmd == "build":
|
||||
try:
|
||||
creds, project = google.auth.default()
|
||||
|
|
@ -259,34 +242,17 @@ async def terminal_exec(req: TerminalExecRequest):
|
|||
r = await client.get(url, headers={"Authorization": f"Bearer {creds.token}"})
|
||||
r.raise_for_status()
|
||||
builds = r.json().get("builds", [])
|
||||
result = [{"id": b["id"], "status": b["status"],
|
||||
"branch": b.get("substitutions", {}).get("BRANCH_NAME", ""),
|
||||
"createTime": b["createTime"]} for b in builds]
|
||||
result = [{"id": b["id"], "status": b["status"], "branch": b.get("substitutions", {}).get("BRANCH_NAME", ""), "createTime": b["createTime"]} for b in builds]
|
||||
return {"output": json.dumps(result, ensure_ascii=False, indent=2, default=str), "exit_code": 0}
|
||||
except Exception as e:
|
||||
return {"output": f"build feilet: {e}", "exit_code": 1}
|
||||
|
||||
if cmd == "logs":
|
||||
try:
|
||||
creds, project = google.auth.default()
|
||||
creds.refresh(google.auth.transport.requests.Request())
|
||||
log_filter = (
|
||||
'resource.type="cloud_run_revision" '
|
||||
'resource.labels.service_name="osvauco-agent" '
|
||||
'severity>=DEFAULT'
|
||||
)
|
||||
body = {
|
||||
"resourceNames": [f"projects/{project}"],
|
||||
"filter": log_filter,
|
||||
"orderBy": "timestamp desc",
|
||||
"pageSize": 20,
|
||||
}
|
||||
body = {"resourceNames": [f"projects/{project}"], "filter": 'resource.type="cloud_run_revision" resource.labels.service_name="osvauco-agent" severity>=DEFAULT', "orderBy": "timestamp desc", "pageSize": 20}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.post(
|
||||
"https://logging.googleapis.com/v2/entries:list",
|
||||
headers={"Authorization": f"Bearer {creds.token}"},
|
||||
json=body,
|
||||
)
|
||||
r = await client.post("https://logging.googleapis.com/v2/entries:list", headers={"Authorization": f"Bearer {creds.token}"}, json=body)
|
||||
entries = r.json().get("entries", [])
|
||||
if not entries:
|
||||
return {"output": "(ingen logglinjer funnet)", "exit_code": 0}
|
||||
|
|
@ -299,44 +265,33 @@ async def terminal_exec(req: TerminalExecRequest):
|
|||
return {"output": "\n".join(lines), "exit_code": 0}
|
||||
except Exception as e:
|
||||
return {"output": f"logs feilet: {e}", "exit_code": 1}
|
||||
|
||||
except Exception as e:
|
||||
return {"output": f"Intern feil: {e}", "exit_code": 1}
|
||||
|
||||
return {"output": "", "exit_code": 0}
|
||||
|
||||
|
||||
# ── VM: GET /vm/ssh-key ────────────────────────────────────────────────────────
|
||||
# ── VM SSH-KEY ──────────────────────────────────────────────────────────────────
|
||||
@app.get("/vm/ssh-key")
|
||||
async def vm_ssh_key(instance: str = "osvauco-dev-vm"):
|
||||
try:
|
||||
creds, project = google.auth.default()
|
||||
creds.refresh(google.auth.transport.requests.Request())
|
||||
zone = "us-central1-b"
|
||||
url = (
|
||||
f"https://compute.googleapis.com/compute/v1/projects/{project}"
|
||||
f"/zones/{zone}/instances/{instance}"
|
||||
)
|
||||
url = f"https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/instances/{instance}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.get(url, headers={"Authorization": f"Bearer {creds.token}"})
|
||||
if r.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail=f"Instans ikke funnet: {instance}")
|
||||
if r.status_code != 200:
|
||||
raise HTTPException(status_code=r.status_code, detail=f"Compute API feil: {r.text[:200]}")
|
||||
|
||||
data = r.json()
|
||||
metadata_items = data.get("metadata", {}).get("items", [])
|
||||
ssh_keys_raw = next(
|
||||
(item["value"] for item in metadata_items if item["key"] == "ssh-keys"),
|
||||
None
|
||||
)
|
||||
ssh_keys_raw = next((item["value"] for item in metadata_items if item["key"] == "ssh-keys"), None)
|
||||
if not ssh_keys_raw:
|
||||
return {"public_key": None, "instance": instance, "detail": "Ingen SSH-nøkkel funnet i metadata"}
|
||||
|
||||
public_key = ssh_keys_raw.strip()
|
||||
if ":" in public_key:
|
||||
public_key = public_key.split(":", 1)[1].strip()
|
||||
|
||||
return {"public_key": public_key, "instance": instance}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -344,36 +299,24 @@ async def vm_ssh_key(instance: str = "osvauco-dev-vm"):
|
|||
raise HTTPException(status_code=500, detail=f"vm/ssh-key feilet: {e}")
|
||||
|
||||
|
||||
# ── OQ-29: GET /notify/channels ───────────────────────────────────────────────
|
||||
# ── OQ-29: NOTIFY CHANNELS ───────────────────────────────────────────────────
|
||||
@app.get("/notify/channels")
|
||||
async def get_notify_channels():
|
||||
return {
|
||||
"channels": [
|
||||
{"type": "email", "configured": bool(os.getenv("SENDGRID_API_KEY")), "target": os.getenv("NOTIFY_EMAIL_TO", None)},
|
||||
{"type": "sms", "configured": bool(os.getenv("TWILIO_ACCOUNT_SID")), "target": os.getenv("TWILIO_FROM_NUMBER", None)},
|
||||
{"type": "webhook", "configured": bool(os.getenv("NOTIFY_WEBHOOK_URL")), "url": os.getenv("NOTIFY_WEBHOOK_URL", None)},
|
||||
]
|
||||
}
|
||||
return {"channels": [
|
||||
{"type": "email", "configured": bool(os.getenv("SENDGRID_API_KEY")), "target": os.getenv("NOTIFY_EMAIL_TO")},
|
||||
{"type": "sms", "configured": bool(os.getenv("TWILIO_ACCOUNT_SID")), "target": os.getenv("TWILIO_FROM_NUMBER")},
|
||||
{"type": "webhook", "configured": bool(os.getenv("NOTIFY_WEBHOOK_URL")), "url": os.getenv("NOTIFY_WEBHOOK_URL")},
|
||||
]}
|
||||
|
||||
|
||||
# ── EMMA: ML-3a intern agent ──────────────────────────────────────────────────
|
||||
# ── EMMA: ML-3a ────────────────────────────────────────────────────────────────────
|
||||
class EmmaRequest(BaseModel):
|
||||
message: str
|
||||
session_id: str = "emma-default"
|
||||
history: Optional[List[Dict[str, str]]] = Field(
|
||||
default=None,
|
||||
description="Valgfri samtalehistorikk: [{role: user|model, content: str}]"
|
||||
)
|
||||
history: Optional[List[Dict[str, str]]] = Field(default=None)
|
||||
|
||||
|
||||
class EmmaChatService:
|
||||
"""
|
||||
Håndterer Emma-kall mot enten:
|
||||
- Vertex AI (EMMA_BACKEND=vertex) — nå, bruker Vertex-kreditter
|
||||
- Lokal Gemma 4 VM (EMMA_BACKEND=local) — når trigger er nådd
|
||||
Bytt backend: sett EMMA_BACKEND=local + EMMA_LOCAL_URL=http://<VM-IP>:8000/generate
|
||||
"""
|
||||
|
||||
async def chat(self, message: str, history: Optional[List[Dict]] = None) -> str:
|
||||
if EMMA_BACKEND == "local":
|
||||
return await self._local(message, history)
|
||||
|
|
@ -383,19 +326,13 @@ class EmmaChatService:
|
|||
try:
|
||||
import vertexai
|
||||
from vertexai.generative_models import GenerativeModel, Content, Part
|
||||
|
||||
vertexai.init(project=PROJECT_ID, location="us-central1")
|
||||
model = GenerativeModel(
|
||||
EMMA_MODEL,
|
||||
system_instruction=_EMMA_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
model = GenerativeModel(EMMA_MODEL, system_instruction=_EMMA_SYSTEM_PROMPT)
|
||||
chat_history = []
|
||||
for turn in (history or []):
|
||||
role = turn.get("role", "user")
|
||||
content = turn.get("content", "")
|
||||
chat_history.append(Content(role=role, parts=[Part.from_text(content)]))
|
||||
|
||||
chat = model.start_chat(history=chat_history)
|
||||
response = chat.send_message(message)
|
||||
return response.text
|
||||
|
|
@ -403,22 +340,14 @@ class EmmaChatService:
|
|||
raise HTTPException(status_code=500, detail=f"Emma (Vertex) feilet: {e}")
|
||||
|
||||
async def _local(self, message: str, history: Optional[List[Dict]] = None) -> str:
|
||||
"""
|
||||
Kaller lokal Gemma 4 på VM via HTTP.
|
||||
Forventet format: POST {EMMA_LOCAL_URL} med {prompt, system_prompt}
|
||||
"""
|
||||
turns = ""
|
||||
for turn in (history or []):
|
||||
role = "User" if turn.get("role") == "user" else "Emma"
|
||||
turns += f"{role}: {turn.get('content', '')}\n"
|
||||
full_prompt = f"{turns}User: {message}\nEmma:"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(
|
||||
EMMA_LOCAL_URL,
|
||||
json={"prompt": full_prompt, "system_prompt": _EMMA_SYSTEM_PROMPT, "max_tokens": 1024},
|
||||
)
|
||||
resp = await client.post(EMMA_LOCAL_URL, json={"prompt": full_prompt, "system_prompt": _EMMA_SYSTEM_PROMPT, "max_tokens": 1024})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("response") or data.get("text") or data.get("generated_text", "")
|
||||
|
|
@ -430,12 +359,7 @@ class EmmaChatService:
|
|||
|
||||
_emma = EmmaChatService()
|
||||
|
||||
|
||||
async def _emma_chat(request: Request, req: EmmaRequest):
|
||||
"""
|
||||
POST /emma — intern agent, kun for Chris (IAP-beskyttet).
|
||||
Backend: EMMA_BACKEND env-var (vertex | local).
|
||||
"""
|
||||
start = time.monotonic()
|
||||
response_text = await _emma.chat(req.message, req.history)
|
||||
duration = round(time.monotonic() - start, 3)
|
||||
|
|
@ -443,13 +367,7 @@ async def _emma_chat(request: Request, req: EmmaRequest):
|
|||
store.push("emma", "last_duration_s", duration)
|
||||
store.push("emma", "last_backend", EMMA_BACKEND)
|
||||
store.push("emma", "last_success", True)
|
||||
return JSONResponse({
|
||||
"response": response_text,
|
||||
"backend": EMMA_BACKEND,
|
||||
"model": EMMA_MODEL,
|
||||
"duration_s": duration,
|
||||
"session_id": req.session_id,
|
||||
})
|
||||
return JSONResponse({"response": response_text, "backend": EMMA_BACKEND, "model": EMMA_MODEL, "duration_s": duration, "session_id": req.session_id})
|
||||
|
||||
app.add_api_route("/emma", endpoint=require_auth(_emma_chat), methods=["POST"])
|
||||
|
||||
|
|
@ -461,7 +379,6 @@ class RunRequest(BaseModel):
|
|||
session_id: str = "default"
|
||||
mode: str = "light"
|
||||
|
||||
|
||||
class DagRequest(BaseModel):
|
||||
messages: List[str] = Field(...)
|
||||
user_id: str = "opax"
|
||||
|
|
@ -481,29 +398,29 @@ class BudgetUpdateRequest(BaseModel):
|
|||
budget: float
|
||||
|
||||
|
||||
# ── NOTIFICATION MODELS (CG5a + CG5b + CG5c) ──────────────────────────────────
|
||||
# ── NOTIFICATION MODELS ──────────────────────────────────────────────────────────
|
||||
class WebhookNotifyRequest(BaseModel):
|
||||
url: str = Field(..., description="Webhook-URL (Slack/Teams/Google Chat/Discord/custom)")
|
||||
event: str = Field("custom", description="Event-type: spike | digest | budget | anomaly | custom")
|
||||
title: str = Field("CostGuard varsel", description="Tittel på meldingen")
|
||||
body: str = Field(..., description="Meldingstekst")
|
||||
payload: Optional[Dict[str, Any]] = Field(None, description="Valgfri rådata å inkludere")
|
||||
url: str
|
||||
event: str = "custom"
|
||||
title: str = "CostGuard varsel"
|
||||
body: str
|
||||
payload: Optional[Dict[str, Any]] = None
|
||||
|
||||
class EmailNotifyRequest(BaseModel):
|
||||
to: Optional[str] = Field(None, description="Mottaker-e-post. Faller tilbake til ALERT_EMAIL hvis tom.")
|
||||
subject: Optional[str] = Field(None, description="Emne. Auto-generert fra event hvis tom.")
|
||||
event: str = Field("digest", description="Event-type: spike | digest | budget | anomaly | custom")
|
||||
body_html: Optional[str] = Field(None, description="HTML-innhold. Auto-generert fra billing-data hvis tom.")
|
||||
payload: Optional[Dict[str, Any]] = Field(None, description="Valgfri rådata å inkludere i e-post")
|
||||
to: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
event: str = "digest"
|
||||
body_html: Optional[str] = None
|
||||
payload: Optional[Dict[str, Any]] = None
|
||||
|
||||
class SmsNotifyRequest(BaseModel):
|
||||
to: str = Field(..., description="Mottakers mobilnummer i E.164-format", pattern=r"^\+[1-9]\d{7,14}$")
|
||||
body: str = Field(..., description="Meldingstekst. Maks 160 tegn anbefalt.", max_length=320)
|
||||
event: str = Field("spike", description="Event-type: spike | budget | anomaly | custom")
|
||||
tier: str = Field(..., description="Kundens tier. Må være guard, shield eller enterprise.")
|
||||
to: str = Field(..., pattern=r"^\+[1-9]\d{7,14}$")
|
||||
body: str = Field(..., max_length=320)
|
||||
event: str = "spike"
|
||||
tier: str
|
||||
|
||||
|
||||
# ── ONBOARDING MODEL (CG5-onboard) ────────────────────────────────────────────
|
||||
# ── ONBOARDING MODELS ───────────────────────────────────────────────────────────
|
||||
class OnboardCompleteRequest(BaseModel):
|
||||
company: str
|
||||
contact: str
|
||||
|
|
@ -513,9 +430,15 @@ class OnboardCompleteRequest(BaseModel):
|
|||
channels: List[str]
|
||||
webhook_url: Optional[str] = None
|
||||
email_to: Optional[str] = None
|
||||
invite_token: Optional[str] = None
|
||||
|
||||
class InviteCreateRequest(BaseModel):
|
||||
company: str
|
||||
email: str
|
||||
tier: str = "starter"
|
||||
|
||||
|
||||
# ── NOTIFICATION SERVICE (CG5a + CG5b + CG5c) ─────────────────────────────────
|
||||
# ── NOTIFICATION SERVICE ─────────────────────────────────────────────────────────
|
||||
class NotificationService:
|
||||
EVENT_META = {
|
||||
"spike": {"emoji": "🚨", "label": "Kostnadsspike oppdaget"},
|
||||
|
|
@ -525,39 +448,30 @@ class NotificationService:
|
|||
"custom": {"emoji": "📢", "label": "CostGuard varsel"},
|
||||
}
|
||||
|
||||
def _event_meta(self, event: str) -> dict:
|
||||
def _event_meta(self, event):
|
||||
return self.EVENT_META.get(event, self.EVENT_META["custom"])
|
||||
|
||||
def _build_slack_payload(self, req: WebhookNotifyRequest) -> dict:
|
||||
def _build_slack_payload(self, req):
|
||||
meta = self._event_meta(req.event)
|
||||
return {
|
||||
"text": f"{meta['emoji']} *{req.title}*\n{req.body}",
|
||||
"attachments": [
|
||||
{
|
||||
"color": "#FF6B35" if req.event in ("spike", "budget") else "#4A90D9",
|
||||
"fields": [
|
||||
{"title": k, "value": str(v), "short": True}
|
||||
for k, v in (req.payload or {}).items()
|
||||
]
|
||||
}
|
||||
] if req.payload else []
|
||||
"attachments": [{"color": "#FF6B35" if req.event in ("spike", "budget") else "#4A90D9", "fields": [{"title": k, "value": str(v), "short": True} for k, v in (req.payload or {}).items()]}] if req.payload else []
|
||||
}
|
||||
|
||||
async def send_webhook(self, req: WebhookNotifyRequest) -> dict:
|
||||
slack_payload = self._build_slack_payload(req)
|
||||
async def send_webhook(self, req) -> dict:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(req.url, json=slack_payload, headers={"Content-Type": "application/json"})
|
||||
resp = await client.post(req.url, json=self._build_slack_payload(req), headers={"Content-Type": "application/json"})
|
||||
if resp.status_code >= 400:
|
||||
return {"status": "error", "http_status": resp.status_code, "detail": resp.text[:200]}
|
||||
return {"status": "ok", "http_status": resp.status_code, "event": req.event}
|
||||
except httpx.TimeoutException:
|
||||
return {"status": "error", "detail": "Webhook timeout (>10s)"}
|
||||
return {"status": "error", "detail": "Webhook timeout"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "detail": str(e)}
|
||||
|
||||
async def send_email(self, req: EmailNotifyRequest) -> dict:
|
||||
api_key = os.environ.get("SENDGRID_API_KEY")
|
||||
async def send_email(self, req) -> dict:
|
||||
api_key = os.environ.get("SENDGRID_API_KEY")
|
||||
to_email = req.to or ALERT_EMAIL
|
||||
if not api_key:
|
||||
return {"status": "not_configured", "detail": "SENDGRID_API_KEY mangler"}
|
||||
|
|
@ -570,14 +484,13 @@ class NotificationService:
|
|||
message = Mail(from_email="costguard@osvauco.no", to_emails=to_email, subject=subject, html_content=html_content)
|
||||
sg = sendgrid.SendGridAPIClient(api_key)
|
||||
response = sg.send(message)
|
||||
msg_id = response.headers.get("X-Message-Id", "")
|
||||
return {"status": "ok", "message_id": msg_id, "to": to_email, "event": req.event}
|
||||
return {"status": "ok", "message_id": response.headers.get("X-Message-Id", ""), "to": to_email, "event": req.event}
|
||||
except Exception as e:
|
||||
return {"status": "error", "detail": str(e)}
|
||||
|
||||
async def send_sms(self, req: SmsNotifyRequest) -> dict:
|
||||
async def send_sms(self, req) -> dict:
|
||||
if req.tier.lower() not in SMS_ALLOWED_TIERS:
|
||||
return {"status": "not_allowed", "detail": f"SMS krever Guard+-tier. Aktuell tier: '{req.tier}'."}
|
||||
return {"status": "not_allowed", "detail": f"SMS krever Guard+-tier."}
|
||||
account_sid = os.environ.get("TWILIO_ACCOUNT_SID")
|
||||
auth_token = os.environ.get("TWILIO_AUTH_TOKEN")
|
||||
from_number = os.environ.get("TWILIO_FROM_NUMBER")
|
||||
|
|
@ -585,68 +498,44 @@ class NotificationService:
|
|||
return {"status": "not_configured", "detail": "Twilio env-vars mangler"}
|
||||
meta = self._event_meta(req.event)
|
||||
sms_body = req.body if req.body.startswith(meta["emoji"]) else f"{meta['emoji']} CostGuard: {req.body}"
|
||||
twilio_url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(twilio_url, data={"From": from_number, "To": req.to, "Body": sms_body}, auth=(account_sid, auth_token))
|
||||
resp = await client.post(f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json", data={"From": from_number, "To": req.to, "Body": sms_body}, auth=(account_sid, auth_token))
|
||||
data = resp.json()
|
||||
if resp.status_code >= 400:
|
||||
return {"status": "error", "http_status": resp.status_code, "detail": data.get("message", "")}
|
||||
return {"status": "ok", "sid": data.get("sid"), "to": req.to, "sms_status": data.get("status"), "event": req.event}
|
||||
except httpx.TimeoutException:
|
||||
return {"status": "error", "detail": "Twilio timeout (>15s)"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "detail": str(e)}
|
||||
|
||||
async def _build_digest_html(self, extra_payload: Optional[dict] = None) -> str:
|
||||
async def _build_digest_html(self, extra_payload=None):
|
||||
try:
|
||||
billing = BillingAgent()
|
||||
summary = billing.get_summary().get("summary", [])
|
||||
billing = BillingAgent()
|
||||
summary = billing.get_summary().get("summary", [])
|
||||
forecast = billing.get_forecast()
|
||||
mtd = forecast.get("month_to_date_cost", 0)
|
||||
daily_avg = forecast.get("daily_average_last_7_days", 0)
|
||||
top3 = "".join(f"<li>{s['service']}: kr {s.get('total_cost', 0):.2f}</li>" for s in summary[:3])
|
||||
mtd = forecast.get("month_to_date_cost", 0)
|
||||
daily = forecast.get("daily_average_last_7_days", 0)
|
||||
top3 = "".join(f"<li>{s['service']}: kr {s.get('total_cost',0):.2f}</li>" for s in summary[:3])
|
||||
except Exception:
|
||||
mtd, daily_avg, top3 = 0, 0, "<li>Data ikke tilgjengelig</li>"
|
||||
extra_rows = "".join(
|
||||
f"<tr><td style='padding:4px 8px;color:#666'>{k}</td><td style='padding:4px 8px'><strong>{v}</strong></td></tr>"
|
||||
for k, v in (extra_payload or {}).items()
|
||||
)
|
||||
return f"""
|
||||
<div style='font-family:sans-serif;max-width:600px;margin:0 auto'>
|
||||
<div style='background:#1a1a2e;padding:20px;border-radius:8px 8px 0 0'>
|
||||
<h2 style='color:#fff;margin:0'>📊 CostGuard — Daglig oppsummering</h2>
|
||||
<p style='color:#aaa;margin:4px 0 0'>{datetime.date.today()}</p>
|
||||
</div>
|
||||
<div style='background:#f9f9f9;padding:20px;border-radius:0 0 8px 8px'>
|
||||
<table style='width:100%;border-collapse:collapse'>
|
||||
<tr><td style='padding:4px 8px;color:#666'>Kostnad MTD</td><td style='padding:4px 8px'><strong>kr {mtd:.2f}</strong></td></tr>
|
||||
<tr><td style='padding:4px 8px;color:#666'>Daglig snitt (7d)</td><td style='padding:4px 8px'><strong>kr {daily_avg:.2f}</strong></td></tr>
|
||||
{extra_rows}
|
||||
</table>
|
||||
<h4 style='margin-top:16px'>Topp 3 GCP-tjenester</h4>
|
||||
<ul>{top3}</ul>
|
||||
<hr style='border:none;border-top:1px solid #ddd;margin:16px 0'>
|
||||
<p style='color:#999;font-size:12px'>CostGuard by Vauco AS · opax.vauco.no</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
mtd, daily, top3 = 0, 0, "<li>Data ikke tilgjengelig</li>"
|
||||
extra = "".join(f"<tr><td style='padding:4px 8px;color:#666'>{k}</td><td style='padding:4px 8px'><strong>{v}</strong></td></tr>" for k, v in (extra_payload or {}).items())
|
||||
return f"<div style='font-family:sans-serif;max-width:600px;margin:0 auto'><div style='background:#1a1a2e;padding:20px;border-radius:8px 8px 0 0'><h2 style='color:#fff;margin:0'>📊 CostGuard — Daglig oppsummering</h2><p style='color:#aaa;margin:4px 0 0'>{datetime.date.today()}</p></div><div style='background:#f9f9f9;padding:20px;border-radius:0 0 8px 8px'><table style='width:100%;border-collapse:collapse'><tr><td style='padding:4px 8px;color:#666'>Kostnad MTD</td><td style='padding:4px 8px'><strong>kr {mtd:.2f}</strong></td></tr><tr><td style='padding:4px 8px;color:#666'>Daglig snitt (7d)</td><td style='padding:4px 8px'><strong>kr {daily:.2f}</strong></td></tr>{extra}</table><h4 style='margin-top:16px'>Topp 3 GCP-tjenester</h4><ul>{top3}</ul><hr style='border:none;border-top:1px solid #ddd;margin:16px 0'><p style='color:#999;font-size:12px'>CostGuard by Vauco AS · opax.vauco.no</p></div></div>"
|
||||
|
||||
|
||||
_notifier = NotificationService()
|
||||
|
||||
|
||||
# ── NOTIFY ENDPOINTS (CG5a + CG5b + CG5c) ─────────────────────────────────────
|
||||
# ── NOTIFY ENDPOINTS ───────────────────────────────────────────────────────────────
|
||||
async def _notify_webhook(request: Request, req: WebhookNotifyRequest):
|
||||
result = await _notifier.send_webhook(req)
|
||||
return JSONResponse(status_code=200 if result["status"] == "ok" else 502, content=result)
|
||||
return JSONResponse(status_code=200 if result["status"]=="ok" else 502, content=result)
|
||||
app.add_api_route("/notify/webhook", endpoint=require_auth(_notify_webhook), methods=["POST"])
|
||||
|
||||
async def _notify_email(request: Request, req: EmailNotifyRequest):
|
||||
result = await _notifier.send_email(req)
|
||||
if result["status"] == "not_configured":
|
||||
return JSONResponse(status_code=503, content=result)
|
||||
return JSONResponse(status_code=200 if result["status"] == "ok" else 500, content=result)
|
||||
return JSONResponse(status_code=200 if result["status"]=="ok" else 500, content=result)
|
||||
app.add_api_route("/notify/email", endpoint=require_auth(_notify_email), methods=["POST"])
|
||||
|
||||
async def _notify_sms(request: Request, req: SmsNotifyRequest):
|
||||
|
|
@ -655,18 +544,108 @@ async def _notify_sms(request: Request, req: SmsNotifyRequest):
|
|||
return JSONResponse(status_code=402, content=result)
|
||||
if result["status"] == "not_configured":
|
||||
return JSONResponse(status_code=503, content=result)
|
||||
return JSONResponse(status_code=200 if result["status"] == "ok" else 500, content=result)
|
||||
return JSONResponse(status_code=200 if result["status"]=="ok" else 500, content=result)
|
||||
app.add_api_route("/notify/sms", endpoint=require_auth(_notify_sms), methods=["POST"])
|
||||
|
||||
|
||||
# ── ONBOARDING ENDPOINTS (CG5-onboard) ───────────────────────────────────────
|
||||
# ── ONBOARDING ENDPOINTS ─────────────────────────────────────────────────────────
|
||||
@app.get("/onboard")
|
||||
def onboard_wizard():
|
||||
return FileResponse("static/onboard.html")
|
||||
f = pathlib.Path(__file__).parent / "static" / "onboard.html"
|
||||
return FileResponse(str(f))
|
||||
|
||||
|
||||
@app.get("/onboard/invite/{token}")
|
||||
async def onboard_invite_landing(token: str):
|
||||
"""
|
||||
Offentlig URL Jason sender til potensiell kunde.
|
||||
Validerer token, server onboard.html med prefilled data i meta-tag.
|
||||
IAP-exempt via IAP_EXEMPT_PREFIXES.
|
||||
"""
|
||||
if not db:
|
||||
raise HTTPException(status_code=503, detail="Databasefeil")
|
||||
store = InviteStore(db)
|
||||
invite = store.get(token)
|
||||
if not invite:
|
||||
raise HTTPException(status_code=404, detail="Invitasjonen er ugyldig eller utløpt.")
|
||||
|
||||
html = (pathlib.Path(__file__).parent / "static" / "onboard.html").read_text(encoding="utf-8")
|
||||
# Inject invite data as a script block before </head>
|
||||
inject = f"""
|
||||
<script>
|
||||
window.__INVITE__ = {{
|
||||
token: "{token}",
|
||||
company: "{invite.get('company', '')}",
|
||||
email: "{invite.get('email', '')}",
|
||||
tier: "{invite.get('tier', 'starter')}"
|
||||
}};
|
||||
</script>"""
|
||||
html = html.replace("</head>", inject + "\n</head>", 1)
|
||||
return Response(content=html, media_type="text/html")
|
||||
|
||||
|
||||
async def _onboard_invite_create(request: Request, req: InviteCreateRequest):
|
||||
"""
|
||||
POST /onboard/invite — intern, IAP-beskyttet.
|
||||
Oppretter token og returnerer ferdig URL Chris/Jason kan sende til kunde.
|
||||
"""
|
||||
if not db:
|
||||
raise HTTPException(status_code=503, detail="Firestore ikke tilgjengelig")
|
||||
user = request.session.get("user", {})
|
||||
created_by = user.get("email", "internal")
|
||||
store = InviteStore(db)
|
||||
token = store.create(company=req.company, email=req.email, tier=req.tier, created_by=created_by)
|
||||
invite_url = f"{BASE_URL}/onboard/invite/{token}"
|
||||
|
||||
# Send invite-e-post til prospekt automatisk
|
||||
email_result = await _notifier.send_email(EmailNotifyRequest(
|
||||
to=req.email,
|
||||
event="custom",
|
||||
subject=f"🚀 Du er invitert til CostGuard — {req.company}",
|
||||
body_html=f"""
|
||||
<div style='font-family:sans-serif;max-width:600px;margin:0 auto'>
|
||||
<div style='background:#1a1a2e;padding:24px;border-radius:8px 8px 0 0'>
|
||||
<h2 style='color:#fff;margin:0'>🛡️ CostGuard</h2>
|
||||
<p style='color:#aaa;margin:4px 0 0'>av Vauco AS</p>
|
||||
</div>
|
||||
<div style='background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px'>
|
||||
<p>Hei!</p>
|
||||
<p>Du er invitert til å aktivere <strong>CostGuard</strong> for <strong>{req.company}</strong>.</p>
|
||||
<p>Klikk på knappen nedenfor for å fullføre oppsett på 2 minutter:</p>
|
||||
<p style='text-align:center;margin:28px 0'>
|
||||
<a href='{invite_url}' style='background:#7c5cff;color:#fff;padding:14px 32px;border-radius:10px;text-decoration:none;font-weight:700;font-size:15px'>Aktiver CostGuard →</a>
|
||||
</p>
|
||||
<p style='color:#999;font-size:12px'>Lenken er gyldig i 72 timer. Trenger du hjelp? Svar på denne e-posten.</p>
|
||||
<hr style='border:none;border-top:1px solid #ddd;margin:16px 0'>
|
||||
<p style='color:#bbb;font-size:11px'>CostGuard by Vauco AS · opax.vauco.no</p>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
))
|
||||
|
||||
return JSONResponse({
|
||||
"status": "ok",
|
||||
"invite_url": invite_url,
|
||||
"token": token,
|
||||
"expires_in": "72 timer",
|
||||
"email_sent": email_result.get("status") == "ok",
|
||||
"company": req.company,
|
||||
"tier": req.tier,
|
||||
})
|
||||
|
||||
app.add_api_route("/onboard/invite", endpoint=require_auth(_onboard_invite_create), methods=["POST"])
|
||||
|
||||
|
||||
@app.post("/onboard/complete")
|
||||
async def onboard_complete(req: OnboardCompleteRequest):
|
||||
# Valider og merk invite-token som brukt
|
||||
if req.invite_token and db:
|
||||
store = InviteStore(db)
|
||||
invite = store.get(req.invite_token)
|
||||
if not invite:
|
||||
raise HTTPException(status_code=400, detail="Invitasjonstoken ugyldig eller utløpt.")
|
||||
store.mark_used(req.invite_token)
|
||||
|
||||
if db:
|
||||
try:
|
||||
db.collection("onboarded_customers").document(req.gcp_project).set({
|
||||
|
|
@ -713,39 +692,35 @@ async def onboard_complete(req: OnboardCompleteRequest):
|
|||
return {"status": "ok", "customer": req.gcp_project, "company": req.company, "channels": req.channels, "notify": notify_results}
|
||||
|
||||
|
||||
# ── ROOT LANDING PAGE ───────────────────────────────────────────────────
|
||||
# ── ROOT ──────────────────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
def root():
|
||||
return FileResponse("static/opax.html")
|
||||
return FileResponse(str(pathlib.Path(__file__).parent / "static" / "opax.html"))
|
||||
|
||||
|
||||
# ── HEALTH ────────────────────────────────────────────────────────────────────
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/manifest.json", include_in_schema=False)
|
||||
def manifest():
|
||||
return FileResponse("static/manifest.json")
|
||||
|
||||
return FileResponse(str(pathlib.Path(__file__).parent / "static" / "manifest.json"))
|
||||
|
||||
@app.get("/sw.js", include_in_schema=False)
|
||||
def service_worker():
|
||||
return FileResponse("static/sw.js")
|
||||
return FileResponse(str(pathlib.Path(__file__).parent / "static" / "sw.js"))
|
||||
|
||||
|
||||
# ── ADMIN ─────────────────────────────────────────────────────────────────────
|
||||
# ── ADMIN ────────────────────────────────────────────────────────────────────────
|
||||
@app.get('/admin')
|
||||
@require_auth
|
||||
async def admin_panel(request: Request):
|
||||
return FileResponse("static/admin.html")
|
||||
return FileResponse(str(pathlib.Path(__file__).parent / "static" / "admin.html"))
|
||||
|
||||
@app.post('/admin/create-customer')
|
||||
@require_auth
|
||||
async def create_customer(request: Request):
|
||||
import subprocess
|
||||
data = await request.json()
|
||||
import subprocess, re, shutil
|
||||
data = await request.json()
|
||||
customer_name = data.get("customer_name", "").strip()
|
||||
project_id = data.get("project_id", "").strip()
|
||||
billing_account_id = data.get("billing_account_id", "").strip()
|
||||
|
|
@ -754,18 +729,15 @@ async def create_customer(request: Request):
|
|||
region = data.get("region", "europe-north1").strip()
|
||||
if not all([customer_name, project_id, billing_account_id, alert_email, container_image]):
|
||||
raise HTTPException(status_code=400, detail="Alle felt er påkrevd")
|
||||
import re
|
||||
if not re.match(r'^[a-z0-9_-]+$', customer_name):
|
||||
raise HTTPException(status_code=400, detail="customer_name kan kun inneholde a-z, 0-9, - og _")
|
||||
customer_dir = f"infrastructure/terraform/customers/{customer_name}"
|
||||
template_dir = "infrastructure/terraform/customers/_template"
|
||||
import shutil
|
||||
if os.path.exists(customer_dir):
|
||||
raise HTTPException(status_code=409, detail=f"Kunde {customer_name} eksisterer allerede")
|
||||
shutil.copytree(template_dir, customer_dir)
|
||||
tfvars_content = f'''customer_id = "{customer_name}"\nproject_id = "{project_id}"\nregion = "{region}"\nbilling_account_id = "{billing_account_id}"\nalert_email = "{alert_email}"\nbilling_viewer_emails = ["chris.christiansen@vauco.no", "jason.vauger@vauco.no"]\ncontainer_image = "{container_image}"\n'''
|
||||
with open(f"{customer_dir}/terraform.tfvars", "w") as f:
|
||||
f.write(tfvars_content)
|
||||
f.write(f'customer_id = "{customer_name}"\nproject_id = "{project_id}"\nregion = "{region}"\nbilling_account_id = "{billing_account_id}"\nalert_email = "{alert_email}"\nbilling_viewer_emails = ["chris.christiansen@vauco.no", "jason.vauger@vauco.no"]\ncontainer_image = "{container_image}"\n')
|
||||
try:
|
||||
init = subprocess.run(["terraform", f"-chdir={customer_dir}", "init", "-no-color"], capture_output=True, text=True, timeout=120)
|
||||
if init.returncode != 0:
|
||||
|
|
@ -776,10 +748,10 @@ async def create_customer(request: Request):
|
|||
raise HTTPException(status_code=500, detail=f"terraform apply feilet: {apply.stderr[-500:]}")
|
||||
return {"status": "ok", "customer": customer_name, "project_id": project_id}
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=504, detail="Terraform tok for lang tid (>10 min)")
|
||||
raise HTTPException(status_code=504, detail="Terraform tok for lang tid")
|
||||
|
||||
|
||||
# ── BILLING ENDPOINTS (CG1 + CG2) ────────────────────────────────────────────
|
||||
# ── BILLING ENDPOINTS ────────────────────────────────────────────────────────────
|
||||
async def get_budget(request: Request):
|
||||
if not db:
|
||||
return JSONResponse(status_code=500, content={"error": "Firestore is not configured"})
|
||||
|
|
@ -801,31 +773,25 @@ async def set_budget(request: Request, payload: BudgetUpdateRequest):
|
|||
app.add_api_route("/billing/budget", endpoint=require_auth(get_budget), methods=["GET"])
|
||||
app.add_api_route("/billing/budget", endpoint=require_auth(set_budget), methods=["POST"])
|
||||
|
||||
|
||||
@app.post("/billing/email-report")
|
||||
async def trigger_email_report():
|
||||
result = await _notifier.send_email(EmailNotifyRequest(event="digest"))
|
||||
if result["status"] == "not_configured":
|
||||
return JSONResponse(status_code=200, content={"status": "not_configured", "error": result["detail"]})
|
||||
return JSONResponse(status_code=200 if result["status"] == "ok" else 500, content=result)
|
||||
|
||||
return JSONResponse(status_code=200 if result["status"]=="ok" else 500, content=result)
|
||||
|
||||
@app.post("/billing/snapshot")
|
||||
async def create_daily_snapshot(request: Request):
|
||||
await trigger_email_report()
|
||||
return JSONResponse(content={"status": "ok", "snapshot_id": str(datetime.date.today())})
|
||||
|
||||
|
||||
async def get_history(request: Request):
|
||||
if not db:
|
||||
return JSONResponse(status_code=500, content={"error": "Firestore is not configured"})
|
||||
try:
|
||||
end_date = datetime.date.today()
|
||||
start_date = end_date - datetime.timedelta(days=90)
|
||||
docs = db.collection("daily_snapshots") \
|
||||
.where("created_at", ">=", start_date.isoformat()) \
|
||||
.order_by("created_at", direction=firestore.Query.DESCENDING) \
|
||||
.limit(90).stream()
|
||||
docs = db.collection("daily_snapshots").where("created_at", ">=", start_date.isoformat()).order_by("created_at", direction=firestore.Query.DESCENDING).limit(90).stream()
|
||||
history = [{"date": doc.id, **doc.to_dict()} for doc in docs]
|
||||
history.reverse()
|
||||
return JSONResponse(content={"history": history})
|
||||
|
|
@ -833,7 +799,6 @@ async def get_history(request: Request):
|
|||
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||
app.add_api_route("/billing/history", endpoint=require_auth(get_history), methods=["GET"])
|
||||
|
||||
|
||||
async def authenticated_billing_summary(request: Request):
|
||||
try:
|
||||
return BillingAgent().get_summary()
|
||||
|
|
@ -841,7 +806,6 @@ async def authenticated_billing_summary(request: Request):
|
|||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||
app.add_api_route("/billing/summary", endpoint=require_auth(authenticated_billing_summary), methods=["GET"])
|
||||
|
||||
|
||||
async def authenticated_billing_forecast(request: Request):
|
||||
try:
|
||||
return BillingAgent().get_forecast()
|
||||
|
|
@ -849,7 +813,6 @@ async def authenticated_billing_forecast(request: Request):
|
|||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||
app.add_api_route("/billing/forecast", endpoint=require_auth(authenticated_billing_forecast), methods=["GET"])
|
||||
|
||||
|
||||
async def authenticated_billing_anomalies(request: Request):
|
||||
try:
|
||||
return AnomalyDetector().detect_anomalies()
|
||||
|
|
@ -857,7 +820,6 @@ async def authenticated_billing_anomalies(request: Request):
|
|||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||
app.add_api_route("/billing/anomalies", endpoint=require_auth(authenticated_billing_anomalies), methods=["GET"])
|
||||
|
||||
|
||||
async def authenticated_billing_credits(request: Request, days: int = 90):
|
||||
try:
|
||||
return JSONResponse(BillingAgent().get_credits_status(days=days))
|
||||
|
|
@ -865,7 +827,6 @@ async def authenticated_billing_credits(request: Request, days: int = 90):
|
|||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||
app.add_api_route("/billing/credits", endpoint=require_auth(authenticated_billing_credits), methods=["GET"])
|
||||
|
||||
|
||||
@app.get("/billing-dashboard")
|
||||
def billing_dashboard_view(request: Request):
|
||||
return RedirectResponse(url='/static/billing-dashboard.html')
|
||||
|
|
@ -886,23 +847,18 @@ async def budget_webhook(payload: BudgetWebhookPayload):
|
|||
return {"status": "error", "detail": "Firestore not configured"}
|
||||
try:
|
||||
data = base64.b64decode(payload.message.get("data", "")).decode("utf-8")
|
||||
data_json = json.loads(data)
|
||||
data_json = json.loads(data)
|
||||
cost_amount = data_json.get("costAmount", 0)
|
||||
budget_amount = data_json.get("budgetAmount", 0)
|
||||
if budget_amount > 0 and (cost_amount / budget_amount) > 0.8:
|
||||
percent_used = round((cost_amount / budget_amount) * 100)
|
||||
tokens = [s.id for s in db.collection("push_subscribers").stream()]
|
||||
if tokens:
|
||||
notification = messaging.Notification(
|
||||
title="⚠️ CostGuard Varsel",
|
||||
body=f"Du har brukt {percent_used}% av budsjett (kr {int(cost_amount)} av kr {int(budget_amount)})"
|
||||
)
|
||||
messaging.send_multicast(messaging.MulticastMessage(tokens=tokens, notification=notification))
|
||||
messaging.send_multicast(messaging.MulticastMessage(tokens=tokens, notification=messaging.Notification(title="⚠️ CostGuard Varsel", body=f"Du har brukt {percent_used}% av budsjett (kr {int(cost_amount)} av kr {int(budget_amount)})")))
|
||||
return {"status": "processed"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "detail": str(e)}
|
||||
|
||||
|
||||
@app.get("/billing/live")
|
||||
@cached(TTLCache(maxsize=1, ttl=3600))
|
||||
async def billing_live(request: Request):
|
||||
|
|
@ -971,17 +927,17 @@ def run_dag(req: DagRequest):
|
|||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
def make_agent_fn(msg: str, idx: int):
|
||||
def _agent_fn(payload: dict):
|
||||
def make_agent_fn(msg, idx):
|
||||
def _agent_fn(payload):
|
||||
return run(message=msg, user_id=payload["user_id"], session_id=f"{payload['session_id']}-dag-{idx}", mode=payload["mode"])
|
||||
_agent_fn.__name__ = f"opax-dag-{idx}"
|
||||
return _agent_fn
|
||||
payload = {"user_id": req.user_id, "session_id": req.session_id, "mode": req.mode}
|
||||
agent_fns = [make_agent_fn(msg, i) for i, msg in enumerate(req.messages)]
|
||||
agent_ids = [f"opax-dag-{i}" for i in range(len(req.messages))]
|
||||
start = time.monotonic()
|
||||
tasks = build_agent_dag(agent_fns, payload, agent_ids)
|
||||
results = execute_dag(tasks, scheduler=req.scheduler)
|
||||
start = time.monotonic()
|
||||
tasks = build_agent_dag(agent_fns, payload, agent_ids)
|
||||
results = execute_dag(tasks, scheduler=req.scheduler)
|
||||
total_dur = round(time.monotonic() - start, 3)
|
||||
store = get_store()
|
||||
log_dag_execution(dag_id=req.session_id, agent_results=results, total_duration_s=total_dur)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user