Files
awoooi/apps/api/src/services/signal_producer.py
ogt 6cf8429d17
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m32s
CD Pipeline / build-and-deploy (push) Successful in 8m2s
CD Pipeline / post-deploy-checks (push) Successful in 2m22s
fix(automation): enforce durable alert closure
Restore D037 durable incident readback without weakening database failure semantics.

Fence Agent99 dispatch and terminal learning to canonical identities, durable leases, verifier receipts, and reconciler-owned checkpoints.

Drain backup and restore legacy receipts in bounded cohorts with strict transport, claim, projection, and no-false-green controls.

Keep SSH service refusal visible while separating it from broker network-policy reachability.
2026-07-14 09:40:53 +08:00

123 lines
3.3 KiB
Python

"""
Signal Producer Service - Phase 17 P0 Router 層違規修復
======================================================
封裝 Redis Stream 的 Signal 生產邏輯,消除 Router 層直接存取 Redis。
功能:
- XADD 寫入 Redis Stream
- Trace Context 傳遞
符合 leWOOOgo 積木化規範:
- Router -> Service -> Redis
"""
import json
from dataclasses import dataclass
from typing import Any
import structlog
from src.core.redis_client import get_redis
from src.core.telemetry import get_trace_context
from src.models.incident import SignalSource
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
# Redis Stream 配置
SIGNAL_STREAM_KEY = "awoooi:signals"
SIGNAL_STREAM_MAXLEN = 10000
@dataclass
class SignalData:
"""Signal 資料結構"""
source: SignalSource
alert_name: str
severity: str
namespace: str
target: str
message: str
labels: dict[str, Any] | None = None
annotations: dict[str, Any] | None = None
class SignalProducerService:
"""
Signal 生產者服務
封裝 Redis Stream 的 XADD 操作
"""
async def produce(self, signal: SignalData) -> str:
"""
將 Signal 寫入 Redis Stream
Phase 17: 從 Router 層遷移至 Service 層
使用 Redis Streams (XADD) 實現非同步事件總線:
- MAXLEN ~10000: 限制 Stream 長度,自動裁剪舊訊息
- *: 自動生成 Message ID
Args:
signal: Signal 資料
Returns:
str: Redis Stream Message ID
"""
redis_client = get_redis()
# 組裝 Signal 字典 (所有值必須是字串)
signal_dict = {
"source": signal.source,
"alert_name": signal.alert_name,
"severity": signal.severity,
"namespace": signal.namespace,
"target": signal.target,
"message": signal.message,
"labels": json.dumps(signal.labels or {}),
"annotations": json.dumps(signal.annotations or {}),
"received_at": now_taipei().isoformat(),
}
# Phase 15.2: 注入 Trace Context
trace_ctx = get_trace_context()
if trace_ctx:
signal_dict["_trace_id"] = trace_ctx.get("trace_id", "")
signal_dict["_span_id"] = trace_ctx.get("span_id", "")
# XADD 寫入 Stream
message_id = await redis_client.xadd(
SIGNAL_STREAM_KEY,
signal_dict,
maxlen=SIGNAL_STREAM_MAXLEN,
approximate=True, # ~MAXLEN 近似裁剪,效能更好
)
logger.info(
"signal_produced",
message_id=message_id,
source=signal.source,
alert_name=signal.alert_name,
severity=signal.severity,
trace_id=trace_ctx.get("trace_id") if trace_ctx else None,
)
return message_id
# =============================================================================
# Singleton
# =============================================================================
_signal_producer: SignalProducerService | None = None
def get_signal_producer() -> SignalProducerService:
"""取得 SignalProducerService 實例 (Singleton)"""
global _signal_producer
if _signal_producer is None:
_signal_producer = SignalProducerService()
return _signal_producer