a47121f99b
Implements ProberAgent interface + Alibaba Cloud CloudMonitor Synthetic adapter:
- ProbeTarget / VantageResult types aligned with 15A schema (L3 always nil)
- AliyunSyntheticAgent: CreateSiteMonitor → poll → map to VantageResult
- ISP vantage uses "3rd-{ISP}" prefix; Redis key probe:{node}:CN:{region}:3rd-*
- Degradation: API error/rate-limit/timeout → skip vantage, log + counter (no write)
- RunOnce(ctx, targets) shape for 15H assembly wiring
- Credentials via cfg only (env-var injection, zero hardcoded secrets)
- 6 new tests (mock vendor server): normalization, rate-limit, timeout, Redis keys,
failed-target L1, no-credentials check; all 22 probe-package tests pass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
338 lines
10 KiB
Go
338 lines
10 KiB
Go
package probe_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
|
|
)
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Mock vendor server helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
// vendorBehavior controls what the mock Alibaba Cloud server returns.
|
|
type vendorBehavior int
|
|
|
|
const (
|
|
behaviorSuccess vendorBehavior = iota // normal TCP probe success
|
|
behaviorFailedProbe // probe ran but target unreachable
|
|
behaviorRateLimit // HTTP 429
|
|
behaviorTimeout // server hangs until context cancelled
|
|
)
|
|
|
|
// newMockAliyunServer creates an httptest.Server that behaves according to b.
|
|
// It counts how many times CreateSiteMonitor and DescribeSiteMonitorData were called.
|
|
func newMockAliyunServer(t *testing.T, b vendorBehavior) (*httptest.Server, *atomic.Int32, *atomic.Int32) {
|
|
t.Helper()
|
|
var createCalls, pollCalls atomic.Int32
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", 400)
|
|
return
|
|
}
|
|
action := r.FormValue("Action")
|
|
|
|
switch b {
|
|
case behaviorRateLimit:
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
|
|
case behaviorTimeout:
|
|
// Block until the client closes or test ends.
|
|
<-r.Context().Done()
|
|
return
|
|
}
|
|
|
|
switch action {
|
|
case "CreateSiteMonitor":
|
|
createCalls.Add(1)
|
|
if b == behaviorSuccess || b == behaviorFailedProbe {
|
|
writeJSON(w, map[string]interface{}{
|
|
"Code": "200",
|
|
"Message": "OK",
|
|
"RequestId": "mock-req-id",
|
|
"Data": map[string]string{"TaskId": "task-mock-001"},
|
|
})
|
|
}
|
|
|
|
case "DescribeSiteMonitorData":
|
|
pollCalls.Add(1)
|
|
switch b {
|
|
case behaviorSuccess:
|
|
writeJSON(w, map[string]interface{}{
|
|
"Code": "200",
|
|
"Message": "OK",
|
|
"Data": map[string]interface{}{
|
|
"List": []map[string]interface{}{
|
|
{
|
|
"Province": "北京",
|
|
"Average": float64(42),
|
|
"Value": float64(100), // 100 = available
|
|
"ErrorCode": "",
|
|
"ErrorInfo": "",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
case behaviorFailedProbe:
|
|
writeJSON(w, map[string]interface{}{
|
|
"Code": "200",
|
|
"Message": "OK",
|
|
"Data": map[string]interface{}{
|
|
"List": []map[string]interface{}{
|
|
{
|
|
"Province": "北京",
|
|
"Average": float64(0),
|
|
"Value": float64(0), // 0 = unavailable
|
|
"ErrorCode": "CONNECT_TIMEOUT",
|
|
"ErrorInfo": "connect timeout",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
default:
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
}))
|
|
|
|
t.Cleanup(srv.Close)
|
|
return srv, &createCalls, &pollCalls
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// newTestAgent creates an AliyunSyntheticAgent wired to a mock server and miniredis.
|
|
func newTestAgent(t *testing.T, srv *httptest.Server, mr *miniredis.Miniredis) *probe.AliyunSyntheticAgent {
|
|
t.Helper()
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
st := probe.NewStore(rdb)
|
|
|
|
cfg := probe.AliyunSyntheticAgentConfig{
|
|
AccessKeyID: "test-key-id",
|
|
AccessKeySecret: "test-key-secret",
|
|
PollInterval: 10 * time.Millisecond, // fast polling in tests
|
|
PollTimeout: 2 * time.Second,
|
|
ProbeCity: "563",
|
|
}
|
|
|
|
var logBuf bytes.Buffer
|
|
logger := slog.New(slog.NewJSONHandler(&logBuf, nil))
|
|
|
|
agent := probe.NewAliyunSyntheticAgentWithOptions(cfg, st, logger, srv.URL+"/")
|
|
return agent
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Tests
|
|
// --------------------------------------------------------------------------
|
|
|
|
// TestProbeSuccess_NormalizesResults verifies that a successful vendor response
|
|
// is correctly mapped to VantageResults with 3rd- prefixed ISP names.
|
|
func TestProbeSuccess_NormalizesResults(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
srv, createCalls, _ := newMockAliyunServer(t, behaviorSuccess)
|
|
agent := newTestAgent(t, srv, mr)
|
|
|
|
target := probe.ProbeTarget{NodeID: "node-hk-01", Host: "hk.example.com", Port: 443}
|
|
results, err := agent.Probe(context.Background(), target)
|
|
if err != nil {
|
|
t.Fatalf("Probe returned error: %v", err)
|
|
}
|
|
|
|
// Expect one result per ISP (3 total: Telecom, Unicom, Mobile).
|
|
if len(results) != 3 {
|
|
t.Fatalf("expected 3 VantageResults, got %d", len(results))
|
|
}
|
|
|
|
// All ISP names must carry the "3rd-" prefix.
|
|
for _, vr := range results {
|
|
if !strings.HasPrefix(vr.Vantage.ISP, "3rd-") {
|
|
t.Errorf("ISP %q missing 3rd- prefix", vr.Vantage.ISP)
|
|
}
|
|
if vr.Vantage.Country != "CN" {
|
|
t.Errorf("Country = %q, want CN", vr.Vantage.Country)
|
|
}
|
|
}
|
|
|
|
// Check first result details (mock returns same data for all ISPs).
|
|
vr := results[0]
|
|
if !vr.L1.OK {
|
|
t.Error("L1.OK should be true on success")
|
|
}
|
|
if vr.L1.RttMs != 42 {
|
|
t.Errorf("L1.RttMs = %d, want 42", vr.L1.RttMs)
|
|
}
|
|
if vr.L2 == nil {
|
|
t.Fatal("L2 should be non-nil for port 443")
|
|
}
|
|
if !vr.L2.OK {
|
|
t.Errorf("L2.OK should be true; Err=%q", vr.L2.Err)
|
|
}
|
|
// L3 must always be nil for 3rd-party results.
|
|
// (VantageResult has no L3 field — this is a compile-time guarantee.)
|
|
|
|
if createCalls.Load() != 3 {
|
|
t.Errorf("expected 3 CreateSiteMonitor calls (one per ISP), got %d", createCalls.Load())
|
|
}
|
|
}
|
|
|
|
// TestProbeRateLimit_DegradesToNoData verifies that a 429 response causes
|
|
// the agent to return an empty slice (no data) and NOT write any Redis keys.
|
|
func TestProbeRateLimit_DegradesToNoData(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
srv, _, _ := newMockAliyunServer(t, behaviorRateLimit)
|
|
agent := newTestAgent(t, srv, mr)
|
|
|
|
target := probe.ProbeTarget{NodeID: "node-sg-01", Host: "sg.example.com", Port: 443}
|
|
results, err := agent.Probe(context.Background(), target)
|
|
if err != nil {
|
|
t.Fatalf("Probe should not return error on rate-limit (degraded); got: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results on rate-limit, got %d", len(results))
|
|
}
|
|
|
|
// No Redis keys should have been written.
|
|
keys := mr.Keys()
|
|
for _, k := range keys {
|
|
if strings.HasPrefix(k, "probe:node-sg-01") {
|
|
t.Errorf("unexpected Redis key written during degradation: %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProbeTimeout_DegradesToNoData verifies that a hung vendor server (simulating
|
|
// timeout) causes degradation: empty results, no Redis writes.
|
|
func TestProbeTimeout_DegradesToNoData(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
srv, _, _ := newMockAliyunServer(t, behaviorTimeout)
|
|
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
st := probe.NewStore(rdb)
|
|
cfg := probe.AliyunSyntheticAgentConfig{
|
|
AccessKeyID: "test-key-id",
|
|
AccessKeySecret: "test-key-secret",
|
|
PollInterval: 10 * time.Millisecond,
|
|
PollTimeout: 100 * time.Millisecond, // very short for test
|
|
}
|
|
// Use a short HTTP client timeout too.
|
|
agent := probe.NewAliyunSyntheticAgentWithOptions(cfg, st, slog.Default(), srv.URL+"/")
|
|
_ = probe.SetHTTPClientTimeout(agent, 50*time.Millisecond)
|
|
|
|
target := probe.ProbeTarget{NodeID: "node-jp-01", Host: "jp.example.com", Port: 443}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
|
defer cancel()
|
|
|
|
results, err := agent.Probe(ctx, target)
|
|
if err != nil {
|
|
t.Fatalf("Probe should not propagate timeout error; got: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results on timeout, got %d", len(results))
|
|
}
|
|
|
|
// No Redis keys for this node.
|
|
for _, k := range mr.Keys() {
|
|
if strings.HasPrefix(k, "probe:node-jp-01") {
|
|
t.Errorf("unexpected Redis key on timeout: %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRunOnce_WritesRedisKeys verifies that RunOnce writes probe:{node}:3rd-* keys
|
|
// to Redis for each ISP after a successful probe cycle.
|
|
func TestRunOnce_WritesRedisKeys(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
srv, _, _ := newMockAliyunServer(t, behaviorSuccess)
|
|
agent := newTestAgent(t, srv, mr)
|
|
|
|
targets := []probe.ProbeTarget{
|
|
{NodeID: "node-hk-02", Host: "hk2.example.com", Port: 443},
|
|
}
|
|
if err := agent.RunOnce(context.Background(), targets); err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
// Expect 3 keys: one per ISP, each with "3rd-" in the vantage segment.
|
|
var thirdPartyKeys []string
|
|
for _, k := range mr.Keys() {
|
|
if strings.HasPrefix(k, "probe:node-hk-02:") && strings.Contains(k, "3rd-") {
|
|
thirdPartyKeys = append(thirdPartyKeys, k)
|
|
}
|
|
}
|
|
if len(thirdPartyKeys) != 3 {
|
|
t.Errorf("expected 3 probe:node-hk-02:...:3rd-* keys, got %d: %v", len(thirdPartyKeys), thirdPartyKeys)
|
|
}
|
|
|
|
// Verify key format: probe:{node}:{country}:{region}:{isp}
|
|
// e.g. probe:node-hk-02:CN:北京:3rd-ChinaTelecom (spaces become underscores)
|
|
for _, k := range thirdPartyKeys {
|
|
parts := strings.Split(k, ":")
|
|
// parts[0]=probe, parts[1]=node-hk-02, parts[2]=CN, parts[3]=region, parts[4]=3rd-...
|
|
if len(parts) < 5 {
|
|
t.Errorf("unexpected key format: %q", k)
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(parts[4], "3rd-") {
|
|
t.Errorf("ISP segment %q missing 3rd- prefix in key %q", parts[4], k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProbeFailedTarget_L1NotOK verifies that when the vendor reports the target
|
|
// is unreachable (Availability=0), L1.OK is false and no L2 is populated.
|
|
func TestProbeFailedTarget_L1NotOK(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
srv, _, _ := newMockAliyunServer(t, behaviorFailedProbe)
|
|
agent := newTestAgent(t, srv, mr)
|
|
|
|
target := probe.ProbeTarget{NodeID: "node-fail-01", Host: "fail.example.com", Port: 443}
|
|
results, err := agent.Probe(context.Background(), target)
|
|
if err != nil {
|
|
t.Fatalf("Probe: %v", err)
|
|
}
|
|
if len(results) != 3 {
|
|
t.Fatalf("expected 3 results even on target failure, got %d", len(results))
|
|
}
|
|
for _, vr := range results {
|
|
if vr.L1.OK {
|
|
t.Errorf("ISP %s: L1.OK should be false for unreachable target", vr.Vantage.ISP)
|
|
}
|
|
if vr.L2 != nil {
|
|
t.Errorf("ISP %s: L2 should be nil when L1 failed", vr.Vantage.ISP)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNoPlaintextCredentials is a compile-time reminder that credentials
|
|
// must not be hardcoded. The test itself only validates that the agent
|
|
// accepts empty credentials (real validation is at runtime via env vars).
|
|
func TestNoPlaintextCredentials(t *testing.T) {
|
|
cfg := probe.AliyunSyntheticAgentConfig{
|
|
AccessKeyID: "", // must come from env, not code
|
|
AccessKeySecret: "", // must come from env, not code
|
|
}
|
|
// A zero-credential config is valid to construct; errors surface on actual calls.
|
|
if cfg.AccessKeyID != "" || cfg.AccessKeySecret != "" {
|
|
t.Error("credentials must not be hardcoded in config defaults")
|
|
}
|
|
}
|