|
1 | | -"""FastAPI service with live config refresh.""" |
2 | | -import time |
| 1 | +"""FastAPI service with startup auth verification.""" |
| 2 | +import sys |
3 | 3 | import logging |
4 | 4 | from contextlib import asynccontextmanager |
5 | 5 |
|
6 | 6 | from fastapi import FastAPI, HTTPException |
7 | | -from pydantic import BaseModel, Field |
8 | 7 |
|
9 | | -from agent import get_agent |
| 8 | +from config import get_settings, verify_azure_auth, get_auth_report |
10 | 9 | from cache import get_cache |
11 | | -from config import get_settings, refresh_settings, get_app_config_loader |
12 | 10 |
|
13 | 11 |
|
14 | 12 | @asynccontextmanager |
15 | 13 | async def lifespan(app: FastAPI): |
16 | | - settings = get_settings() |
| 14 | + # ===== Phase 1: Logging ===== |
17 | 15 | logging.basicConfig( |
18 | | - level=settings.log_level, |
| 16 | + level="INFO", # Will be overridden once config is loaded |
19 | 17 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", |
20 | 18 | ) |
21 | 19 | logger = logging.getLogger(__name__) |
22 | | - logger.info(f"Starting weather-agent (model={settings.openai_model})") |
23 | | - logger.info(f"App Config enabled: {settings.use_app_configuration}") |
24 | | - logger.info(f"Feature flags: streaming={settings.feature_streaming}, " |
25 | | - f"response_cache={settings.feature_response_cache}") |
26 | 20 |
|
27 | | - get_cache() |
28 | | - get_agent() |
29 | | - logger.info("Agent ready ✓") |
30 | | - yield |
| 21 | + logger.info("=" * 60) |
| 22 | + logger.info("🌤️ Weather Agent starting up") |
| 23 | + logger.info("=" * 60) |
| 24 | + |
| 25 | + # ===== Phase 2: Verify Azure auth (fail fast) ===== |
| 26 | + try: |
| 27 | + report = verify_azure_auth(strict=True) |
| 28 | + if not report.overall_success: |
| 29 | + # This branch only hits when strict=False; with strict=True |
| 30 | + # an exception will have been raised. |
| 31 | + logger.error("Azure auth verification FAILED") |
| 32 | + sys.exit(1) |
| 33 | + except RuntimeError as e: |
| 34 | + logger.error(f"❌ Startup failed: {e}") |
| 35 | + # Exit with non-zero so K8s restarts the pod (likely with backoff) |
| 36 | + sys.exit(1) |
| 37 | + except Exception as e: |
| 38 | + logger.exception(f"❌ Unexpected startup error: {e}") |
| 39 | + sys.exit(1) |
| 40 | + |
| 41 | + # ===== Phase 3: Load config ===== |
| 42 | + try: |
| 43 | + settings = get_settings() |
| 44 | + logging.getLogger().setLevel(settings.log_level) |
| 45 | + logger.info(f"Config loaded (model={settings.openai_model})") |
| 46 | + except Exception as e: |
| 47 | + logger.exception(f"❌ Config load failed: {e}") |
| 48 | + sys.exit(1) |
| 49 | + |
| 50 | + # ===== Phase 4: Warm up dependencies ===== |
| 51 | + try: |
| 52 | + get_cache() |
| 53 | + from agent import get_agent |
| 54 | + get_agent() |
| 55 | + logger.info("✅ Agent ready — accepting traffic") |
| 56 | + except Exception as e: |
| 57 | + logger.exception(f"❌ Agent warm-up failed: {e}") |
| 58 | + sys.exit(1) |
| 59 | + |
| 60 | + yield # ← App is running |
| 61 | + |
| 62 | + logger.info("👋 Shutting down") |
31 | 63 |
|
32 | 64 |
|
33 | | -app = FastAPI(title="Weather Agent API", version="1.3.0", lifespan=lifespan) |
| 65 | +app = FastAPI(title="Weather Agent API", version="1.4.0", lifespan=lifespan) |
34 | 66 | logger = logging.getLogger(__name__) |
35 | 67 |
|
36 | 68 |
|
37 | | -class WeatherQuery(BaseModel): |
38 | | - query: str = Field(..., min_length=1, max_length=500) |
39 | | - bypass_cache: bool = False |
40 | | - |
41 | | - |
42 | | -class WeatherResponse(BaseModel): |
43 | | - answer: str |
44 | | - latency_ms: int |
45 | | - cached: bool = False |
46 | | - model_used: str |
47 | | - |
48 | | - |
49 | | -@app.get("/") |
50 | | -def root(): |
51 | | - return {"service": "weather-agent", "status": "ok"} |
52 | | - |
| 69 | +# ===== Health & readiness endpoints ===== |
53 | 70 |
|
54 | 71 | @app.get("/health") |
55 | 72 | def health(): |
| 73 | + """Liveness probe — process is alive.""" |
56 | 74 | return {"status": "healthy"} |
57 | 75 |
|
58 | 76 |
|
59 | 77 | @app.get("/ready") |
60 | 78 | def ready(): |
| 79 | + """Readiness probe — pod can serve traffic. |
| 80 | + |
| 81 | + Returns 503 if Azure auth checks haven't passed. |
| 82 | + """ |
| 83 | + report = get_auth_report() |
| 84 | + if report is None or not report.overall_success: |
| 85 | + raise HTTPException( |
| 86 | + status_code=503, |
| 87 | + detail={ |
| 88 | + "status": "not_ready", |
| 89 | + "reason": "Azure authentication not verified", |
| 90 | + "failed_checks": [ |
| 91 | + {"name": c.name, "error": c.error} |
| 92 | + for c in (report.failed_checks if report else []) |
| 93 | + ], |
| 94 | + }, |
| 95 | + ) |
| 96 | + |
| 97 | + settings = get_settings() |
| 98 | + return { |
| 99 | + "status": "ready", |
| 100 | + "model": settings.openai_model, |
| 101 | + "azure": { |
| 102 | + "app_config": settings.use_app_configuration, |
| 103 | + "key_vault": settings.use_key_vault, |
| 104 | + "identity": report.identity_info, |
| 105 | + }, |
| 106 | + } |
| 107 | + |
| 108 | + |
| 109 | +@app.get("/auth/status") |
| 110 | +def auth_status(): |
| 111 | + """Show the Azure authentication verification report.""" |
| 112 | + report = get_auth_report() |
| 113 | + if report is None: |
| 114 | + return {"status": "not_yet_verified"} |
| 115 | + return { |
| 116 | + "overall_success": report.overall_success, |
| 117 | + "identity": report.identity_info, |
| 118 | + "checks": [ |
| 119 | + { |
| 120 | + "name": c.name, |
| 121 | + "success": c.success, |
| 122 | + "duration_ms": c.duration_ms, |
| 123 | + "detail": c.detail, |
| 124 | + "error": c.error, |
| 125 | + } |
| 126 | + for c in report.checks |
| 127 | + ], |
| 128 | + } |
| 129 | + |
| 130 | + |
| 131 | +@app.post("/auth/verify") |
| 132 | +def reverify_auth(): |
| 133 | + """Manually re-run the auth verification (useful after credential rotation).""" |
61 | 134 | try: |
62 | | - settings = get_settings() |
| 135 | + report = verify_azure_auth(strict=False) |
63 | 136 | return { |
64 | | - "status": "ready", |
65 | | - "model": settings.openai_model, |
66 | | - "cache_backend": settings.cache_backend, |
67 | | - "app_config": settings.use_app_configuration, |
68 | | - "features": { |
69 | | - "response_cache": settings.feature_response_cache, |
70 | | - "tool_cache": settings.feature_tool_cache, |
71 | | - "streaming": settings.feature_streaming, |
72 | | - "strict_mode": settings.feature_strict_mode, |
73 | | - }, |
| 137 | + "overall_success": report.overall_success, |
| 138 | + "checks_passed": len([c for c in report.checks if c.success]), |
| 139 | + "checks_total": len(report.checks), |
74 | 140 | } |
75 | 141 | except Exception as e: |
76 | | - raise HTTPException(status_code=503, detail=str(e)) |
77 | | - |
| 142 | + raise HTTPException(status_code=500, detail=str(e)) |
78 | 143 |
|
79 | 144 | @app.post("/ask", response_model=WeatherResponse) |
80 | 145 | def ask_weather(payload: WeatherQuery): |
|
0 commit comments