Files
pangolin/server/internal/alert/notifier.go
T
wangjia 5d4b484646 feat(alert): 统一告警出口 TG bot + runbook [tsk_9YMHMTfWJyNB]
新增 server/internal/alert 包(15G):
- 定义 Notifier 接口及 7 种 EventType(判封确认/补新失败/水位低/熔断/
  探针失联/心跳缺失/故障态)
- TGNotifier:Bot API 发送,Critical 事件不去重,Warning/Info 事件
  10min SETNX 去重窗口,失败重试 ≤2 次后降级至 LogNotifier
- LogNotifier:slog 结构化降级实现
- 单测:7 种事件模板 + runbook 锚点正确性;去重窗口内第二条被抑制;
  TG 5xx 重试后 fallback 且 Notify() 返回 nil;
  runbook 文件锚点与枚举一致性

接入 scheduler(替换旧的 NotifyFault 桩):
- detect/engine.go:故障态(Rule 5)→ EventTypeFault;
  判封确认(Rule 3)→ EventTypeBlockConfirmed
- orchestrate/deps.go:Notifier 类型别名指向 alert.Notifier
- orchestrate/replacer.go:补新失败 → EventTypeReplenishFailed;
  熔断触发 → EventTypeBreakerTripped
- probe/prober_agent.go:failCount ≥3 → EventTypeProbeAgentLost
- probe/store.go:新增 CheckHeartbeats() 供 15H 检测心跳缺失>90s

新增 docs/runbook-scheduler.md:7 节各含含义/先查什么/处置/升级条件,
锚点与代码枚举对应。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:52:26 +08:00

431 lines
16 KiB
Go

