diff --git a/main.py b/main.py index 3b49496..1ae574c 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,8 @@ 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. DOCS: GET /docs/{path} — proxy til privat GitHub-repo via ADC/PAT. +OQ-29: GET /notify/channels — list delivery-kanaler per gcp_project. +OQ-31: GET /opax/build-status — ekte Cloud Build-status. """ import os @@ -51,6 +53,7 @@ import sendgrid from sendgrid.helpers.mail import Mail AGENT_ID = "osvauco-opax" +PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5") # Guard+-tiers som får tilgang til SMS-varsler SMS_ALLOWED_TIERS = {"guard", "shield", "enterprise"} @@ -61,8 +64,7 @@ app = FastAPI( version="0.1.0", ) -# Paths exempt from IAP enforcement (health/readiness probes reach Cloud Run -# directly without the IAP-injected x-goog-authenticated-user-email header). +# Paths exempt from IAP enforcement IAP_EXEMPT_PATHS = {"/health", "/healthz", "/readiness", "/liveness", "/onboard"} @app.middleware("http") @@ -148,16 +150,11 @@ if _static_dir.is_dir(): # ── DOCS PROXY (MD-filer fra privat GitHub-repo) ────────────────────────────── -_GH_REPO = "vauco-saas/OSVauco" +_GH_REPO = "vauco-saas/OSVauco" _GH_BRANCH = os.environ.get("DOCS_BRANCH", "main") @app.get("/docs/{path:path}") async def proxy_doc(path: str): - """ - Henter vilkårlig fil fra privat GitHub-repo. - Bruker GITHUB_PAT env-var (Secret Manager → Cloud Run). - Fallback: forsøker uten auth (fungerer ikke for private repos, men feiler pent). - """ token = os.environ.get("GITHUB_PAT") url = f"https://raw.githubusercontent.com/{_GH_REPO}/{_GH_BRANCH}/{path}" headers = {"Authorization": f"token {token}"} if token else {} @@ -175,6 +172,83 @@ async def proxy_doc(path: str): return Response(content=resp.text, media_type="text/plain; charset=utf-8") +# ── OQ-31: Cloud Build status ───────────────────────────────────────────────── +@app.get("/opax/build-status") +async def build_status(): + """ + OQ-31 — Ekte Cloud Build-status fra GCP. + Erstatter mock/manglende endepunkt. Henter siste bygg via Cloud Build API. + """ + try: + from google.cloud.devtools import cloudbuild_v1 + client = cloudbuild_v1.CloudBuildClient() + request_obj = cloudbuild_v1.ListBuildsRequest( + project_id=PROJECT_ID, + filter='trigger_id!=""', + page_size=5, + ) + builds = list(client.list_builds(request=request_obj)) + if not builds: + return JSONResponse({"status": "unknown", "message": "Ingen builds funnet"}) + b = builds[0] + status_map = { + 1: "queued", 2: "working", 3: "success", + 4: "failure", 5: "internal_error", 6: "timeout", 7: "cancelled" + } + status_str = status_map.get(int(b.status), "unknown") + duration_s = None + if b.start_time and b.finish_time: + duration_s = int(b.finish_time.seconds - b.start_time.seconds) + return JSONResponse({ + "status": status_str, + "build_id": b.id, + "trigger_id": b.build_trigger_id or "", + "branch": (b.substitutions or {}).get("BRANCH_NAME", "main"), + "commit": (b.substitutions or {}).get("SHORT_SHA", ""), + "duration_s": duration_s, + "start_time": str(b.start_time) if b.start_time else None, + "finish_time": str(b.finish_time) if b.finish_time else None, + "log_url": b.log_url or "", + }) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"[/opax/build-status] Failed: {e}", exc_info=True) + return JSONResponse({"status": "error", "message": str(e)}, status_code=200) + + +# ── OQ-29: Notify channels per kunde ───────────────────────────────────────── +@app.get("/notify/channels") +async def notify_channels(gcp_project: str): + """ + OQ-29 — List delivery-kanaler konfigurert for en kunde. + Leser fra Firestore: onboarded_customers/{gcp_project}.channels + Returformat: { "gcp_project": "xxx", "channels": ["webhook", "email", "sms"] } + """ + if not db: + return JSONResponse( + status_code=503, + content={"error": "Firestore ikke tilgjengelig"} + ) + try: + doc = db.collection("onboarded_customers").document(gcp_project).get() + if not doc.exists: + return JSONResponse( + status_code=404, + content={"gcp_project": gcp_project, "channels": [], "detail": "Kunde ikke funnet"} + ) + data = doc.to_dict() + return JSONResponse({ + "gcp_project": gcp_project, + "company": data.get("company", ""), + "tier": data.get("tier", ""), + "channels": data.get("channels", []), + "webhook_url": data.get("webhook_url") or None, + "email_to": data.get("email_to") or None, + }) + except Exception as e: + return JSONResponse(status_code=500, content={"error": str(e)}) + + # ── MODELLER ────────────────────────────────────────────────────────────────── class RunRequest(BaseModel): message: str @@ -218,29 +292,10 @@ class EmailNotifyRequest(BaseModel): payload: Optional[Dict[str, Any]] = Field(None, description="Valgfri rådata å inkludere i e-post") class SmsNotifyRequest(BaseModel): - """ - CG5c: SMS-varsel via Twilio REST API. - Kun tilgjengelig for Guard+-tier (guard | shield | enterprise). - Brukes primært til kritiske kostnadsspikes — hold meldinger korte (<160 tegn). - """ - to: str = Field( - ..., - description="Mottakers mobilnummer i E.164-format, f.eks. +4791234567", - pattern=r"^\+[1-9]\d{7,14}$", - ) - body: str = Field( - ..., - description="Meldingstekst. Maks 160 tegn anbefalt (ett SMS-segment).", - 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(..., 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.") # ── ONBOARDING MODEL (CG5-onboard) ──────────────────────────────────────────── @@ -257,14 +312,6 @@ class OnboardCompleteRequest(BaseModel): # ── NOTIFICATION SERVICE (CG5a + CG5b + CG5c) ───────────────────────────────── class NotificationService: - """ - Felles delivery layer for CostGuard-varsler. - CG5a: send_webhook() — poster til hvilken som helst webhook-URL - CG5b: send_email() — sender via SendGrid - CG5c: send_sms() — sender via Twilio REST (Guard+-tier) - """ - - # Event-type → emoji + standardtekst EVENT_META = { "spike": {"emoji": "🚨", "label": "Kostnadsspike oppdaget"}, "digest": {"emoji": "📊", "label": "Daglig kostnadsoppsummering"}, @@ -277,7 +324,6 @@ class NotificationService: return self.EVENT_META.get(event, self.EVENT_META["custom"]) def _build_slack_payload(self, req: WebhookNotifyRequest) -> dict: - """Slack/Teams/Google Chat-kompatibelt JSON-format.""" meta = self._event_meta(req.event) return { "text": f"{meta['emoji']} *{req.title}*\n{req.body}", @@ -293,19 +339,10 @@ class NotificationService: } async def send_webhook(self, req: WebhookNotifyRequest) -> dict: - """ - CG5a: POST JSON til oppgitt webhook-URL. - Støtter Slack, Teams, Google Chat, Discord og alle custom webhooks. - """ slack_payload = self._build_slack_payload(req) try: async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post( - req.url, - json=slack_payload, - headers={"Content-Type": "application/json"}, - ) - print(f"[NotificationService] webhook → {req.url} status={resp.status_code}") + resp = await client.post(req.url, json=slack_payload, 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} @@ -315,130 +352,61 @@ class NotificationService: return {"status": "error", "detail": str(e)} async def send_email(self, req: EmailNotifyRequest) -> dict: - """ - CG5b: Send e-post via SendGrid. - Hvis body_html er None, bygges innhold fra billing-data automatisk. - """ 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"} if not to_email: - return {"status": "not_configured", "detail": "Ingen mottaker-e-post (to eller ALERT_EMAIL)"} - + return {"status": "not_configured", "detail": "Ingen mottaker-e-post"} meta = self._event_meta(req.event) subject = req.subject or f"{meta['emoji']} CostGuard: {meta['label']} — {datetime.date.today()}" - - if req.body_html: - html_content = req.body_html - else: - html_content = await self._build_digest_html(req.payload) - + html_content = req.body_html if req.body_html else await self._build_digest_html(req.payload) try: - message = Mail( - from_email="costguard@osvauco.no", - to_emails=to_email, - subject=subject, - html_content=html_content, - ) + 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", "") - print(f"[NotificationService] email → {to_email} msg_id={msg_id}") return {"status": "ok", "message_id": msg_id, "to": to_email, "event": req.event} except Exception as e: - print(f"[NotificationService] email error: {e}", file=sys.stderr) return {"status": "error", "detail": str(e)} async def send_sms(self, req: SmsNotifyRequest) -> dict: - """ - CG5c: Send SMS via Twilio REST API (ingen Twilio SDK — kun httpx). - Guard+-tier påkrevd. Konfigureres via env-vars: - TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER - """ - # Guard+-tier-sjekk if req.tier.lower() not in SMS_ALLOWED_TIERS: - return { - "status": "not_allowed", - "detail": f"SMS krever Guard+-tier. Aktuæll tier: '{req.tier}'. " - f"Oppgrader på opax.vauco.no/onboard.", - } - - account_sid = os.environ.get("TWILIO_ACCOUNT_SID") - auth_token = os.environ.get("TWILIO_AUTH_TOKEN") - from_number = os.environ.get("TWILIO_FROM_NUMBER") - + return {"status": "not_allowed", "detail": f"SMS krever Guard+-tier. Aktuell tier: '{req.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") if not all([account_sid, auth_token, from_number]): - return { - "status": "not_configured", - "detail": "En eller flere Twilio-env-vars mangler: " - "TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER", - } - + return {"status": "not_configured", "detail": "Twilio env-vars mangler"} meta = self._event_meta(req.event) - # Bygg meldingstekst med event-prefix hvis den ikke allerede starter med emoji - sms_body = req.body - if not sms_body.startswith(meta["emoji"]): - sms_body = f"{meta['emoji']} CostGuard: {sms_body}" - - # Twilio Messages API — form-encoded POST + 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(twilio_url, data={"From": from_number, "To": req.to, "Body": sms_body}, auth=(account_sid, auth_token)) data = resp.json() - print( - f"[NotificationService] sms → {req.to} " - f"sid={data.get('sid','?')} status={data.get('status','?')}" - ) if resp.status_code >= 400: - return { - "status": "error", - "http_status": resp.status_code, - "twilio_code": data.get("code"), - "detail": data.get("message", resp.text[:200]), - } - return { - "status": "ok", - "sid": data.get("sid"), - "to": req.to, - "sms_status": data.get("status"), - "event": req.event, - } + 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: - print(f"[NotificationService] sms error: {e}", file=sys.stderr) return {"status": "error", "detail": str(e)} async def _build_digest_html(self, extra_payload: Optional[dict] = None) -> str: - """Henter billing-data og bygger HTML-digest.""" try: 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"
  • {s['service']}: kr {s.get('total_cost', 0):.2f}
  • " - for s in summary[:3] - ) + top3 = "".join(f"
  • {s['service']}: kr {s.get('total_cost', 0):.2f}
  • " for s in summary[:3]) except Exception: mtd, daily_avg, top3 = 0, 0, "
  • Data ikke tilgjengelig
  • " - - extra_rows = "" - if extra_payload: - extra_rows = "".join( - f"{k}" - f"{v}" - for k, v in extra_payload.items() - ) - + extra_rows = "".join( + f"{k}{v}" + for k, v in (extra_payload or {}).items() + ) return f"""
    @@ -447,82 +415,53 @@ class NotificationService:
    - - - - + + {extra_rows}
    Kostnad MTDkr {mtd:.2f}
    Daglig snitt (7d)kr {daily_avg:.2f}
    Kostnad MTDkr {mtd:.2f}
    Daglig snitt (7d)kr {daily_avg:.2f}

    Topp 3 GCP-tjenester


    -

    CostGuard by Vauco AS · opax.vauco.no

    +

    CostGuard by Vauco AS · opax.vauco.no

    """ -# Singleton _notifier = NotificationService() # ── NOTIFY ENDPOINTS (CG5a + CG5b + CG5c) ───────────────────────────────────── - async def _notify_webhook(request: Request, req: WebhookNotifyRequest): result = await _notifier.send_webhook(req) - status_code = 200 if result["status"] == "ok" else 502 - return JSONResponse(status_code=status_code, 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) - status_code = 200 if result["status"] == "ok" else 500 - return JSONResponse(status_code=status_code, 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): - """ - CG5c: POST /notify/sms — Guard+-tier SMS via Twilio. - HTTP-svar: - 200 — sendt OK - 402 — tier ikke Guard+ (payment required / upgrade) - 503 — Twilio ikke konfigurert (env-vars mangler) - 500 — Twilio-feil - """ result = await _notifier.send_sms(req) if result["status"] == "not_allowed": return JSONResponse(status_code=402, content=result) if result["status"] == "not_configured": return JSONResponse(status_code=503, content=result) - status_code = 200 if result["status"] == "ok" else 500 - return JSONResponse(status_code=status_code, 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) ─────────────────────────────────────── - @app.get("/onboard") def onboard_wizard(): - """ - CG5-onboard: Server onboarding-wizard HTML. - IAP-exempt — kunder når denne uten eksisterende sesjon. - """ return FileResponse("static/onboard.html") @app.post("/onboard/complete") async def onboard_complete(req: OnboardCompleteRequest): - """ - CG5-onboard: Lagrer kundeprofil i Firestore og sender velkomstvarsler - via eksisterende _notifier (send_webhook + send_email). - """ if db: try: db.collection("onboarded_customers").document(req.gcp_project).set({ @@ -536,87 +475,29 @@ async def onboard_complete(req: OnboardCompleteRequest): "email_to": req.email_to, "onboarded_at": firestore.SERVER_TIMESTAMP, }) - print(f"[Onboard] {req.gcp_project} lagret i Firestore") except Exception as e: - print(f"[Onboard] Firestore-feil (ikke kritisk): {e}", file=sys.stderr) + print(f"[Onboard] Firestore-feil: {e}", file=sys.stderr) notify_results: Dict[str, Any] = {} - if req.webhook_url and "webhook" in req.channels: notify_results["webhook"] = await _notifier.send_webhook(WebhookNotifyRequest( - url=req.webhook_url, - event="custom", + url=req.webhook_url, event="custom", title=f"🎉 {req.company} er koblet til CostGuard!", - body=( - f"Hei {req.contact}! CostGuard overvåker nå *{req.gcp_project}*. " - f"Du mottar første kostnadsrapport innen 24 timer." - ), - payload={ - "bedrift": req.company, - "tier": req.tier, - "prosjekt": req.gcp_project, - "dato": datetime.date.today().isoformat(), - }, + body=f"Hei {req.contact}! CostGuard overvåker nå *{req.gcp_project}*.", + payload={"bedrift": req.company, "tier": req.tier, "prosjekt": req.gcp_project, "dato": datetime.date.today().isoformat()}, )) - if req.email_to and "email" in req.channels: - TIER_LABELS = { - "starter": "Starter ($499/mnd)", - "guard": "Guard ($999/mnd)", - "shield": "Shield ($1.999/mnd)", - "enterprise": "Enterprise ($3.500+/mnd)", - } - tier_label = TIER_LABELS.get(req.tier, req.tier) + TIER_LABELS = {"starter": "Starter ($499/mnd)", "guard": "Guard ($999/mnd)", "shield": "Shield ($1.999/mnd)", "enterprise": "Enterprise ($3.500+/mnd)"} notify_results["email"] = await _notifier.send_email(EmailNotifyRequest( - to=req.email_to, - event="custom", + to=req.email_to, event="custom", subject=f"✅ Velkommen til CostGuard — {req.company}", - body_html=f""" -
    -
    -

    🎉 Velkommen til CostGuard!

    -
    -
    -

    Hei {req.contact},

    -

    CostGuard overvåker nå ditt GCP-prosjekt og vil varsle deg ved kostnadsspikes, - anomalier og budsjettoverskridelser.

    - - - - - - - - - - - - - -
    Bedrift{req.company}
    GCP-prosjekt{req.gcp_project}
    Tier{tier_label}
    Kanaler{', '.join(req.channels)}
    -

    Du mottar din første kostnadsrapport innen 24 timer.

    -
    - - Åpne dashboard → - -
    -
    -

    CostGuard by Vauco AS · opax.vauco.no

    -
    -
    - """, + body_html=f"

    Hei {req.contact}, CostGuard overvåker nå {req.gcp_project} (tier: {TIER_LABELS.get(req.tier, req.tier)}).

    ", )) - internal_wh = os.environ.get("VAUCO_INTERNAL_WEBHOOK") if internal_wh: try: await _notifier.send_webhook(WebhookNotifyRequest( - url=internal_wh, - event="custom", + url=internal_wh, event="custom", title="🆕 Ny CostGuard-kunde onboardet", body=f"{req.company} ({req.gcp_project}) — tier: {req.tier}", payload={"kontakt": req.contact, "e-post": req.email, "kanaler": ", ".join(req.channels)}, @@ -624,13 +505,7 @@ async def onboard_complete(req: OnboardCompleteRequest): except Exception as e: print(f"[Onboard] Intern Slack-feil: {e}", file=sys.stderr) - return { - "status": "ok", - "customer": req.gcp_project, - "company": req.company, - "channels": req.channels, - "notify": notify_results, - } + return {"status": "ok", "customer": req.gcp_project, "company": req.company, "channels": req.channels, "notify": notify_results} # ── ROOT LANDING PAGE ─────────────────────────────────────────────────── @@ -655,7 +530,7 @@ def service_worker(): return FileResponse("static/sw.js") -# ── AUTH — User Authentication ────────────────────────────────────────── +# ── ADMIN ───────────────────────────────────────────────────────────────────── @app.get('/admin') @require_auth async def admin_panel(request: Request): @@ -664,76 +539,48 @@ async def admin_panel(request: Request): @app.post('/admin/create-customer') @require_auth async def create_customer(request: Request): - import subprocess, shlex + import subprocess data = await request.json() - customer_name = data.get("customer_name", "").strip() - project_id = data.get("project_id", "").strip() + customer_name = data.get("customer_name", "").strip() + project_id = data.get("project_id", "").strip() billing_account_id = data.get("billing_account_id", "").strip() - alert_email = data.get("alert_email", "").strip() - container_image = data.get("container_image", "").strip() - region = data.get("region", "europe-north1").strip() - + alert_email = data.get("alert_email", "").strip() + container_image = data.get("container_image", "").strip() + 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 os, shutil + 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}" -project_id = "{project_id}" -region = "{region}" -billing_account_id = "{billing_account_id}" -alert_email = "{alert_email}" -billing_viewer_emails = ["chris.christiansen@vauco.no", "jason.vauger@vauco.no"] -container_image = "{container_image}" -''' + 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) - try: - init = subprocess.run( - ["terraform", f"-chdir={customer_dir}", "init", "-no-color"], - capture_output=True, text=True, timeout=120 - ) + init = subprocess.run(["terraform", f"-chdir={customer_dir}", "init", "-no-color"], capture_output=True, text=True, timeout=120) if init.returncode != 0: shutil.rmtree(customer_dir) raise HTTPException(status_code=500, detail=f"terraform init feilet: {init.stderr[-500:]}") - - apply = subprocess.run( - ["terraform", f"-chdir={customer_dir}", "apply", "-auto-approve", "-no-color"], - capture_output=True, text=True, timeout=600 - ) + apply = subprocess.run(["terraform", f"-chdir={customer_dir}", "apply", "-auto-approve", "-no-color"], capture_output=True, text=True, timeout=600) if apply.returncode != 0: 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)") # ── BILLING ENDPOINTS (CG1 + CG2) ──────────────────────────────────────────── - async def get_budget(request: Request): if not db: return JSONResponse(status_code=500, content={"error": "Firestore is not configured"}) try: - doc_ref = db.collection("settings").document("budget") - doc = doc_ref.get() - if doc.exists: - return JSONResponse(content={"budget": doc.to_dict().get("limit", 500)}) - else: - return JSONResponse(content={"budget": 500}) + doc = db.collection("settings").document("budget").get() + return JSONResponse(content={"budget": doc.to_dict().get("limit", 500) if doc.exists else 500}) except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) @@ -741,8 +588,7 @@ async def set_budget(request: Request, payload: BudgetUpdateRequest): if not db: return JSONResponse(status_code=500, content={"error": "Firestore is not configured"}) try: - doc_ref = db.collection("settings").document("budget") - doc_ref.set({"limit": payload.budget}) + db.collection("settings").document("budget").set({"limit": payload.budget}) return JSONResponse(content={"status": "ok", "budget": payload.budget}) except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) @@ -756,16 +602,12 @@ 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"]}) - if result["status"] == "error": - return JSONResponse(status_code=500, content=result) - return JSONResponse(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): - email_response = await trigger_email_report() - if email_response.status_code != 200: - print(f"Daily snapshot: email report failed. Status: {email_response.status_code}", file=sys.stderr) + await trigger_email_report() return JSONResponse(content={"status": "ok", "snapshot_id": str(datetime.date.today())}) @@ -773,20 +615,17 @@ 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() + 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() + .limit(90).stream() history = [{"date": doc.id, **doc.to_dict()} for doc in docs] history.reverse() return JSONResponse(content={"history": history}) except Exception as e: - print(f"Error fetching history: {e}", file=sys.stderr) return JSONResponse(status_code=500, content={"error": str(e)}) - app.add_api_route("/billing/history", endpoint=require_auth(get_history), methods=["GET"]) @@ -823,43 +662,31 @@ def subscribe_for_push(sub: PushSubscription): if not db: raise HTTPException(status_code=500, detail="Firestore is not configured") try: - doc_ref = db.collection("push_subscribers").document(sub.token) - doc_ref.set({"budget_nok": sub.budget_nok, "subscribed_at": firestore.SERVER_TIMESTAMP}) + db.collection("push_subscribers").document(sub.token).set({"budget_nok": sub.budget_nok, "subscribed_at": firestore.SERVER_TIMESTAMP}) return {"status": "ok"} except Exception as e: - print(f"Error subscribing token: {e}", file=sys.stderr) raise HTTPException(status_code=500, detail=str(e)) @app.post("/billing/budget-webhook") async def budget_webhook(payload: BudgetWebhookPayload): if not db: - print("Webhook called but Firestore is not configured. Aborting.", file=sys.stderr) return {"status": "error", "detail": "Firestore not configured"} try: data = base64.b64decode(payload.message.get("data", "")).decode("utf-8") data_json = json.loads(data) - cost_amount = data_json.get("costAmount", 0) + cost_amount = data_json.get("costAmount", 0) budget_amount = data_json.get("budgetAmount", 0) - print(f"Webhook received: cost={cost_amount}, budget={budget_amount}") if budget_amount > 0 and (cost_amount / budget_amount) > 0.8: percent_used = round((cost_amount / budget_amount) * 100) - subscribers_ref = db.collection("push_subscribers") - tokens = [subscriber.id for subscriber in subscribers_ref.stream()] - if not tokens: - print("Budget threshold exceeded, but no push subscribers found.") - return {"status": "no subscribers"} - notification = messaging.Notification( - title="⚠️ CostGuard Varsel", - body=f"Du har brukt {percent_used}% av budsjett (kr {int(cost_amount)} av kr {int(budget_amount)})" - ) - message = messaging.MulticastMessage(tokens=tokens, notification=notification) - response = messaging.send_multicast(message) - print(f"Sent push notification to {response.success_count} subscribers.") - else: - print("Budget threshold not exceeded, no notification sent.") + 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)) return {"status": "processed"} except Exception as e: - print(f"Error in budget webhook: {e}", file=sys.stderr) return {"status": "error", "detail": str(e)} @@ -871,13 +698,12 @@ async def billing_live(request: Request): return JSONResponse(status_code=401, content={"error": "Not authenticated"}) if ALLOWED_EMAILS and user.get('email') not in ALLOWED_EMAILS: return JSONResponse(status_code=403, content={"error": "Email not allowed"}) - print("Billing Budget API not implemented, falling back to BigQuery", file=sys.stderr) bq_forecast = BillingAgent().get_forecast() bq_forecast["data_source"] = "BigQuery Fallback" return bq_forecast -# ── AWS BILLING ENDPOINTS ───────────────────────────────────────────────────── +# ── AWS BILLING ─────────────────────────────────────────────────────────────── async def authenticated_aws_billing_summary(request: Request): try: return AWSBillingAgent().get_summary() @@ -902,42 +728,25 @@ def run_agent(req: RunRequest): raise HTTPException(status_code=400, detail=str(e)) except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) - store = get_store() start = time.monotonic() error_msg = None - response = None - + response = None try: - response = run( - message=req.message, - user_id=req.user_id, - session_id=req.session_id, - mode=req.mode, - ) + response = run(message=req.message, user_id=req.user_id, session_id=req.session_id, mode=req.mode) except Exception as e: error_msg = str(e) raise HTTPException(status_code=500, detail=error_msg) finally: duration = round(time.monotonic() - start, 3) - success = error_msg is None - model = "gemini-2.5-flash" if req.mode in ("light", "A") else "gemini-2.5-pro" - log_agent_call( - agent_id=AGENT_ID, - input_payload={"message": req.message, "mode": req.mode}, - output=response, - model_used=model, - mode=req.mode, - duration_s=duration, - success=success, - error=error_msg, - ) + success = error_msg is None + model = "gemini-2.5-flash" if req.mode in ("light", "A") else "gemini-2.5-pro" + log_agent_call(agent_id=AGENT_ID, input_payload={"message": req.message, "mode": req.mode}, output=response, model_used=model, mode=req.mode, duration_s=duration, success=success, error=error_msg) store.push(AGENT_ID, "last_duration_s", duration) store.push(AGENT_ID, "last_mode", req.mode) store.push(AGENT_ID, "last_success", success) if response: store.push(AGENT_ID, "last_result_preview", response[:200]) - return {"response": response} @@ -949,46 +758,26 @@ 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): - return run( - message=msg, - user_id=payload["user_id"], - session_id=f"{payload['session_id']}-dag-{idx}", - mode=payload["mode"], - ) + 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} + 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) + 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) store.push(AGENT_ID, "last_dag_total_duration_s", total_dur) store.push(AGENT_ID, "last_dag_agent_count", len(agent_ids)) store.push(AGENT_ID, "last_dag_success_count", sum(1 for r in results if r["success"])) - return { "total_duration_s": total_dur, - "results": [ - { - "index": i, - "message": req.messages[i], - "response": r["result"], - "success": r["success"], - "duration_s": r["duration_s"], - "error": r["error"], - } - for i, r in enumerate(results) - ], + "results": [{"index": i, "message": req.messages[i], "response": r["result"], "success": r["success"], "duration_s": r["duration_s"], "error": r["error"]} for i, r in enumerate(results)], }