Phase 6.4 - Modular Architecture: - Add lewooogo-brain adapters for LLM providers - Add lewooogo-data dual memory (Redis + PostgreSQL) - Implement consensus engine for multi-agent decisions - Add incident memory service for historical context Phase 9 - Agent Teams (Claude Agent SDK): - Add base agent class with Claude Sonnet 4 integration - Implement action planner, blast radius, and security agents - Add agent API endpoints and proposal workflow - Integrate ADR-009 OpenClaw Agent Teams architecture DevOps & CI/CD: - Add GitHub Actions CI/CD workflows (ci.yaml, cd.yaml) - Add pre-commit hooks and secrets baseline - Add docker-compose for local development - Update Kubernetes network policies Frontend Improvements: - Add auto-healing error boundary component - Update i18n messages for agent features - Enhance dual-state incident card with execution feedback Documentation: - Add 7 ADRs covering MCP, design system, architecture decisions - Update ARCHITECTURE_MEMORY.md with modular design - Add GLOBAL_RULES.md and SOUL.md for project identity Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
443 lines
14 KiB
Python
443 lines
14 KiB
Python
"""
|
||
Multi-Sig Redis Service - 簽核狀態持久化
|
||
=========================================
|
||
Phase 6.1.1: Multi-Sig Redis 遷移
|
||
|
||
Features:
|
||
- 簽核狀態 Redis Hash 持久化
|
||
- 7 天 TTL 稽核保留 (資安合規)
|
||
- 分散式鎖防止 Race Condition
|
||
- 與現有 SQLite 雙寫模式 (Phase 6.2 後可移除 SQLite)
|
||
|
||
統帥鐵律:
|
||
- 所有簽核狀態變更必須經過此模組
|
||
- 7 天 TTL 不可修改 (資安稽核要求)
|
||
- 分散式鎖必須包裹所有寫入操作
|
||
"""
|
||
|
||
import json
|
||
from datetime import datetime, timezone
|
||
from uuid import UUID
|
||
|
||
import structlog
|
||
|
||
from src.core.redis_client import get_redis, RedisLock
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
# =============================================================================
|
||
# Constants
|
||
# =============================================================================
|
||
|
||
# Redis Key 前綴
|
||
APPROVAL_KEY_PREFIX = "approval:"
|
||
SIGNATURE_KEY_PREFIX = "signature:"
|
||
|
||
# 7 天 TTL (資安稽核要求)
|
||
APPROVAL_TTL_SECONDS = 86400 * 7 # 604800 秒
|
||
|
||
|
||
# =============================================================================
|
||
# Approval State Model
|
||
# =============================================================================
|
||
|
||
class ApprovalStateRedis:
|
||
"""
|
||
Redis 中的簽核狀態結構
|
||
|
||
Hash Fields:
|
||
- id: 簽核單 ID
|
||
- action: 操作類型 (DELETE_POD, RESTART_SERVICE, etc.)
|
||
- description: 描述
|
||
- status: 狀態 (pending, approved, rejected, voided, executed)
|
||
- risk_level: 風險等級 (critical, high, medium, low)
|
||
- required_signatures: 需要簽核數
|
||
- current_signatures: 目前簽核數
|
||
- signatures: 簽核列表 (JSON Array)
|
||
- created_at: 建立時間
|
||
- updated_at: 更新時間
|
||
- namespace: K8s Namespace
|
||
- resource_name: 資源名稱
|
||
"""
|
||
|
||
@staticmethod
|
||
def get_key(approval_id: str | UUID) -> str:
|
||
"""取得 Redis Key"""
|
||
return f"{APPROVAL_KEY_PREFIX}{str(approval_id)}"
|
||
|
||
|
||
# =============================================================================
|
||
# Multi-Sig Redis Service
|
||
# =============================================================================
|
||
|
||
class MultiSigRedisService:
|
||
"""
|
||
Multi-Sig Redis 持久化服務
|
||
|
||
提供簽核狀態的 CRUD 操作,包含:
|
||
- 建立簽核單
|
||
- 新增簽名
|
||
- 更新狀態
|
||
- 查詢狀態
|
||
- 分散式鎖保護
|
||
"""
|
||
|
||
async def create_approval(
|
||
self,
|
||
approval_id: str | UUID,
|
||
action: str,
|
||
description: str,
|
||
risk_level: str,
|
||
required_signatures: int,
|
||
namespace: str = "default",
|
||
resource_name: str = "",
|
||
blast_radius: dict | None = None,
|
||
dry_run_checks: list | None = None,
|
||
) -> dict:
|
||
"""
|
||
建立新的簽核單
|
||
|
||
Args:
|
||
approval_id: 簽核單 ID
|
||
action: 操作類型
|
||
description: 描述
|
||
risk_level: 風險等級
|
||
required_signatures: 需要簽核數
|
||
namespace: K8s Namespace
|
||
resource_name: 資源名稱
|
||
blast_radius: 爆炸半徑
|
||
dry_run_checks: Dry-Run 檢查結果
|
||
|
||
Returns:
|
||
dict: 建立的簽核狀態
|
||
"""
|
||
redis_client = get_redis()
|
||
key = ApprovalStateRedis.get_key(approval_id)
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
|
||
state = {
|
||
"id": str(approval_id),
|
||
"action": action,
|
||
"description": description,
|
||
"status": "pending",
|
||
"risk_level": risk_level,
|
||
"required_signatures": required_signatures,
|
||
"current_signatures": 0,
|
||
"signatures": json.dumps([]), # JSON Array
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
"namespace": namespace,
|
||
"resource_name": resource_name,
|
||
"blast_radius": json.dumps(blast_radius or {}),
|
||
"dry_run_checks": json.dumps(dry_run_checks or []),
|
||
}
|
||
|
||
# 使用 HSET 寫入 Hash
|
||
await redis_client.hset(key, mapping=state)
|
||
|
||
# 設定 7 天 TTL (資安稽核要求)
|
||
await redis_client.expire(key, APPROVAL_TTL_SECONDS)
|
||
|
||
logger.info(
|
||
"redis_approval_created",
|
||
approval_id=str(approval_id),
|
||
risk_level=risk_level,
|
||
ttl_days=7,
|
||
)
|
||
|
||
return state
|
||
|
||
async def get_approval(self, approval_id: str | UUID) -> dict | None:
|
||
"""
|
||
取得簽核狀態
|
||
|
||
Args:
|
||
approval_id: 簽核單 ID
|
||
|
||
Returns:
|
||
dict | None: 簽核狀態,若不存在則返回 None
|
||
"""
|
||
redis_client = get_redis()
|
||
key = ApprovalStateRedis.get_key(approval_id)
|
||
|
||
state = await redis_client.hgetall(key)
|
||
|
||
if not state:
|
||
return None
|
||
|
||
# 解析 JSON 欄位
|
||
if "signatures" in state:
|
||
state["signatures"] = json.loads(state["signatures"])
|
||
if "blast_radius" in state:
|
||
state["blast_radius"] = json.loads(state["blast_radius"])
|
||
if "dry_run_checks" in state:
|
||
state["dry_run_checks"] = json.loads(state["dry_run_checks"])
|
||
|
||
# 轉換數值欄位
|
||
if "required_signatures" in state:
|
||
state["required_signatures"] = int(state["required_signatures"])
|
||
if "current_signatures" in state:
|
||
state["current_signatures"] = int(state["current_signatures"])
|
||
|
||
return state
|
||
|
||
async def add_signature(
|
||
self,
|
||
approval_id: str | UUID,
|
||
signer_id: str,
|
||
signer_name: str,
|
||
comment: str = "",
|
||
source: str = "web",
|
||
telegram_user_id: int | None = None,
|
||
telegram_message_id: int | None = None,
|
||
) -> dict:
|
||
"""
|
||
新增簽名 (含分散式鎖保護)
|
||
|
||
防禦場景:
|
||
- Web + Telegram 同時簽核
|
||
- 防止 K8s Executor 被觸發兩次
|
||
|
||
Args:
|
||
approval_id: 簽核單 ID
|
||
signer_id: 簽核者 ID
|
||
signer_name: 簽核者名稱
|
||
comment: 備註
|
||
source: 來源 (web, telegram, api)
|
||
telegram_user_id: Telegram User ID
|
||
telegram_message_id: Telegram Message ID
|
||
|
||
Returns:
|
||
dict: 更新後的簽核狀態
|
||
|
||
Raises:
|
||
RuntimeError: 若無法取得鎖或簽核單不存在
|
||
"""
|
||
redis_client = get_redis()
|
||
key = ApprovalStateRedis.get_key(approval_id)
|
||
lock_key = f"{str(approval_id)}:sign"
|
||
|
||
# 使用分散式鎖保護簽核操作
|
||
async with RedisLock(lock_key, timeout=10, blocking_timeout=5):
|
||
# 取得目前狀態
|
||
state = await self.get_approval(approval_id)
|
||
if not state:
|
||
raise RuntimeError(f"Approval not found: {approval_id}")
|
||
|
||
# 檢查狀態是否可簽核
|
||
if state["status"] != "pending":
|
||
raise RuntimeError(f"Approval is not pending: {state['status']}")
|
||
|
||
# 檢查是否已簽過
|
||
signatures = state.get("signatures", [])
|
||
for sig in signatures:
|
||
if sig.get("signer_id") == signer_id:
|
||
raise RuntimeError(f"Already signed by: {signer_id}")
|
||
|
||
# 新增簽名
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
new_signature = {
|
||
"signer_id": signer_id,
|
||
"signer_name": signer_name,
|
||
"timestamp": now,
|
||
"comment": comment,
|
||
"source": source,
|
||
}
|
||
|
||
if telegram_user_id:
|
||
new_signature["telegram_user_id"] = telegram_user_id
|
||
if telegram_message_id:
|
||
new_signature["telegram_message_id"] = telegram_message_id
|
||
|
||
signatures.append(new_signature)
|
||
current_signatures = len(signatures)
|
||
|
||
# 檢查是否達到簽核門檻
|
||
new_status = "pending"
|
||
if current_signatures >= state["required_signatures"]:
|
||
new_status = "approved"
|
||
|
||
# 更新 Redis
|
||
await redis_client.hset(key, mapping={
|
||
"signatures": json.dumps(signatures),
|
||
"current_signatures": current_signatures,
|
||
"status": new_status,
|
||
"updated_at": now,
|
||
})
|
||
|
||
# 延長 TTL (每次操作都重設 7 天)
|
||
await redis_client.expire(key, APPROVAL_TTL_SECONDS)
|
||
|
||
logger.info(
|
||
"redis_signature_added",
|
||
approval_id=str(approval_id),
|
||
signer_id=signer_id,
|
||
source=source,
|
||
current=current_signatures,
|
||
required=state["required_signatures"],
|
||
new_status=new_status,
|
||
)
|
||
|
||
return await self.get_approval(approval_id)
|
||
|
||
async def update_status(
|
||
self,
|
||
approval_id: str | UUID,
|
||
status: str,
|
||
executor_id: str | None = None,
|
||
execution_result: dict | None = None,
|
||
) -> dict:
|
||
"""
|
||
更新簽核狀態
|
||
|
||
Args:
|
||
approval_id: 簽核單 ID
|
||
status: 新狀態 (approved, rejected, voided, executed)
|
||
executor_id: 執行者 ID
|
||
execution_result: 執行結果
|
||
|
||
Returns:
|
||
dict: 更新後的簽核狀態
|
||
"""
|
||
redis_client = get_redis()
|
||
key = ApprovalStateRedis.get_key(approval_id)
|
||
lock_key = f"{str(approval_id)}:status"
|
||
|
||
async with RedisLock(lock_key, timeout=10, blocking_timeout=5):
|
||
state = await self.get_approval(approval_id)
|
||
if not state:
|
||
raise RuntimeError(f"Approval not found: {approval_id}")
|
||
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
|
||
updates = {
|
||
"status": status,
|
||
"updated_at": now,
|
||
}
|
||
|
||
if executor_id:
|
||
updates["executor_id"] = executor_id
|
||
if execution_result:
|
||
updates["execution_result"] = json.dumps(execution_result)
|
||
|
||
await redis_client.hset(key, mapping=updates)
|
||
await redis_client.expire(key, APPROVAL_TTL_SECONDS)
|
||
|
||
logger.info(
|
||
"redis_status_updated",
|
||
approval_id=str(approval_id),
|
||
status=status,
|
||
)
|
||
|
||
return await self.get_approval(approval_id)
|
||
|
||
async def reject_approval(
|
||
self,
|
||
approval_id: str | UUID,
|
||
rejector_id: str,
|
||
rejector_name: str,
|
||
reason: str = "",
|
||
) -> dict:
|
||
"""
|
||
拒絕簽核單
|
||
|
||
Args:
|
||
approval_id: 簽核單 ID
|
||
rejector_id: 拒絕者 ID
|
||
rejector_name: 拒絕者名稱
|
||
reason: 拒絕原因
|
||
|
||
Returns:
|
||
dict: 更新後的簽核狀態
|
||
"""
|
||
redis_client = get_redis()
|
||
key = ApprovalStateRedis.get_key(approval_id)
|
||
lock_key = f"{str(approval_id)}:reject"
|
||
|
||
async with RedisLock(lock_key, timeout=10, blocking_timeout=5):
|
||
state = await self.get_approval(approval_id)
|
||
if not state:
|
||
raise RuntimeError(f"Approval not found: {approval_id}")
|
||
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
|
||
await redis_client.hset(key, mapping={
|
||
"status": "rejected",
|
||
"updated_at": now,
|
||
"rejector_id": rejector_id,
|
||
"rejector_name": rejector_name,
|
||
"rejection_reason": reason,
|
||
})
|
||
await redis_client.expire(key, APPROVAL_TTL_SECONDS)
|
||
|
||
logger.info(
|
||
"redis_approval_rejected",
|
||
approval_id=str(approval_id),
|
||
rejector_id=rejector_id,
|
||
)
|
||
|
||
return await self.get_approval(approval_id)
|
||
|
||
async def list_pending(self, limit: int = 100) -> list[dict]:
|
||
"""
|
||
列出所有待簽核單
|
||
|
||
注意: 此方法使用 SCAN,在大量資料時效能較低
|
||
建議在 Phase 6.2 加入索引機制
|
||
|
||
Args:
|
||
limit: 最大返回數量
|
||
|
||
Returns:
|
||
list[dict]: 待簽核單列表
|
||
"""
|
||
redis_client = get_redis()
|
||
results = []
|
||
|
||
async for key in redis_client.scan_iter(match=f"{APPROVAL_KEY_PREFIX}*", count=100):
|
||
if len(results) >= limit:
|
||
break
|
||
|
||
state = await redis_client.hgetall(key)
|
||
if state and state.get("status") == "pending":
|
||
# 解析 JSON 欄位
|
||
if "signatures" in state:
|
||
state["signatures"] = json.loads(state["signatures"])
|
||
if "required_signatures" in state:
|
||
state["required_signatures"] = int(state["required_signatures"])
|
||
if "current_signatures" in state:
|
||
state["current_signatures"] = int(state["current_signatures"])
|
||
results.append(state)
|
||
|
||
return results
|
||
|
||
async def exists(self, approval_id: str | UUID) -> bool:
|
||
"""
|
||
檢查簽核單是否存在
|
||
|
||
Args:
|
||
approval_id: 簽核單 ID
|
||
|
||
Returns:
|
||
bool: 是否存在
|
||
"""
|
||
redis_client = get_redis()
|
||
key = ApprovalStateRedis.get_key(approval_id)
|
||
return await redis_client.exists(key) > 0
|
||
|
||
|
||
# =============================================================================
|
||
# Singleton
|
||
# =============================================================================
|
||
|
||
_service: MultiSigRedisService | None = None
|
||
|
||
|
||
def get_multi_sig_redis_service() -> MultiSigRedisService:
|
||
"""取得全域 MultiSigRedisService 實例"""
|
||
global _service
|
||
if _service is None:
|
||
_service = MultiSigRedisService()
|
||
return _service
|