5d4b484646
新增 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>
193 lines
9.3 KiB
Go
193 lines
9.3 KiB
Go
// Package orchestrate implements the replacement orchestration state machine
|
|
// (task 15E). It is driven every 30 s by the OrchestrateLoop (task 15H).
|
|
//
|
|
// Key components:
|
|
// - Replacer.Tick — drains the 15D replace queue and advances in-flight records.
|
|
// - Grayscale.Advance — gradually ramps new/recovered node weight 10→25→50→75→100.
|
|
package orchestrate
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/alert"
|
|
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
|
|
)
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Phase constants
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Phase is the orchestration phase of a replacement record.
|
|
type Phase string
|
|
|
|
const (
|
|
PhasePending Phase = "pending" // waiting for breaker / quota approval
|
|
PhaseCreating Phase = "creating" // CreateNode called; waiting for new node
|
|
PhaseProbing Phase = "probing" // waiting for consecutive probe passes
|
|
PhaseActivating Phase = "activating" // promoting new node; starting grayscale
|
|
PhaseDrainingOld Phase = "draining_old" // destroying old (already-down) node
|
|
PhaseDone Phase = "done" // finished successfully
|
|
PhaseFailed Phase = "failed" // terminal failure; human review needed
|
|
)
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tunable constants
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const (
|
|
// MaxAttempts is the maximum number of create+probe tries before marking failed.
|
|
MaxAttempts = 3
|
|
|
|
// ProbeCyclesRequired is the number of consecutive Tick cycles with a passing
|
|
// probe snapshot required before the new node is promoted to "up".
|
|
ProbeCyclesRequired = 2
|
|
|
|
// ProbeTimeout is the maximum time allowed in the probing phase per attempt.
|
|
ProbeTimeout = 15 * time.Minute
|
|
|
|
// GrayscaleInterval is the time between successive weight ramp steps.
|
|
GrayscaleInterval = 6 * time.Hour
|
|
)
|
|
|
|
// GrayscaleWeights is the weight ladder for the warmup ramp.
|
|
// New / recovered nodes start at 10 and advance every GrayscaleInterval.
|
|
var GrayscaleWeights = []int{10, 25, 50, 75, 100}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Data types
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// NodeSpec describes the replacement node to be provisioned.
|
|
type NodeSpec struct {
|
|
Tier string
|
|
Region string
|
|
Role string
|
|
ProviderID string // preferred provider for this attempt
|
|
RealitySNI string // SNI rotation
|
|
RealityPBK string
|
|
HY2Port int
|
|
NameZH string
|
|
NameEn string
|
|
Tags []string
|
|
}
|
|
|
|
// NodeInfo holds the properties of an existing node needed to build a
|
|
// replacement spec or to look up tier / region for the breaker.
|
|
type NodeInfo struct {
|
|
ID string
|
|
Tier string
|
|
Region string
|
|
Role string
|
|
ProviderID string
|
|
RealitySNI string
|
|
RealityPBK string
|
|
HY2Port int
|
|
NameZH string
|
|
NameEn string
|
|
Tags []string
|
|
}
|
|
|
|
// ProviderInfo describes a cloud provider available for provisioning.
|
|
type ProviderInfo struct {
|
|
ID string // provider identifier
|
|
Regions []string // supported regions (empty means all regions)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Service interfaces
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// ProvisionService is the #14 provisioning interface.
|
|
// The real implementation is provided by task #14; a mock is used in tests.
|
|
type ProvisionService interface {
|
|
// CreateNode provisions a new node. idempotencyKey makes the call crash-safe:
|
|
// replaying the same key returns the already-created node without booting again.
|
|
CreateNode(ctx context.Context, spec NodeSpec, idempotencyKey string) (nodeID string, err error)
|
|
|
|
// DestroyNode tears down a node and releases its IP.
|
|
DestroyNode(ctx context.Context, nodeID string) error
|
|
|
|
// RotateIP swaps the elastic IP on an existing node without re-creating it.
|
|
RotateIP(ctx context.Context, nodeID string) (newNodeID string, err error)
|
|
|
|
// ListProviders returns providers available for the given tier and region.
|
|
// The returned slice is ordered; callers use rotation for provider selection.
|
|
ListProviders(ctx context.Context, tier, region string) ([]ProviderInfo, error)
|
|
}
|
|
|
|
// LifecycleService provides node lifecycle management (real impl: task #5).
|
|
type LifecycleService interface {
|
|
// GetNode returns the current properties of the node, or nil if not found.
|
|
GetNode(ctx context.Context, nodeID string) (*NodeInfo, error)
|
|
|
|
// TransitionStatus performs an optimistic-lock status transition.
|
|
// Returns (1, nil) on success, (0, nil) on lock conflict.
|
|
TransitionStatus(ctx context.Context, nodeID string, from, to string, detail map[string]any) (int, error)
|
|
|
|
// SetWeight updates the routing weight for the node.
|
|
SetWeight(ctx context.Context, nodeID string, weight int) error
|
|
|
|
// BumpVersion increments the global directory version so clients re-fetch.
|
|
BumpVersion(ctx context.Context) error
|
|
|
|
// WriteAuditLog records an audit trail entry.
|
|
WriteAuditLog(ctx context.Context, actor, action, target, meta string) error
|
|
}
|
|
|
|
// ProbeSnapshotter reads the most-recent probe snapshots for a node (15A store).
|
|
type ProbeSnapshotter interface {
|
|
SnapshotsByNode(ctx context.Context, nodeID string) (map[string]probe.ProbeSnapshot, error)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Breaker (15F stub)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Breaker is the 15F circuit-breaker interface.
|
|
type Breaker interface {
|
|
// Allow returns true if a new replacement is permitted to proceed.
|
|
Allow(tier, region string) bool
|
|
|
|
// Record increments the failure counter after a replaced node has been destroyed.
|
|
Record(tier, region string)
|
|
}
|
|
|
|
// StubBreaker always permits replacements.
|
|
// TODO(15F): replace with the real Breaker once task 15F is implemented.
|
|
type StubBreaker struct{}
|
|
|
|
// Allow implements Breaker; always returns true.
|
|
func (StubBreaker) Allow(_, _ string) bool { return true }
|
|
|
|
// Record implements Breaker; no-op until 15F is implemented.
|
|
func (StubBreaker) Record(_, _ string) {}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Notifier (15G)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Notifier is the 15G unified alert outlet used by the orchestrator.
|
|
// It is satisfied by alert.Notifier (TG implementation) and alert.LogNotifier
|
|
// (fallback / development stub).
|
|
type Notifier = alert.Notifier
|
|
|
|
// LogNotifier is re-exported for callers that need a no-op Notifier without
|
|
// importing the alert package directly.
|
|
type LogNotifier = alert.LogNotifier
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Clock (for testability)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Clock abstracts wall-clock time so tests can fast-forward without sleeping.
|
|
type Clock interface {
|
|
Now() time.Time
|
|
}
|
|
|
|
// RealClock is the production clock.
|
|
type RealClock struct{}
|
|
|
|
// Now implements Clock.
|
|
func (RealClock) Now() time.Time { return time.Now().UTC() }
|