Tribh/devops-copilot
0
1"""2api.py — FastAPI server exposing DevOps Copilot control plane.3 4Endpoints:5 POST /sessions/{session_id}/approve — Grant human approval (unblocks engine)6 POST /sessions/{session_id}/reject — Reject proposed action (marks false positive)7 GET /sessions/{session_id} — Inspect current session state8 GET /sessions — List all active sessions9 GET /health — Liveness probe10"""11from fastapi import FastAPI, HTTPException12from fastapi.middleware.cors import CORSMiddleware13from fastapi.responses import JSONResponse14from pydantic import BaseModel15from typing import Optional, List, Dict, Any16from devops_copilot.core.persistence import PersistenceLayer17from devops_copilot.core.observability import FALSE_POSITIVE_TOTAL, ACTIVE_INCIDENTS18from devops_copilot.tools.devops_tools import scrape_metrics, log_store19from devops_copilot.utils.logger import logger20import os21 22app = FastAPI(23 title="AgentNexus DevOps Copilot API",24 description="Control plane for the AI DevOps Copilot — approve/reject agent actions.",25 version="1.0.0",26)27 28@app.get("/")29async def read_root():30 return {"status": "ACTIVE", "version": "v3.0.0", "service": "DevOps Copilot", "message": "SRE Watchtower Ready"}31 32# Enable CORS for the portfolio frontend33app.add_middleware(34 CORSMiddleware,35 allow_origins=["*"], # In production, restrict to your portfolio URL36 allow_credentials=True,37 allow_methods=["*"],38 allow_headers=["*"],39)40 41# Shared persistence layer (same DB as the engine)42_db_path = os.getenv("DATABASE_URL", "agentnexus_state.db").replace("sqlite:///./", "")43persistence = PersistenceLayer(db_path=_db_path)44 45 46@app.on_event("startup")47async def _startup():48 await persistence.setup()49 logger.info("API server started.")50 51 52# ── Request/Response models ────────────────────────────────────────────────────53 54class ApproveRequest(BaseModel):55 reason: Optional[str] = "Approved via API"56 57 58class RejectRequest(BaseModel):59 reason: Optional[str] = "Rejected — false positive"60 61 62# ── Endpoints ──────────────────────────────────────────────────────────────────63 64@app.get("/health")65async def health():66 return {"status": "ok"}67 68 69@app.get("/sessions")70async def list_sessions():71 sessions = await persistence.list_sessions()72 return {"sessions": sessions, "count": len(sessions)}73 74 75@app.get("/sessions/{session_id}")76async def get_session(session_id: str):77 state = await persistence.load_session(session_id)78 if state is None:79 raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found.")80 return state81 82 83@app.post("/sessions/{session_id}/approve")84async def approve_action(session_id: str, body: ApproveRequest = ApproveRequest()):85 """86 Grant human approval for a PENDING_APPROVAL action.87 The engine polls session state — setting human_approved=True unblocks it.88 """89 try:90 await persistence.set_approved(session_id)91 except KeyError as e:92 raise HTTPException(status_code=404, detail=str(e))93 logger.info(f"Approved session {session_id}: {body.reason}")94 return {"session_id": session_id, "status": "approved", "reason": body.reason}95 96 97@app.post("/sessions/{session_id}/reject")98async def reject_action(session_id: str, body: RejectRequest = RejectRequest()):99 """100 Reject a pending action and track it as a false positive in Prometheus.101 """102 state = await persistence.load_session(session_id)103 if state is None:104 raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found.")105 106 # Record false positive metric107 FALSE_POSITIVE_TOTAL.inc()108 109 # Mark rejected so the engine can clean up or try a different plan110 state.setdefault("metadata", {})["human_approved"] = False111 state["metadata"]["rejected"] = True112 await persistence.save_session(session_id, state)113 114 logger.warning(f"Rejected session {session_id}: {body.reason}")115 return {"session_id": session_id, "status": "rejected", "reason": body.reason}116 117 118@app.get("/health-summary")119async def health_summary():120 """121 Returns a unified view of all monitored services.122 Used by the portfolio dashboard.123 """124 services = ["docugenie", "portfolio"]125 summary = []126 127 for svc in services:128 # Get live metrics summary from the tool129 metrics_raw = scrape_metrics(svc)130 131 # Simple health check from log volume/errors132 window = 300133 error_rate = log_store.get_error_rate_sync(svc, window_seconds=window)134 135 summary.append({136 "service": svc,137 "status": "HEALTHY" if error_rate < 0.1 else "DEGRADED",138 "metrics": metrics_raw,139 "error_rate": f"{error_rate*100:.1f}%"140 })141 142 return {143 "overall_status": "OK" if all(s["status"] == "HEALTHY" for s in summary) else "ISSUE_DETECTED",144 "active_incidents": 0, # Should tie into ACTIVE_INCIDENTS gauge145 "services": summary146 }147 148 149class ScenarioRequest(BaseModel):150 scenario: str151 152 153@app.post("/trigger-scenario")154async def trigger_scenario(body: ScenarioRequest):155 """156 Trigger a specific chaos scenario.157 """158 from devops_copilot.tools.devops_tools import trigger_chaos_simulation159 result = trigger_chaos_simulation(body.scenario)160 if "Error" in result or "Unknown" in result:161 raise HTTPException(status_code=400, detail=result)162 return {"status": "success", "message": result}163 