#!/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 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.oauth_flow import get_authorization_url, exchange_code_for_token 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", ) # ── 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' } ) # 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.""" # This will be updated in a later step to a proper landing page return FileResponse("static/command-hub.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('/auth/login') async def auth_login(request: Request): """Redirects to Google for login.""" redirect_uri = request.url_for('auth_callback') return await oauth.google.authorize_redirect(request, redirect_uri) @app.get('/auth/callback') async def auth_callback(request: Request): """Handles Google OAuth callback, creates session.""" try: token = await oauth.google.authorize_access_token(request) user = token.get('userinfo') if user: email = user.get('email') if not ALLOWED_EMAILS or email in ALLOWED_EMAILS: request.session['user'] = dict(user) else: request.session.clear() # Redirect to a "not authorized" page or show an error return JSONResponse( status_code=403, content={"error": f"Access denied for {email}. Please contact your administrator."} ) except Exception as e: print(f"Error during auth callback: {e}", file=sys.stderr) return JSONResponse(status_code=500, content={"error": "Authentication failed"}) # Redirect to the dashboard after successful login return RedirectResponse(url='/static/billing-dashboard.html') @app.get('/auth/logout') async def auth_logout(request: Request): """Clears the user session.""" request.session.clear() return RedirectResponse(url='/') @app.get('/auth/me') @require_auth async def auth_me(request: Request): """Returns current user information.""" return JSONResponse(request.session.get('user')) @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