// Package alert implements the unified alert exit channel for Pangolin's
// scheduler subsystem (task 15G).
//
// # Event types
//
// Seven fixed event types cover all failure modes detected by 15D (detect),
// 15E (orchestrate), and 15F (probe):
//
// EventTypeBlockConfirmed — node confirmed GFW-censored after ≥6 cycles
// EventTypeReplenishFailed — replacement provisioning exhausted all retries
// EventTypeWatermarkLow — pool node-weight drops below 70 % capacity
// EventTypeBreakerTripped — circuit breaker blocked a replacement attempt
// EventTypeProbeAgentLost — third-party synthetic probe failing ≥3 times
// EventTypeHeartbeatMissing — first-party probe silent for >90 s
// EventTypeFault — node-level outage (domestic + overseas both fail)
//
// # Deduplication
//
// Non-critical events are throttled: the same (Type, NodeID) pair fires at
// most once per 10-minute window (Redis SETNX + TTL). Critical events always
// fire — they are never suppressed.
//
// # Alert channel safety
//
// Notify must never return a blocking error. TG API failures are retried up
// to maxRetries times, then fall back to the slog-based LogNotifier. The
// calling goroutine (scheduler tick) is never stalled by alert-channel faults.
//
// # Identity isolation
//
// The TG bot token and chat ID are injected via environment variables only.
// The bot belongs to a dedicated anonymous operator account; see
// infra/identity-isolation.md and red line 06 §2.
//
// # Privacy
//
// Event messages use internal node IDs (opaque), never public domain names.
// This prevents domain leakage if the alert message is forwarded.
package alert
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// ─────────────────────────────────────────────────────────────────────────────
// Event types
// ─────────────────────────────────────────────────────────────────────────────
// EventType identifies the class of alert event.
type EventType string
const (
// EventTypeBlockConfirmed fires when detect (15D) transitions a node from
// blocked_suspect to blocked_confirmed, confirming GFW censorship after
// ≥6 consecutive failing probe cycles.
// Runbook anchor: #block-confirmed
EventTypeBlockConfirmed EventType = "block_confirmed"
// EventTypeReplenishFailed fires when orchestrate (15E) exhausts all
// MaxAttempts replacement attempts for a blocked node.
// Runbook anchor: #replenish-failed
EventTypeReplenishFailed EventType = "replenish_failed"
// EventTypeWatermarkLow fires when the effective capacity of a node pool
// (combined routing weight of live nodes) drops below 70 % of full capacity.
// Runbook anchor: #watermark-low
EventTypeWatermarkLow EventType = "watermark_low"
// EventTypeBreakerTripped fires when the circuit breaker (15F) blocks a
// replacement attempt due to a burst of confirmed blocks in the same pool.
// Runbook anchor: #breaker-tripped
EventTypeBreakerTripped EventType = "breaker_tripped"
// EventTypeProbeAgentLost fires when the third-party (Aliyun) synthetic
// probe agent encounters ≥3 consecutive API failures.
// Runbook anchor: #probe-agent-lost
EventTypeProbeAgentLost EventType = "probe_agent_lost"
// EventTypeHeartbeatMissing fires when a first-party probe agent has not
// delivered any heartbeat for more than 90 seconds.
// Runbook anchor: #heartbeat-missing
EventTypeHeartbeatMissing EventType = "heartbeat_missing"
// EventTypeFault fires when both domestic AND overseas probes are failing
// for a node, indicating a node-level outage rather than GFW censorship.
// Runbook anchor: #node-fault
EventTypeFault EventType = "fault"
)
// Severity indicates the urgency of an event.
type Severity string
const (
SeverityCritical Severity = "critical" // requires immediate human action
SeverityWarning Severity = "warning" // review within the hour
SeverityInfo Severity = "info" // informational only
)
// typeMeta bundles display properties for one event type.
type typeMeta struct {
Severity Severity
TitleZH string // human-readable Chinese label
Emoji string // prefixed to the TG message
RunbookAnchor string // HTML id anchor in docs/runbook-scheduler.md
}
// typeMetaMap is indexed by EventType.
var typeMetaMap = map[EventType]typeMeta{
EventTypeBlockConfirmed: {SeverityWarning, "判封确认", "⚠️", "#block-confirmed"},
EventTypeReplenishFailed: {SeverityCritical, "补新连续失败≥3", "🔴", "#replenish-failed"},
EventTypeWatermarkLow: {SeverityWarning, "水位<70%", "⚠️", "#watermark-low"},
EventTypeBreakerTripped: {SeverityCritical, "熔断触发", "🔴", "#breaker-tripped"},
EventTypeProbeAgentLost: {SeverityWarning, "探针失联", "⚠️", "#probe-agent-lost"},
EventTypeHeartbeatMissing: {SeverityWarning, "心跳缺失>90s", "⚠️", "#heartbeat-missing"},
EventTypeFault: {SeverityCritical, "故障态", "🔴", "#node-fault"},
}
// ─────────────────────────────────────────────────────────────────────────────
// Event
// ─────────────────────────────────────────────────────────────────────────────
// Event carries all data required to render one alert notification.
type Event struct {
// Type is one of the seven EventType constants.
Type EventType
// Severity controls dedup and display urgency.
// Derived from Type via NewEvent; may be overridden by the caller.
Severity Severity
// NodeID is the internal opaque node or probe agent identifier.
// MUST be an internal ID — never a public domain name (privacy).
NodeID string
// Pool optionally identifies the operational pool (e.g. "free/hkg").
Pool string
// Detail holds supplementary key-value data for the notification.
// Values must not contain personal user data.
Detail map[string]string
// RunbookAnchor is the #fragment anchor in docs/runbook-scheduler.md.
// Overrides the type default when non-empty.
RunbookAnchor string
}
// NewEvent constructs an Event with type-derived Severity and RunbookAnchor.
// Callers may override any field after construction.
func NewEvent(t EventType, nodeID string, detail map[string]string) Event {
m := typeMetaMap[t]
return Event{
Type: t,
Severity: m.Severity,
NodeID: nodeID,
Detail: detail,
RunbookAnchor: m.RunbookAnchor,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Notifier interface
// ─────────────────────────────────────────────────────────────────────────────
// Notifier is the unified alert outlet for scheduler events.
// Implementations must be safe for concurrent use and must never return a
// blocking error — alert-channel failures must not halt the scheduler.
type Notifier interface {
Notify(ctx context.Context, event Event) error
}
// ─────────────────────────────────────────────────────────────────────────────
// LogNotifier
// ─────────────────────────────────────────────────────────────────────────────
// LogNotifier writes every event to the structured logger.
// It is used when TG_BOT_TOKEN is not configured, or as the fallback when the
// TGNotifier exhausts its retries.
type LogNotifier struct{}
// Notify implements Notifier.
func (LogNotifier) Notify(_ context.Context, e Event) error {
m := typeMetaMap[e.Type]
slog.Warn("alert: scheduler event",
"type", string(e.Type),
"title", m.TitleZH,
"severity", string(e.Severity),
"node_id", e.NodeID,
"pool", e.Pool,
"detail", e.Detail,
"runbook", e.RunbookAnchor,
)
return nil
}
// ─────────────────────────────────────────────────────────────────────────────
// TGNotifier
// ─────────────────────────────────────────────────────────────────────────────
const (
// dedupTTL is the dedup suppression window for non-critical events.
dedupTTL = 10 * time.Minute
// dedupKeyPrefix is the Redis key namespace for dedup tokens.
// Full key: alert:dedup:{type}:{nodeID}
dedupKeyPrefix = "alert:dedup:"
// maxRetries is the number of additional TG send attempts after the first
// failure. Exhausting retries falls back to LogNotifier silently.
maxRetries = 2
// tgSendMessageURL is the Telegram Bot API pattern for sendMessage.
tgSendMessageURL = "https://api.telegram.org/bot%s/sendMessage"
)
// TGConfig holds the runtime configuration for TGNotifier.
// All sensitive values come from environment variables; see cmd/ bootstrap.
type TGConfig struct {
// BotToken is the Telegram bot token (env: TG_BOT_TOKEN).
// The bot must belong to a dedicated anonymous operator account (red line §2).
BotToken string
// ChatID is the target Telegram group or channel ID (env: TG_ALERT_CHAT_ID).
ChatID string
// RunbookBaseURL is prepended to the anchor to form the full runbook URL.
// Leave empty to embed only the #anchor fragment.
RunbookBaseURL string
// HTTPTimeout overrides the per-call HTTP timeout (default 10 s).
HTTPTimeout time.Duration
// BaseURL overrides the TG API base URL for testing.
// Leave empty in production.
BaseURL string
}
// TGNotifier sends events to a Telegram group via the Bot API.
//
// Deduplication:
// - Non-critical events: one message per (Type, NodeID) per 10-minute window
// (Redis SETNX + TTL). The second identical message within the window is
// silently dropped.
// - Critical events: always delivered, no dedup.
//
// Retry + fallback:
// - On TG API failure, retries up to maxRetries (2) times before delegating
// to the fallback Notifier. Notify always returns nil.
type TGNotifier struct {
cfg TGConfig
rdb *redis.Client
httpClient *http.Client
fallback Notifier
apiURL string // base send URL, pre-formatted with token
}
// NewTGNotifier constructs a TGNotifier.
// rdb is used for the dedup SETNX gate.
// fallback is used when TG API calls are exhausted; nil defaults to LogNotifier.
func NewTGNotifier(cfg TGConfig, rdb *redis.Client, fallback Notifier) *TGNotifier {
if fallback == nil {
fallback = LogNotifier{}
}
httpTimeout := 10 * time.Second
if cfg.HTTPTimeout > 0 {
httpTimeout = cfg.HTTPTimeout
}
baseURL := fmt.Sprintf(tgSendMessageURL, cfg.BotToken)
if cfg.BaseURL != "" {
// Test hook: override the entire API base URL (token appended separately).
baseURL = cfg.BaseURL
}
return &TGNotifier{
cfg: cfg,
rdb: rdb,
httpClient: &http.Client{Timeout: httpTimeout},
fallback: fallback,
apiURL: baseURL,
}
}
// Notify implements Notifier.
func (n *TGNotifier) Notify(ctx context.Context, e Event) error {
// Critical events bypass the dedup gate entirely.
if e.Severity != SeverityCritical {
allowed, err := n.dedupGate(ctx, e)
if err != nil {
// Redis error → allow through (fail-open) to avoid losing alerts.
slog.Warn("alert: dedup Redis error; allowing through", "error", err)
}
if !allowed {
return nil // duplicate within the 10-min window; silently dropped
}
}
text := n.renderMessage(e)
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if err := n.callTG(ctx, text); err != nil {
lastErr = err
slog.Warn("alert: TG send failed",
"attempt", attempt+1, "of", maxRetries+1,
"type", string(e.Type), "node_id", e.NodeID,
"error", err,
)
continue
}
return nil // delivered
}
// Retries exhausted: degrade to log. Never return an error.
slog.Error("alert: TG delivery exhausted retries; falling back to log",
"type", string(e.Type), "node_id", e.NodeID, "last_error", lastErr)
_ = n.fallback.Notify(ctx, e)
return nil
}
// dedupGate returns (true, nil) if the event should be sent (key newly set),
// or (false, nil) if it is a duplicate within the 10-min window.
func (n *TGNotifier) dedupGate(ctx context.Context, e Event) (bool, error) {
key := dedupKeyPrefix + string(e.Type) + ":" + e.NodeID
set, err := n.rdb.SetNX(ctx, key, "1", dedupTTL).Result()
if err != nil {
return true, fmt.Errorf("alert: dedup SETNX: %w", err)
}
return set, nil
}
// renderMessage builds the Telegram HTML message body.
// Node IDs are used as-is (internal opaque IDs, not domain names).
func (n *TGNotifier) renderMessage(e Event) string {
m := typeMetaMap[e.Type]
var sb strings.Builder
// Line 1: emoji + bold event title + node id
sb.WriteString(m.Emoji)
sb.WriteString(" <b>")
sb.WriteString(htmlEscape(m.TitleZH))
sb.WriteString("</b> — 节点 <code>")
sb.WriteString(htmlEscape(e.NodeID))
sb.WriteString("</code>")
if e.Pool != "" {
sb.WriteString(" 池 <code>")
sb.WriteString(htmlEscape(e.Pool))
sb.WriteString("</code>")
}
sb.WriteString("\n")
// Line 2: severity label
sb.WriteString("严重度: ")
sb.WriteString(string(e.Severity))
sb.WriteString("\n")
// Detail pairs (sorted-ish; map iteration is random, determinism not required)
for k, v := range e.Detail {
sb.WriteString(htmlEscape(k))
sb.WriteString(": ")
sb.WriteString(htmlEscape(v))
sb.WriteString("\n")
}
// Runbook link
anchor := e.RunbookAnchor
if anchor == "" {
anchor = m.RunbookAnchor
}
link := anchor
if n.cfg.RunbookBaseURL != "" {
link = n.cfg.RunbookBaseURL + anchor
}
sb.WriteString("📖 <a href=\"")
sb.WriteString(link)
sb.WriteString("\">处置手册</a>")
return sb.String()
}
// callTG posts one sendMessage request to the Telegram Bot API.
func (n *TGNotifier) callTG(ctx context.Context, text string) error {
payload, err := json.Marshal(map[string]string{
"chat_id": n.cfg.ChatID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": "true",
})
if err != nil {
return fmt.Errorf("alert: marshal TG payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.apiURL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("alert: build TG request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := n.httpClient.Do(req)
if err != nil {
return fmt.Errorf("alert: TG http: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) //nolint:errcheck // response body drained for keep-alive
if resp.StatusCode >= 500 {
return fmt.Errorf("alert: TG server error HTTP %d", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("alert: TG unexpected HTTP %d", resp.StatusCode)
}
return nil
}
// htmlEscape escapes the five HTML special characters relevant to Telegram HTML mode.
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}