#!/usr/bin/env python3 """ main.py — Cloud Run entrypoint, OSVauco OPAX. Modes: light (gemini-2.5-flash) | heavy (gemini-2.5-pro) ML-1: telemetri, state store, DAG. CG1+CG2: billing endpoints + IAP-beskyttelse. CG3: static-mappe serveres fra /static/*. AUTH: /auth/login + /auth/callback for klient OAuth2 onboarding. """ import os import sys import time import pathlib sys.path.insert(0, os.path.join(os.path.dirname(__file__), "agents", "core-logic")) from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from typing import List import datetime from cachetools import cached, TTLCache from agent import run, authorize_mode from ml import build_agent_dag, execute_dag, get_store, log_agent_call from ml.telemetry import log_dag_execution 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 google.cloud import firestore import firebase_admin from firebase_admin import credentials, messaging import base64 import json from functools import wraps from starlette.middleware.sessions import SessionMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from authlib.integrations.starlette_client import OAuth import sendgrid from sendgrid.helpers.mail import Mail AGENT_ID = "osvauco-opax" app = FastAPI( title="OSVauco OPAX Agent", description="Agent for OSVauco-OPAX platform.", 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). IAP_EXEMPT_PATHS = {"/health", "/healthz", "/readiness", "/liveness"} @app.middleware("http") async def require_iap(request: Request, call_next): if request.url.path in IAP_EXEMPT_PATHS: return await call_next(request) if not request.headers.get("x-goog-authenticated-user-email"): return Response(status_code=401, content="Unauthorized") return await call_next(request) # ── FIREBASE & FIRESTORE INIT ─────────────────────────────────────────────── db = None try: # When running in Google Cloud, ADC will be used automatically. # For local dev, `gcloud auth application-default login` is required. firebase_admin.initialize_app() db = firestore.Client() print("Firestore client initialized successfully.") except Exception as e: # The app can still run in a limited mode without Firestore. # Endpoints that depend on `db` will return a 500 error. print(f"WARNING: Firestore client failed to initialize: {e}", file=sys.stderr) # ── AUTH & SESSION ──────────────────────────────────────────────────────────── # Add session middleware for storing auth state app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") app.add_middleware(SessionMiddleware, secret_key=os.environ.get("SESSION_SECRET")) # Configure Authlib's OAuth client oauth = OAuth() oauth.register( name='google', client_id=os.environ.get("GOOGLE_CLIENT_ID"), client_secret=os.environ.get("GOOGLE_CLIENT_SECRET"), server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', client_kwargs={ 'scope': 'openid email profile' } ) @app.get('/auth/login') async def login(request: Request): """Redirects user to Google's OAuth 2.0 login page.""" redirect_uri = "https://opax.vauco.no/auth/callback" return await oauth.google.authorize_redirect(request, redirect_uri) @app.get('/auth/callback', name='auth') async def auth(request: Request): """ Handles the callback from Google's OAuth. Stores user info in session and redirects to the billing dashboard. """ token = await oauth.google.authorize_access_token(request) user = token.get('userinfo') if user: request.session['user'] = dict(user) return RedirectResponse(url='/static/billing-dashboard.html') @app.get('/auth/me') @require_auth async def me(request: Request): """Returns the authenticated user's information.""" user = request.session.get('user') return JSONResponse(user) @app.get('/auth/logout') async def logout(request: Request): """Clears the user session and logs them out.""" request.session.pop('user', None) return RedirectResponse(url='/static/billing-dashboard.html') # Allowed emails for login ALLOWED_EMAILS = [email.strip() for email in os.environ.get("ALLOWED_EMAILS", "").split(",") if email.strip()] 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') if not user: 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"}) return await func(request, *args, **kwargs) return wrapper # ── STATIC FILES ────────────────────────────────────────────────────────────── _static_dir = pathlib.Path(__file__).parent / "static" if _static_dir.is_dir(): app.mount("/static", StaticFiles(directory=str(_static_dir), html=True), name="static") # ── MODELLER ────────────────────────────────────────────────────────────────── class RunRequest(BaseModel): message: str user_id: str = "opax" session_id: str = "default" mode: str = "light" class DagRequest(BaseModel): messages: List[str] = Field(...) user_id: str = "opax" session_id: str = "default" mode: str = "light" scheduler: str = Field("threads") class PushSubscription(BaseModel): token: str budget_nok: float class BudgetWebhookPayload(BaseModel): message: dict subscription: str class BudgetUpdateRequest(BaseModel): budget: float # ── ROOT LANDING PAGE ─────────────────────────────────────────────────── @app.get("/") def root(): """Serves the main landing page.""" return FileResponse("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") @app.get("/sw.js", include_in_schema=False) def service_worker(): return FileResponse("static/sw.js") # ── AUTH — User Authentication ────────────────────────────────────────── @app.get('/admin') @require_auth async def admin_panel(request: Request): return FileResponse("static/admin.html") @app.post('/admin/create-customer') @require_auth async def create_customer(request: Request): import subprocess, shlex 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() 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 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}" ''' 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 ) 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 ) 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}) # Default except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) 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}) return JSONResponse(content={"status": "ok", "budget": payload.budget}) except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) 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(): """ Generates and sends a daily cost summary email. """ if not ALERT_EMAIL or not os.environ.get("SENDGRID_API_KEY"): err_msg = "Email reporting is not configured. Missing ALERT_EMAIL or SENDGRID_API_KEY." print(err_msg, file=sys.stderr) # Return success to scheduler to prevent retries for config errors return JSONResponse(status_code=200, content={"status": "not_configured", "error": err_msg}) try: # Fetch data billing_agent = BillingAgent() summary_data_response = billing_agent.get_summary() summary_data = summary_data_response.get("summary", []) forecast_data = billing_agent.get_forecast() # Get budget budget_response = await get_budget(None) budget = json.loads(budget_response.body).get("budget", 500) # Format email content mtd_cost = forecast_data.get("month_to_date_cost", 0) daily_avg = forecast_data.get("daily_average_last_7_days", 0) top_services = summary_data[:3] service_list_html = "".join([f"
Kostnad MTD: kr {mtd_cost:.2f}
Daglig snitt (7d): kr {daily_avg:.2f}
Budsjettstatus: kr {mtd_cost:.2f} av kr {budget:.2f}
Topp 3 GCP-tjenester:
--
CostGuard by Vauco