2026-08-26 20:47:54 +03:00
|
|
|
package probercore
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"sync"
|
2026-09-18 10:43:00 +03:00
|
|
|
"sync/atomic"
|
2026-08-26 20:47:54 +03:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"cloudipvalidator/internal/apiclient"
|
|
|
|
|
"cloudipvalidator/internal/config"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func testLogger() *slog.Logger {
|
|
|
|
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestPollOnceCallsHeartbeatBeforeAssignments mirrors agentcore's
|
|
|
|
|
// heartbeat-then-assignment ordering: the prober must announce it's alive
|
|
|
|
|
// on every poll cycle before fetching work, not just once at startup.
|
|
|
|
|
func TestPollOnceCallsHeartbeatBeforeAssignments(t *testing.T) {
|
|
|
|
|
var mu sync.Mutex
|
|
|
|
|
var calls []string
|
|
|
|
|
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("POST /api/v1/probers/site-1/heartbeat", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mu.Lock()
|
|
|
|
|
calls = append(calls, "heartbeat")
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
w.Write([]byte(`{"ok":true}`))
|
|
|
|
|
})
|
|
|
|
|
mux.HandleFunc("GET /api/v1/probers/site-1/assignments", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mu.Lock()
|
|
|
|
|
calls = append(calls, "assignments")
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
w.Write([]byte(`[]`))
|
|
|
|
|
})
|
|
|
|
|
ts := httptest.NewServer(mux)
|
|
|
|
|
defer ts.Close()
|
|
|
|
|
|
|
|
|
|
p := &Prober{
|
|
|
|
|
cfg: &config.Prober{SiteID: "site-1"},
|
|
|
|
|
client: apiclient.New(ts.URL, 5*time.Second),
|
|
|
|
|
log: testLogger(),
|
|
|
|
|
}
|
|
|
|
|
p.pollOnce(context.Background())
|
|
|
|
|
|
|
|
|
|
mu.Lock()
|
|
|
|
|
defer mu.Unlock()
|
|
|
|
|
if len(calls) != 2 || calls[0] != "heartbeat" || calls[1] != "assignments" {
|
|
|
|
|
t.Fatalf("expected [heartbeat assignments], got %v", calls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestPollOnceSkipsAssignmentsWhenHeartbeatFails confirms a failed
|
|
|
|
|
// heartbeat aborts the poll cycle early — mirrors agentcore.pollOnce's
|
|
|
|
|
// same early-return behavior.
|
|
|
|
|
func TestPollOnceSkipsAssignmentsWhenHeartbeatFails(t *testing.T) {
|
|
|
|
|
var mu sync.Mutex
|
|
|
|
|
var calls []string
|
|
|
|
|
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("POST /api/v1/probers/site-1/heartbeat", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mu.Lock()
|
|
|
|
|
calls = append(calls, "heartbeat")
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
|
})
|
|
|
|
|
mux.HandleFunc("GET /api/v1/probers/site-1/assignments", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mu.Lock()
|
|
|
|
|
calls = append(calls, "assignments")
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
w.Write([]byte(`[]`))
|
|
|
|
|
})
|
|
|
|
|
ts := httptest.NewServer(mux)
|
|
|
|
|
defer ts.Close()
|
|
|
|
|
|
|
|
|
|
p := &Prober{
|
|
|
|
|
cfg: &config.Prober{SiteID: "site-1"},
|
|
|
|
|
client: apiclient.New(ts.URL, 5*time.Second),
|
|
|
|
|
log: testLogger(),
|
|
|
|
|
}
|
|
|
|
|
p.pollOnce(context.Background())
|
|
|
|
|
|
|
|
|
|
mu.Lock()
|
|
|
|
|
defer mu.Unlock()
|
|
|
|
|
if len(calls) != 1 || calls[0] != "heartbeat" {
|
|
|
|
|
t.Fatalf("expected only [heartbeat] (assignments skipped on heartbeat failure), got %v", calls)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-26 23:52:31 +03:00
|
|
|
|
2026-09-18 10:43:00 +03:00
|
|
|
// TestRegisterWithRetrySucceedsAfterSiteConfigured mirrors an admin adding
|
|
|
|
|
// this prober's site_id to control-api's config after the prober has
|
|
|
|
|
// already started: registration is rejected with 400 until then, and the
|
|
|
|
|
// prober must keep retrying rather than give up.
|
|
|
|
|
func TestRegisterWithRetrySucceedsAfterSiteConfigured(t *testing.T) {
|
|
|
|
|
var attempts int32
|
|
|
|
|
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("POST /api/v1/probers/register", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
n := atomic.AddInt32(&attempts, 1)
|
|
|
|
|
if n < 3 {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
w.Write([]byte(`{"error":"unknown site_id: site-1"}`))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
})
|
|
|
|
|
ts := httptest.NewServer(mux)
|
|
|
|
|
defer ts.Close()
|
|
|
|
|
|
|
|
|
|
p := &Prober{
|
|
|
|
|
cfg: &config.Prober{SiteID: "site-1", ControlAPIURL: ts.URL},
|
|
|
|
|
client: apiclient.New(ts.URL, 5*time.Second),
|
|
|
|
|
log: testLogger(),
|
|
|
|
|
registerRetryInitial: time.Millisecond,
|
|
|
|
|
registerRetryMax: 5 * time.Millisecond,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
if err := p.registerWithRetry(ctx); err != nil {
|
|
|
|
|
t.Fatalf("expected eventual success, got err: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := atomic.LoadInt32(&attempts); got != 3 {
|
|
|
|
|
t.Fatalf("expected exactly 3 attempts, got %d", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestRegisterWithRetryStopsOnCancel confirms a prober waiting on an
|
|
|
|
|
// unreachable/unconfigured control-api can still be shut down promptly.
|
|
|
|
|
func TestRegisterWithRetryStopsOnCancel(t *testing.T) {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("POST /api/v1/probers/register", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
})
|
|
|
|
|
ts := httptest.NewServer(mux)
|
|
|
|
|
defer ts.Close()
|
|
|
|
|
|
|
|
|
|
p := &Prober{
|
|
|
|
|
cfg: &config.Prober{SiteID: "site-1", ControlAPIURL: ts.URL},
|
|
|
|
|
client: apiclient.New(ts.URL, 5*time.Second),
|
|
|
|
|
log: testLogger(),
|
|
|
|
|
registerRetryInitial: 10 * time.Millisecond,
|
|
|
|
|
registerRetryMax: 10 * time.Millisecond,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
go func() {
|
|
|
|
|
time.Sleep(30 * time.Millisecond)
|
|
|
|
|
cancel()
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
var err error
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
defer close(done)
|
|
|
|
|
err = p.registerWithRetry(ctx)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-done:
|
|
|
|
|
case <-time.After(2 * time.Second):
|
|
|
|
|
t.Fatal("registerWithRetry did not return after ctx cancellation")
|
|
|
|
|
}
|
|
|
|
|
if err != context.Canceled {
|
|
|
|
|
t.Fatalf("expected context.Canceled, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 23:52:31 +03:00
|
|
|
// TestExtraChecksForPort confirms only ports 443 (TLS) and 22 (SSH) get an
|
|
|
|
|
// extra protocol check beyond the baseline TCPConnect every port gets.
|
|
|
|
|
func TestExtraChecksForPort(t *testing.T) {
|
|
|
|
|
// Use a host nothing is listening on so the extra check's own network
|
|
|
|
|
// call fails fast — only its CheckType is asserted here, the
|
|
|
|
|
// port->check mapping, not real connectivity.
|
|
|
|
|
closedHost := "127.0.0.1"
|
|
|
|
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
port int
|
|
|
|
|
wantLen int
|
|
|
|
|
wantCheckType string
|
|
|
|
|
}{
|
|
|
|
|
{port: 443, wantLen: 1, wantCheckType: "tls-443"},
|
|
|
|
|
{port: 22, wantLen: 1, wantCheckType: "ssh"},
|
|
|
|
|
{port: 80, wantLen: 0},
|
|
|
|
|
{port: 8080, wantLen: 0},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
extra := extraChecksForPort(closedHost, tc.port, 100*time.Millisecond)
|
|
|
|
|
if len(extra) != tc.wantLen {
|
|
|
|
|
t.Fatalf("port %d: expected %d extra checks, got %d", tc.port, tc.wantLen, len(extra))
|
|
|
|
|
}
|
|
|
|
|
if tc.wantLen == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
res := extra[0](context.Background())
|
|
|
|
|
if res.CheckType != tc.wantCheckType {
|
|
|
|
|
t.Fatalf("port %d: expected check type %q, got %q", tc.port, tc.wantCheckType, res.CheckType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-01 11:35:24 +03:00
|
|
|
|
|
|
|
|
// TestWithTokenSendsBearerToControlAPI checks the prober's control-api
|
|
|
|
|
// client carries the agent token on every request.
|
|
|
|
|
func TestWithTokenSendsBearerToControlAPI(t *testing.T) {
|
|
|
|
|
var mu sync.Mutex
|
|
|
|
|
var auths []string
|
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
mu.Lock()
|
|
|
|
|
auths = append(auths, r.Header.Get("Authorization"))
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
w.Write([]byte(`[]`))
|
|
|
|
|
}))
|
|
|
|
|
defer ts.Close()
|
|
|
|
|
|
|
|
|
|
p := New(&config.Prober{SiteID: "site-1", ControlAPIURL: ts.URL}, testLogger()).WithToken("agent-secret")
|
|
|
|
|
p.pollOnce(context.Background())
|
|
|
|
|
|
|
|
|
|
mu.Lock()
|
|
|
|
|
defer mu.Unlock()
|
|
|
|
|
if len(auths) == 0 {
|
|
|
|
|
t.Fatal("no requests reached control-api")
|
|
|
|
|
}
|
|
|
|
|
for _, a := range auths {
|
|
|
|
|
if a != "Bearer agent-secret" {
|
|
|
|
|
t.Fatalf("Authorization = %q, want bearer token", a)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|