feat: add all application source code
- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1
apps/api/src/routes/__init__.py
Normal file
1
apps/api/src/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API Routes"""
|
||||
184
apps/api/src/routes/agent.py
Normal file
184
apps/api/src/routes/agent.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Agent (ClawBot) Endpoints
|
||||
ADR-005: BFF 架構 - 所有 AI 調用經過 BFF
|
||||
Phase 1.2: 真實 Ollama 串接
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ==================== Ollama Config ====================
|
||||
OLLAMA_BASE_URL = "http://192.168.0.188:11434"
|
||||
OLLAMA_MODEL = "llama3.2:latest" # 可根據實際部署調整
|
||||
OLLAMA_TIMEOUT = 120.0 # 串流超時
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
conversation_id: UUID | None = None
|
||||
context: dict | None = None
|
||||
|
||||
|
||||
class SuggestedAction(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
risk_level: Literal["low", "medium", "high", "critical"]
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
message: str
|
||||
conversation_id: UUID
|
||||
actions: list[SuggestedAction] | None = None
|
||||
requires_approval: bool = False
|
||||
approval_id: UUID | None = None
|
||||
|
||||
|
||||
class AgentStatus(BaseModel):
|
||||
status: Literal["idle", "thinking", "executing", "waiting_approval"]
|
||||
active_conversations: int
|
||||
current_task: str | None = None
|
||||
last_activity: datetime | None = None
|
||||
|
||||
|
||||
@router.post("/chat", response_model=ChatResponse)
|
||||
async def chat_with_agent(request: ChatRequest) -> ChatResponse:
|
||||
"""與 ClawBot 對話"""
|
||||
conversation_id = request.conversation_id or uuid4()
|
||||
|
||||
# TODO: 實際調用 ClawBot
|
||||
return ChatResponse(
|
||||
message=f"收到訊息: {request.message}",
|
||||
conversation_id=conversation_id,
|
||||
requires_approval=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def chat_with_agent_stream(request: ChatRequest) -> StreamingResponse:
|
||||
"""與 ClawBot 對話 (SSE 串流)"""
|
||||
|
||||
async def generate():
|
||||
# TODO: 實際串流
|
||||
yield "data: Hello from ClawBot\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=AgentStatus)
|
||||
async def get_agent_status() -> AgentStatus:
|
||||
"""ClawBot 狀態"""
|
||||
return AgentStatus(
|
||||
status="idle",
|
||||
active_conversations=0,
|
||||
current_task=None,
|
||||
last_activity=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/thinking")
|
||||
async def get_agent_thinking(
|
||||
prompt: str = Query(
|
||||
default="你是 AWOOOI 智能運維助手。請簡短分析一下目前系統的健康狀態,用中文回答。",
|
||||
description="發送給 AI 的提示詞",
|
||||
),
|
||||
model: str = Query(default=OLLAMA_MODEL, description="Ollama 模型名稱"),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
ClawBot 思考軌跡 (SSE 串流)
|
||||
Phase 1.2: 真實串接 Ollama at 192.168.0.188:11434
|
||||
"""
|
||||
|
||||
async def generate_thinking_stream():
|
||||
"""串接 Ollama 並轉換為 SSE 格式"""
|
||||
# 1. 開始思考
|
||||
yield f"data: {json.dumps({'type': 'thinking', 'content': '正在連接 AI 模型...'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=OLLAMA_TIMEOUT) as client:
|
||||
# 2. 發送請求到 Ollama
|
||||
yield f"data: {json.dumps({'type': 'thinking', 'content': f'模型: {model}'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{OLLAMA_BASE_URL}/api/generate",
|
||||
json={
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
yield f"data: {json.dumps({'type': 'error', 'content': f'Ollama 錯誤: HTTP {response.status_code}'}, ensure_ascii=False)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'thinking', 'content': '開始接收 AI 回應...'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 3. 串流讀取 Ollama 回應
|
||||
buffer = ""
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
token = chunk.get("response", "")
|
||||
done = chunk.get("done", False)
|
||||
|
||||
if token:
|
||||
# 累積 token,每 10 字符或遇到標點符號時發送
|
||||
buffer += token
|
||||
if len(buffer) >= 10 or any(p in buffer for p in "。!?,、\n"):
|
||||
yield f"data: {json.dumps({'type': 'thinking', 'content': buffer}, ensure_ascii=False)}\n\n"
|
||||
buffer = ""
|
||||
|
||||
if done:
|
||||
# 發送剩餘 buffer
|
||||
if buffer:
|
||||
yield f"data: {json.dumps({'type': 'thinking', 'content': buffer}, ensure_ascii=False)}\n\n"
|
||||
# 發送完成訊息
|
||||
yield f"data: {json.dumps({'type': 'result', 'content': '分析完成'}, ensure_ascii=False)}\n\n"
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"JSON 解析失敗: {line[:100]}... - {e}")
|
||||
continue
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"無法連接 Ollama: {e}")
|
||||
yield f"data: {json.dumps({'type': 'error', 'content': f'無法連接 Ollama ({OLLAMA_BASE_URL})'}, ensure_ascii=False)}\n\n"
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"Ollama 超時: {e}")
|
||||
yield f"data: {json.dumps({'type': 'error', 'content': '請求超時'}, ensure_ascii=False)}\n\n"
|
||||
except Exception as e:
|
||||
logger.error(f"未知錯誤: {e}")
|
||||
yield f"data: {json.dumps({'type': 'error', 'content': f'未知錯誤: {str(e)}'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 4. 結束標記
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate_thinking_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # 禁用 Nginx 緩衝
|
||||
},
|
||||
)
|
||||
477
apps/api/src/routes/approvals.py
Normal file
477
apps/api/src/routes/approvals.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
Approval (HITL) Endpoints
|
||||
Phase 2.2: Dry-Run 預演 API
|
||||
Phase 2.3: Multi-Sig 多重簽核 API
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.services.dry_run import dry_run_engine
|
||||
from src.services.approval import (
|
||||
multi_sig_engine,
|
||||
RISK_MATRIX,
|
||||
InsufficientPermissionError,
|
||||
DuplicateSignatureError,
|
||||
TOCTOUConflictError,
|
||||
ApprovalNotFoundError,
|
||||
ApprovalAlreadyDecidedError,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PendingAction(BaseModel):
|
||||
plugin_id: str
|
||||
operation: str
|
||||
parameters: dict
|
||||
risk_level: Literal["low", "medium", "high", "critical"]
|
||||
dry_run_result: dict | None = None
|
||||
|
||||
|
||||
class Approval(BaseModel):
|
||||
id: UUID
|
||||
type: str
|
||||
status: Literal["pending", "approved", "rejected", "expired"]
|
||||
action: PendingAction
|
||||
requested_at: datetime
|
||||
expires_at: datetime
|
||||
decided_at: datetime | None = None
|
||||
decided_by: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ApprovalDecision(BaseModel):
|
||||
reason: str | None = None
|
||||
modified_parameters: dict | None = None
|
||||
|
||||
|
||||
class ApprovalList(BaseModel):
|
||||
items: list[Approval]
|
||||
next_page_token: str | None = None
|
||||
|
||||
|
||||
# ==================== Dry-Run Models ====================
|
||||
|
||||
|
||||
class DryRunCheckResponse(BaseModel):
|
||||
"""單項檢查結果"""
|
||||
name: str
|
||||
passed: bool
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class BlastRadiusResponse(BaseModel):
|
||||
"""爆炸半徑"""
|
||||
affected_pods: int
|
||||
estimated_downtime: str
|
||||
related_services: list[str]
|
||||
data_impact: Literal["NONE", "READ_ONLY", "WRITE", "DESTRUCTIVE"]
|
||||
|
||||
|
||||
class DryRunResponse(BaseModel):
|
||||
"""Dry-Run 完整結果 (對應前端 ApprovalCard)"""
|
||||
checks: list[DryRunCheckResponse]
|
||||
blast_radius: BlastRadiusResponse
|
||||
overall_passed: bool
|
||||
risk_level: Literal["low", "medium", "high", "critical"]
|
||||
|
||||
|
||||
# ==================== Multi-Sig Models (Phase 2.3) ====================
|
||||
|
||||
|
||||
class SignatureRequest(BaseModel):
|
||||
"""簽章請求"""
|
||||
user_id: str
|
||||
user_role: str # "admin", "devops", "cto", "ciso"
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class SignerInfo(BaseModel):
|
||||
"""簽章者資訊"""
|
||||
user_id: str
|
||||
role: str
|
||||
signed_at: datetime
|
||||
|
||||
|
||||
class SignatureStatusResponse(BaseModel):
|
||||
"""簽章狀態回應"""
|
||||
approval_id: str
|
||||
risk_level: str
|
||||
status: str
|
||||
current_signatures: int
|
||||
required_signatures: int
|
||||
has_required_role: bool
|
||||
required_roles: list[str]
|
||||
signers: list[SignerInfo]
|
||||
|
||||
|
||||
class MultiSigApproveResponse(BaseModel):
|
||||
"""Multi-Sig 簽核回應"""
|
||||
approval_id: str
|
||||
status: str
|
||||
message: str
|
||||
current_signatures: int
|
||||
required_signatures: int
|
||||
needs_more: bool
|
||||
signers: list[SignerInfo]
|
||||
|
||||
|
||||
class TOCTOUErrorResponse(BaseModel):
|
||||
"""TOCTOU 衝突回應"""
|
||||
error: str
|
||||
reason: str
|
||||
failed_checks: list[str]
|
||||
signatures_cleared: bool
|
||||
|
||||
|
||||
# In-memory storage
|
||||
_approvals: dict[UUID, Approval] = {}
|
||||
|
||||
|
||||
@router.get("", response_model=ApprovalList)
|
||||
async def list_approvals(
|
||||
status: Literal["pending", "approved", "rejected", "expired"] | None = None,
|
||||
) -> ApprovalList:
|
||||
"""列出待授權項目"""
|
||||
items = list(_approvals.values())
|
||||
if status:
|
||||
items = [a for a in items if a.status == status]
|
||||
return ApprovalList(items=items)
|
||||
|
||||
|
||||
@router.get("/{approval_id}", response_model=Approval)
|
||||
async def get_approval(approval_id: UUID) -> Approval:
|
||||
"""取得授權項目詳情"""
|
||||
if approval_id not in _approvals:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
return _approvals[approval_id]
|
||||
|
||||
|
||||
@router.post("/{approval_id}/approve", response_model=MultiSigApproveResponse)
|
||||
async def approve_approval(
|
||||
approval_id: UUID,
|
||||
request: SignatureRequest,
|
||||
) -> MultiSigApproveResponse:
|
||||
"""
|
||||
Multi-Sig 簽核 (Phase 2.3)
|
||||
|
||||
提交簽章到指定的審批項目。
|
||||
根據風險等級,可能需要多個簽章才能完成審批。
|
||||
|
||||
風險矩陣:
|
||||
- low: 自動執行
|
||||
- medium: 需要 1 位 admin/devops
|
||||
- high: 需要 2 位管理員
|
||||
- critical: 需要 2 人,含 CTO 或 CISO
|
||||
|
||||
⚠️ TOCTOU 防護:
|
||||
當簽章達到閾值時,會自動重新執行 Dry-Run。
|
||||
如果資源狀態已改變,將回傳 409 Conflict 並清空所有簽章。
|
||||
"""
|
||||
# 確保 Approval 存在於舊系統
|
||||
if approval_id not in _approvals:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
|
||||
approval = _approvals[approval_id]
|
||||
|
||||
# 同步到 Multi-Sig 引擎 (如果還沒有)
|
||||
try:
|
||||
multi_sig_engine.get_approval(approval_id)
|
||||
except ApprovalNotFoundError:
|
||||
multi_sig_engine.create_approval(
|
||||
approval_id=approval_id,
|
||||
operation=approval.action.operation,
|
||||
parameters=approval.action.parameters,
|
||||
risk_level=approval.action.risk_level,
|
||||
)
|
||||
|
||||
# 執行簽核
|
||||
try:
|
||||
state = multi_sig_engine.approve_request(
|
||||
approval_id=approval_id,
|
||||
user_id=request.user_id,
|
||||
user_role=request.user_role,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
# 同步狀態回舊系統
|
||||
if state.status.value == "approved":
|
||||
approval.status = "approved"
|
||||
approval.decided_at = state.executed_at
|
||||
|
||||
requirement = RISK_MATRIX[state.risk_level]
|
||||
|
||||
return MultiSigApproveResponse(
|
||||
approval_id=str(approval_id),
|
||||
status=state.status.value,
|
||||
message=(
|
||||
"Approval complete - executing action"
|
||||
if state.status.value == "approved"
|
||||
else f"Signature recorded ({len(state.signatures)}/{requirement.min_signatures})"
|
||||
),
|
||||
current_signatures=len(state.signatures),
|
||||
required_signatures=requirement.min_signatures,
|
||||
needs_more=len(state.signatures) < requirement.min_signatures,
|
||||
signers=[
|
||||
SignerInfo(
|
||||
user_id=sig.user_id,
|
||||
role=sig.user_role.value,
|
||||
signed_at=sig.signed_at,
|
||||
)
|
||||
for sig in state.signatures
|
||||
],
|
||||
)
|
||||
|
||||
except InsufficientPermissionError as e:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Insufficient permission",
|
||||
"role": e.role,
|
||||
"required_roles": e.required_roles,
|
||||
},
|
||||
)
|
||||
|
||||
except DuplicateSignatureError as e:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "Duplicate signature",
|
||||
"user_id": e.user_id,
|
||||
},
|
||||
)
|
||||
|
||||
except ApprovalAlreadyDecidedError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": str(e)},
|
||||
)
|
||||
|
||||
except TOCTOUConflictError as e:
|
||||
# ⚠️ TOCTOU 衝突 - 資源狀態已改變
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "TOCTOU Conflict",
|
||||
"reason": e.reason,
|
||||
"failed_checks": e.failed_checks,
|
||||
"signatures_cleared": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{approval_id}/reject", response_model=Approval)
|
||||
async def reject_approval(approval_id: UUID, decision: ApprovalDecision) -> Approval:
|
||||
"""拒絕授權"""
|
||||
if approval_id not in _approvals:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
|
||||
approval = _approvals[approval_id]
|
||||
approval.status = "rejected"
|
||||
approval.decided_at = datetime.utcnow()
|
||||
approval.reason = decision.reason
|
||||
|
||||
# 同步到 Multi-Sig 引擎
|
||||
try:
|
||||
multi_sig_engine.reject_request(
|
||||
approval_id=approval_id,
|
||||
user_id="system",
|
||||
user_role="admin",
|
||||
reason=decision.reason,
|
||||
)
|
||||
except (ApprovalNotFoundError, ApprovalAlreadyDecidedError):
|
||||
pass # 忽略,舊系統已處理
|
||||
|
||||
return approval
|
||||
|
||||
|
||||
@router.get("/{approval_id}/signatures", response_model=SignatureStatusResponse)
|
||||
async def get_signature_status(approval_id: UUID) -> SignatureStatusResponse:
|
||||
"""
|
||||
取得簽章狀態 (Phase 2.3)
|
||||
|
||||
回傳目前有多少簽章、還需要多少、已簽核者列表等資訊
|
||||
"""
|
||||
if approval_id not in _approvals:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
|
||||
approval = _approvals[approval_id]
|
||||
|
||||
# 確保同步到 Multi-Sig 引擎
|
||||
try:
|
||||
multi_sig_engine.get_approval(approval_id)
|
||||
except ApprovalNotFoundError:
|
||||
multi_sig_engine.create_approval(
|
||||
approval_id=approval_id,
|
||||
operation=approval.action.operation,
|
||||
parameters=approval.action.parameters,
|
||||
risk_level=approval.action.risk_level,
|
||||
)
|
||||
|
||||
status = multi_sig_engine.get_signature_status(approval_id)
|
||||
|
||||
return SignatureStatusResponse(
|
||||
approval_id=status["approval_id"],
|
||||
risk_level=status["risk_level"],
|
||||
status=status["status"],
|
||||
current_signatures=status["current_signatures"],
|
||||
required_signatures=status["required_signatures"],
|
||||
has_required_role=status["has_required_role"],
|
||||
required_roles=status["required_roles"],
|
||||
signers=[
|
||||
SignerInfo(
|
||||
user_id=s["user_id"],
|
||||
role=s["role"],
|
||||
signed_at=datetime.fromisoformat(s["signed_at"]),
|
||||
)
|
||||
for s in status["signers"]
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{approval_id}/dry-run", response_model=DryRunResponse)
|
||||
async def run_dry_run(approval_id: UUID) -> DryRunResponse:
|
||||
"""
|
||||
執行 Dry-Run 預演檢查
|
||||
|
||||
Phase 2.2: 回傳 ApprovalCard 所需的 dryRunChecks 格式
|
||||
- RBAC 權限檢查
|
||||
- 語法正確性
|
||||
- 資源存在性
|
||||
- 爆炸半徑評估
|
||||
"""
|
||||
if approval_id not in _approvals:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
|
||||
approval = _approvals[approval_id]
|
||||
action = approval.action
|
||||
|
||||
# 執行 Dry-Run 引擎
|
||||
result = dry_run_engine.evaluate(
|
||||
operation=action.operation,
|
||||
parameters=action.parameters,
|
||||
user_role="cluster-admin", # TODO: 從 JWT 取得真實角色
|
||||
)
|
||||
|
||||
# 轉換為 API Response 格式
|
||||
return DryRunResponse(
|
||||
checks=[
|
||||
DryRunCheckResponse(
|
||||
name=c.name,
|
||||
passed=c.passed,
|
||||
message=c.message,
|
||||
)
|
||||
for c in result.checks
|
||||
],
|
||||
blast_radius=BlastRadiusResponse(
|
||||
affected_pods=result.blast_radius.affected_pods,
|
||||
estimated_downtime=result.blast_radius.estimated_downtime,
|
||||
related_services=result.blast_radius.related_services,
|
||||
data_impact=result.blast_radius.data_impact,
|
||||
),
|
||||
overall_passed=result.overall_passed,
|
||||
risk_level=result.risk_level,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/dry-run/preview", response_model=DryRunResponse)
|
||||
async def preview_dry_run(
|
||||
operation: str,
|
||||
parameters: dict,
|
||||
user_role: str = "cluster-admin",
|
||||
) -> DryRunResponse:
|
||||
"""
|
||||
預覽 Dry-Run (不需要先建立 Approval)
|
||||
|
||||
用於前端即時預覽操作風險
|
||||
"""
|
||||
result = dry_run_engine.evaluate(
|
||||
operation=operation,
|
||||
parameters=parameters,
|
||||
user_role=user_role,
|
||||
)
|
||||
|
||||
return DryRunResponse(
|
||||
checks=[
|
||||
DryRunCheckResponse(
|
||||
name=c.name,
|
||||
passed=c.passed,
|
||||
message=c.message,
|
||||
)
|
||||
for c in result.checks
|
||||
],
|
||||
blast_radius=BlastRadiusResponse(
|
||||
affected_pods=result.blast_radius.affected_pods,
|
||||
estimated_downtime=result.blast_radius.estimated_downtime,
|
||||
related_services=result.blast_radius.related_services,
|
||||
data_impact=result.blast_radius.data_impact,
|
||||
),
|
||||
overall_passed=result.overall_passed,
|
||||
risk_level=result.risk_level,
|
||||
)
|
||||
|
||||
|
||||
# ==================== Test Helpers ====================
|
||||
|
||||
|
||||
def create_test_approval(
|
||||
operation: str = "delete_pod",
|
||||
parameters: dict | None = None,
|
||||
risk_level: Literal["low", "medium", "high", "critical"] = "high",
|
||||
) -> Approval:
|
||||
"""Create a test approval for development"""
|
||||
approval_id = uuid4()
|
||||
now = datetime.utcnow()
|
||||
|
||||
if parameters is None:
|
||||
if operation == "delete_pod":
|
||||
parameters = {"pod_name": "nginx-frontend-7d4b8c9f5-xk2m3"}
|
||||
elif operation == "drop_table":
|
||||
parameters = {"table_name": "user_sessions"}
|
||||
else:
|
||||
parameters = {}
|
||||
|
||||
approval = Approval(
|
||||
id=approval_id,
|
||||
type="action_execution",
|
||||
status="pending",
|
||||
action=PendingAction(
|
||||
plugin_id="lewooogo-action-k8s",
|
||||
operation=operation,
|
||||
parameters=parameters,
|
||||
risk_level=risk_level,
|
||||
),
|
||||
requested_at=now,
|
||||
expires_at=now + timedelta(hours=1),
|
||||
)
|
||||
_approvals[approval_id] = approval
|
||||
return approval
|
||||
|
||||
|
||||
def create_test_approvals() -> list[Approval]:
|
||||
"""建立多個測試 Approval (對應前端 Mock Data)"""
|
||||
return [
|
||||
# HIGH RISK: 刪除 Pod
|
||||
create_test_approval(
|
||||
operation="delete_pod",
|
||||
parameters={"pod_name": "nginx-frontend-7d4b8c9f5-xk2m3"},
|
||||
risk_level="high",
|
||||
),
|
||||
# CRITICAL: DROP TABLE (DESTRUCTIVE)
|
||||
create_test_approval(
|
||||
operation="drop_table",
|
||||
parameters={"table_name": "user_sessions"},
|
||||
risk_level="critical",
|
||||
),
|
||||
# MEDIUM: Scale Deployment
|
||||
create_test_approval(
|
||||
operation="scale_deployment",
|
||||
parameters={"deployment": "api-server", "replicas": 5},
|
||||
risk_level="medium",
|
||||
),
|
||||
]
|
||||
107
apps/api/src/routes/health.py
Normal file
107
apps/api/src/routes/health.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Health Check Endpoints
|
||||
======================
|
||||
K8s probes + component health checks
|
||||
|
||||
Endpoints:
|
||||
- GET /health - Full health check with components
|
||||
- GET /health/ready - K8s readinessProbe
|
||||
- GET /health/live - K8s livenessProbe
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.logging import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger("awoooi.health")
|
||||
|
||||
|
||||
class ComponentStatus(BaseModel):
|
||||
"""Individual component status"""
|
||||
name: str
|
||||
status: Literal["up", "down", "degraded"]
|
||||
latency_ms: float | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Full health check response"""
|
||||
status: Literal["healthy", "degraded", "unhealthy"]
|
||||
version: str
|
||||
environment: str
|
||||
timestamp: datetime
|
||||
components: dict[str, Literal["up", "down", "degraded"]]
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def get_health() -> HealthResponse:
|
||||
"""
|
||||
Full health check with component status
|
||||
|
||||
Returns overall system health and individual component statuses.
|
||||
Used for monitoring dashboards and alerting.
|
||||
"""
|
||||
# TODO: Implement actual async health checks
|
||||
components = {
|
||||
"api": "up",
|
||||
"database": "up", # TODO: asyncpg ping
|
||||
"redis": "up", # TODO: redis ping
|
||||
"ollama": "up", # TODO: httpx check
|
||||
"clawbot": "up", # TODO: httpx check
|
||||
}
|
||||
|
||||
# Determine overall status
|
||||
down_count = sum(1 for s in components.values() if s == "down")
|
||||
degraded_count = sum(1 for s in components.values() if s == "degraded")
|
||||
|
||||
if down_count > 0:
|
||||
overall_status: Literal["healthy", "degraded", "unhealthy"] = "unhealthy"
|
||||
elif degraded_count > 0:
|
||||
overall_status = "degraded"
|
||||
else:
|
||||
overall_status = "healthy"
|
||||
|
||||
logger.debug(
|
||||
"health_check",
|
||||
status=overall_status,
|
||||
components=components,
|
||||
)
|
||||
|
||||
return HealthResponse(
|
||||
status=overall_status,
|
||||
version=settings.VERSION,
|
||||
environment=settings.ENVIRONMENT,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
components=components,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def get_readiness() -> dict[str, str]:
|
||||
"""
|
||||
K8s readinessProbe
|
||||
|
||||
Returns 200 when the service is ready to accept traffic.
|
||||
Used by K8s to determine if pod should receive traffic.
|
||||
"""
|
||||
# TODO: Check if all required connections are established
|
||||
logger.debug("readiness_check", ready=True)
|
||||
return {"status": "ready"}
|
||||
|
||||
|
||||
@router.get("/health/live")
|
||||
async def get_liveness() -> dict[str, str]:
|
||||
"""
|
||||
K8s livenessProbe
|
||||
|
||||
Returns 200 when the service is alive.
|
||||
Used by K8s to determine if pod needs restart.
|
||||
"""
|
||||
logger.debug("liveness_check", alive=True)
|
||||
return {"status": "alive"}
|
||||
73
apps/api/src/routes/notifications.py
Normal file
73
apps/api/src/routes/notifications.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Notification Endpoints
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NotificationChannel(BaseModel):
|
||||
id: str
|
||||
type: Literal["telegram", "slack", "line", "email", "discord", "webhook"]
|
||||
name: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
class NotificationRequest(BaseModel):
|
||||
channel_id: str
|
||||
message: str
|
||||
template_id: str | None = None
|
||||
variables: dict | None = None
|
||||
priority: Literal["low", "normal", "high", "urgent"] = "normal"
|
||||
|
||||
|
||||
class NotificationResult(BaseModel):
|
||||
id: UUID
|
||||
status: Literal["queued", "sent", "failed"]
|
||||
sent_at: datetime | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# Mock channels
|
||||
MOCK_CHANNELS: list[NotificationChannel] = [
|
||||
NotificationChannel(
|
||||
id="telegram-ops",
|
||||
type="telegram",
|
||||
name="Ops Team",
|
||||
enabled=True,
|
||||
),
|
||||
NotificationChannel(
|
||||
id="slack-alerts",
|
||||
type="slack",
|
||||
name="Alerts Channel",
|
||||
enabled=True,
|
||||
),
|
||||
NotificationChannel(
|
||||
id="email-oncall",
|
||||
type="email",
|
||||
name="On-Call Email",
|
||||
enabled=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/channels", response_model=list[NotificationChannel])
|
||||
async def list_notification_channels() -> list[NotificationChannel]:
|
||||
"""列出通知頻道"""
|
||||
return MOCK_CHANNELS
|
||||
|
||||
|
||||
@router.post("/send", response_model=NotificationResult, status_code=202)
|
||||
async def send_notification(request: NotificationRequest) -> NotificationResult:
|
||||
"""發送通知"""
|
||||
# TODO: 實際發送通知
|
||||
return NotificationResult(
|
||||
id=uuid4(),
|
||||
status="queued",
|
||||
)
|
||||
110
apps/api/src/routes/pipelines.py
Normal file
110
apps/api/src/routes/pipelines.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Pipeline Endpoints
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PipelineStep(BaseModel):
|
||||
id: str
|
||||
plugin_id: str
|
||||
type: Literal["INPUT", "BRAIN", "OUTPUT", "ACTION", "DATA", "UI"]
|
||||
config: dict | None = None
|
||||
|
||||
|
||||
class Pipeline(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
status: Literal["draft", "active", "paused", "archived"]
|
||||
steps: list[PipelineStep]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PipelineCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
steps: list[PipelineStep]
|
||||
|
||||
|
||||
class PipelineExecution(BaseModel):
|
||||
id: UUID
|
||||
pipeline_id: UUID
|
||||
status: Literal["pending", "running", "completed", "failed", "cancelled"]
|
||||
started_at: datetime
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class PipelineList(BaseModel):
|
||||
items: list[Pipeline]
|
||||
next_page_token: str | None = None
|
||||
|
||||
|
||||
# In-memory storage
|
||||
_pipelines: dict[UUID, Pipeline] = {}
|
||||
|
||||
|
||||
@router.get("", response_model=PipelineList)
|
||||
async def list_pipelines(
|
||||
status: Literal["draft", "active", "paused", "archived"] | None = None,
|
||||
) -> PipelineList:
|
||||
"""列出工作流"""
|
||||
items = list(_pipelines.values())
|
||||
if status:
|
||||
items = [p for p in items if p.status == status]
|
||||
return PipelineList(items=items)
|
||||
|
||||
|
||||
@router.post("", response_model=Pipeline, status_code=201)
|
||||
async def create_pipeline(data: PipelineCreate) -> Pipeline:
|
||||
"""建立工作流"""
|
||||
now = datetime.utcnow()
|
||||
pipeline = Pipeline(
|
||||
id=uuid4(),
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
status="draft",
|
||||
steps=data.steps,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
_pipelines[pipeline.id] = pipeline
|
||||
return pipeline
|
||||
|
||||
|
||||
@router.get("/{pipeline_id}", response_model=Pipeline)
|
||||
async def get_pipeline(pipeline_id: UUID) -> Pipeline:
|
||||
"""取得工作流詳情"""
|
||||
if pipeline_id not in _pipelines:
|
||||
raise HTTPException(status_code=404, detail="Pipeline not found")
|
||||
return _pipelines[pipeline_id]
|
||||
|
||||
|
||||
@router.delete("/{pipeline_id}", status_code=204)
|
||||
async def delete_pipeline(pipeline_id: UUID) -> None:
|
||||
"""刪除工作流"""
|
||||
if pipeline_id not in _pipelines:
|
||||
raise HTTPException(status_code=404, detail="Pipeline not found")
|
||||
del _pipelines[pipeline_id]
|
||||
|
||||
|
||||
@router.post("/{pipeline_id}/trigger", response_model=PipelineExecution, status_code=202)
|
||||
async def trigger_pipeline(pipeline_id: UUID) -> PipelineExecution:
|
||||
"""手動觸發工作流"""
|
||||
if pipeline_id not in _pipelines:
|
||||
raise HTTPException(status_code=404, detail="Pipeline not found")
|
||||
|
||||
return PipelineExecution(
|
||||
id=uuid4(),
|
||||
pipeline_id=pipeline_id,
|
||||
status="pending",
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
98
apps/api/src/routes/plugins.py
Normal file
98
apps/api/src/routes/plugins.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Plugin Management Endpoints
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PluginCategory = Literal["INPUT", "BRAIN", "OUTPUT", "ACTION", "DATA", "UI"]
|
||||
|
||||
|
||||
class Plugin(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
category: PluginCategory
|
||||
enabled: bool
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PluginHealth(BaseModel):
|
||||
plugin_id: str
|
||||
status: Literal["healthy", "unhealthy", "unknown"]
|
||||
last_check: datetime
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# Mock data
|
||||
MOCK_PLUGINS: list[Plugin] = [
|
||||
Plugin(
|
||||
id="lewooogo-input-webhook",
|
||||
name="Webhook Trigger",
|
||||
version="0.1.0",
|
||||
category="INPUT",
|
||||
enabled=True,
|
||||
description="HTTP Webhook 觸發器",
|
||||
),
|
||||
Plugin(
|
||||
id="lewooogo-brain-llm-router",
|
||||
name="LLM Router",
|
||||
version="0.1.0",
|
||||
category="BRAIN",
|
||||
enabled=True,
|
||||
description="多模型路由器",
|
||||
),
|
||||
Plugin(
|
||||
id="lewooogo-output-telegram",
|
||||
name="Telegram Notifier",
|
||||
version="0.1.0",
|
||||
category="OUTPUT",
|
||||
enabled=True,
|
||||
description="Telegram 通知",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=list[Plugin])
|
||||
async def list_plugins(
|
||||
category: PluginCategory | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> list[Plugin]:
|
||||
"""列出所有已註冊 Plugin"""
|
||||
result = MOCK_PLUGINS
|
||||
|
||||
if category:
|
||||
result = [p for p in result if p.category == category]
|
||||
if enabled is not None:
|
||||
result = [p for p in result if p.enabled == enabled]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", response_model=Plugin)
|
||||
async def get_plugin(plugin_id: str) -> Plugin:
|
||||
"""取得 Plugin 詳情"""
|
||||
for plugin in MOCK_PLUGINS:
|
||||
if plugin.id == plugin_id:
|
||||
return plugin
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
|
||||
|
||||
@router.get("/{plugin_id}/health", response_model=PluginHealth)
|
||||
async def get_plugin_health(plugin_id: str) -> PluginHealth:
|
||||
"""Plugin 健康檢查"""
|
||||
# Check if plugin exists
|
||||
found = any(p.id == plugin_id for p in MOCK_PLUGINS)
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
|
||||
return PluginHealth(
|
||||
plugin_id=plugin_id,
|
||||
status="healthy",
|
||||
last_check=datetime.utcnow(),
|
||||
)
|
||||
Reference in New Issue
Block a user