605bfa1ec9
Implements AgentService gRPC server with all 6 RPCs (Enroll/Register/Heartbeat/ Subscribe/Ack/ReportUsage), Hub command routing with Redis ZSET at-least-once persistence and cross-instance pub/sub delivery, LoadCache for node:load metrics, NodeStore SQL interface + MySQL implementation, and full mTLS gRPC listener in main. Integration tests: 18 tests covering full Enroll→Register→Heartbeat→Subscribe→Ack flow, reconnect resume with last_command_id, and cross-instance pub/sub delivery via bufconn + miniredis + mockNodeStore + real mTLS certificates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
387 lines
12 KiB
Go
387 lines
12 KiB
Go
package nodes_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/nodes"
|
|
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
|
|
)
|
|
|
|
// newTestHub creates a Hub backed by a fresh miniredis instance.
|
|
func newTestHub(t *testing.T) (*nodes.Hub, *redis.Client, *miniredis.Miniredis) {
|
|
t.Helper()
|
|
mr := miniredis.RunT(t)
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
t.Cleanup(func() { rdb.Close() })
|
|
return nodes.NewHub(rdb), rdb, mr
|
|
}
|
|
|
|
// ─── Push / Replay / Ack ─────────────────────────────────────────────────────
|
|
|
|
// TestHub_PushReplay verifies that pushed commands are retrievable via Replay,
|
|
// filtered by afterID (exclusive), and removed from the queue on Ack.
|
|
func TestHub_PushReplay(t *testing.T) {
|
|
hub, _, _ := newTestHub(t)
|
|
ctx := context.Background()
|
|
const nodeUUID = "node-replay-test"
|
|
|
|
// Push three commands with explicit IDs so the test is deterministic.
|
|
cmds := []*agentv1.Command{
|
|
{CommandID: 10, Type: agentv1.CommandTypeUpsert,
|
|
Upsert: &agentv1.UpsertPayload{Credential: &agentv1.Credential{DpUUID: "dp-a"}}},
|
|
{CommandID: 20, Type: agentv1.CommandTypeUpsert,
|
|
Upsert: &agentv1.UpsertPayload{Credential: &agentv1.Credential{DpUUID: "dp-b"}}},
|
|
{CommandID: 30, Type: agentv1.CommandTypeRevoke,
|
|
Revoke: &agentv1.RevokePayload{DpUUID: "dp-a"}},
|
|
}
|
|
for _, c := range cmds {
|
|
if err := hub.Push(ctx, nodeUUID, c); err != nil {
|
|
t.Fatalf("Push id=%d: %v", c.CommandID, err)
|
|
}
|
|
}
|
|
|
|
// Replay from after 10 → must return 20 and 30 only.
|
|
got, err := hub.Replay(ctx, nodeUUID, 10)
|
|
if err != nil {
|
|
t.Fatalf("Replay: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("Replay returned %d commands, want 2", len(got))
|
|
}
|
|
if got[0].CommandID != 20 || got[1].CommandID != 30 {
|
|
t.Errorf("Replay ids = [%d %d], want [20 30]",
|
|
got[0].CommandID, got[1].CommandID)
|
|
}
|
|
|
|
// Ack command 20.
|
|
if err := hub.Ack(ctx, nodeUUID, 20); err != nil {
|
|
t.Fatalf("Ack 20: %v", err)
|
|
}
|
|
|
|
// Replay from 0: 10 and 30 remain; 20 was removed.
|
|
got, err = hub.Replay(ctx, nodeUUID, 0)
|
|
if err != nil {
|
|
t.Fatalf("Replay after ack: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("after Ack 20: Replay returned %d commands, want 2", len(got))
|
|
}
|
|
ids := []int64{got[0].CommandID, got[1].CommandID}
|
|
if ids[0] != 10 || ids[1] != 30 {
|
|
t.Errorf("after Ack 20: ids = %v, want [10 30]", ids)
|
|
}
|
|
}
|
|
|
|
// TestHub_AutoID verifies that Hub assigns monotonically increasing IDs when
|
|
// cmd.CommandID == 0.
|
|
func TestHub_AutoID(t *testing.T) {
|
|
hub, _, _ := newTestHub(t)
|
|
ctx := context.Background()
|
|
const nodeUUID = "node-auto-id"
|
|
|
|
c1 := &agentv1.Command{Type: agentv1.CommandTypeLifecycle,
|
|
Lifecycle: &agentv1.LifecyclePayload{Action: agentv1.LifecycleActionDrain}}
|
|
c2 := &agentv1.Command{Type: agentv1.CommandTypeLifecycle,
|
|
Lifecycle: &agentv1.LifecyclePayload{Action: agentv1.LifecycleActionResume}}
|
|
|
|
if err := hub.Push(ctx, nodeUUID, c1); err != nil {
|
|
t.Fatalf("Push c1: %v", err)
|
|
}
|
|
if err := hub.Push(ctx, nodeUUID, c2); err != nil {
|
|
t.Fatalf("Push c2: %v", err)
|
|
}
|
|
|
|
if c1.CommandID == 0 {
|
|
t.Error("c1.CommandID still 0 after Push")
|
|
}
|
|
if c2.CommandID == 0 {
|
|
t.Error("c2.CommandID still 0 after Push")
|
|
}
|
|
if c1.CommandID >= c2.CommandID {
|
|
t.Errorf("c1.CommandID=%d >= c2.CommandID=%d; want strictly increasing",
|
|
c1.CommandID, c2.CommandID)
|
|
}
|
|
}
|
|
|
|
// TestHub_AckIdempotent verifies that a second Ack for the same command is a no-op.
|
|
func TestHub_AckIdempotent(t *testing.T) {
|
|
hub, _, _ := newTestHub(t)
|
|
ctx := context.Background()
|
|
|
|
cmd := &agentv1.Command{CommandID: 99, Type: agentv1.CommandTypeUpsert,
|
|
Upsert: &agentv1.UpsertPayload{Credential: &agentv1.Credential{DpUUID: "x"}}}
|
|
if err := hub.Push(ctx, "node-ack-idem", cmd); err != nil {
|
|
t.Fatalf("Push: %v", err)
|
|
}
|
|
|
|
// First Ack: removes the command.
|
|
if err := hub.Ack(ctx, "node-ack-idem", 99); err != nil {
|
|
t.Fatalf("Ack 1: %v", err)
|
|
}
|
|
// Second Ack: must not return an error (ZREMRANGEBYSCORE with no match = 0 removed).
|
|
if err := hub.Ack(ctx, "node-ack-idem", 99); err != nil {
|
|
t.Errorf("Ack 2 (idempotent): unexpected error: %v", err)
|
|
}
|
|
|
|
// Queue should be empty.
|
|
got, _ := hub.Replay(ctx, "node-ack-idem", 0)
|
|
if len(got) != 0 {
|
|
t.Errorf("after double Ack, queue has %d commands, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
// ─── Local delivery ───────────────────────────────────────────────────────────
|
|
|
|
// TestHub_LocalDelivery verifies that a Push reaches a registered subscriber's
|
|
// channel when the node is connected to the same hub instance.
|
|
func TestHub_LocalDelivery(t *testing.T) {
|
|
hub, _, _ := newTestHub(t)
|
|
ctx := context.Background()
|
|
const nodeUUID = "node-local-delivery"
|
|
|
|
ch, done := hub.Register(nodeUUID)
|
|
defer done()
|
|
|
|
cmd := &agentv1.Command{
|
|
Type: agentv1.CommandTypeUpsert,
|
|
Upsert: &agentv1.UpsertPayload{Credential: &agentv1.Credential{DpUUID: "dp-local"}},
|
|
}
|
|
if err := hub.Push(ctx, nodeUUID, cmd); err != nil {
|
|
t.Fatalf("Push: %v", err)
|
|
}
|
|
|
|
select {
|
|
case received := <-ch:
|
|
if received.Upsert.Credential.DpUUID != "dp-local" {
|
|
t.Errorf("got dp_uuid=%q, want dp-local", received.Upsert.Credential.DpUUID)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timeout: command not delivered to local subscriber")
|
|
}
|
|
}
|
|
|
|
// TestHub_RegisterUnregister verifies that after done() is called, the node no
|
|
// longer receives commands on the old channel.
|
|
func TestHub_RegisterUnregister(t *testing.T) {
|
|
hub, _, _ := newTestHub(t)
|
|
ctx := context.Background()
|
|
const nodeUUID = "node-unreg"
|
|
|
|
ch, done := hub.Register(nodeUUID)
|
|
done() // immediately unregister
|
|
|
|
cmd := &agentv1.Command{
|
|
CommandID: 42,
|
|
Type: agentv1.CommandTypeLifecycle,
|
|
Lifecycle: &agentv1.LifecyclePayload{Action: agentv1.LifecycleActionDrain},
|
|
}
|
|
if err := hub.Push(ctx, nodeUUID, cmd); err != nil {
|
|
t.Fatalf("Push: %v", err)
|
|
}
|
|
|
|
select {
|
|
case m := <-ch:
|
|
// The channel might receive the command if it was buffered before unregister.
|
|
// That is acceptable (it was still in the buffer at send time).
|
|
// What must NOT happen is a panic or deadlock.
|
|
_ = m
|
|
case <-time.After(100 * time.Millisecond):
|
|
// Expected: no delivery since we unregistered.
|
|
}
|
|
}
|
|
|
|
// ─── Replay after reconnect ───────────────────────────────────────────────────
|
|
|
|
// TestHub_ReconnectResume verifies the "only resume unacked commands" invariant:
|
|
// - Push commands 1, 2, 3
|
|
// - Ack 1 and 2
|
|
// - Reconnect with last_command_id = 3 (agent already has 3)
|
|
// - Push command 4
|
|
// - Replay from 3 → only command 4 delivered
|
|
func TestHub_ReconnectResume(t *testing.T) {
|
|
hub, _, _ := newTestHub(t)
|
|
ctx := context.Background()
|
|
const nodeUUID = "node-reconnect"
|
|
|
|
push := func(id int64) {
|
|
c := &agentv1.Command{
|
|
CommandID: id,
|
|
Type: agentv1.CommandTypeLifecycle,
|
|
Lifecycle: &agentv1.LifecyclePayload{Action: agentv1.LifecycleActionDrain},
|
|
}
|
|
if err := hub.Push(ctx, nodeUUID, c); err != nil {
|
|
t.Fatalf("Push %d: %v", id, err)
|
|
}
|
|
}
|
|
|
|
push(1)
|
|
push(2)
|
|
push(3)
|
|
|
|
// Ack 1 and 2 (simulating a previous session that processed them).
|
|
for _, id := range []int64{1, 2} {
|
|
if err := hub.Ack(ctx, nodeUUID, id); err != nil {
|
|
t.Fatalf("Ack %d: %v", id, err)
|
|
}
|
|
}
|
|
// Command 3 was delivered but not acked (stream dropped before ack).
|
|
|
|
// Push command 4 (arrived while node was offline).
|
|
push(4)
|
|
|
|
// Reconnect: agent sends last_command_id = 3 (highest it processed).
|
|
// Should receive command 3 (in queue, not acked) and command 4.
|
|
got, err := hub.Replay(ctx, nodeUUID, 3)
|
|
if err != nil {
|
|
t.Fatalf("Replay: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("Replay from 3: got %d commands, want 1 (only cmd 4)", len(got))
|
|
}
|
|
if got[0].CommandID != 4 {
|
|
t.Errorf("Replay from 3: got command_id=%d, want 4", got[0].CommandID)
|
|
}
|
|
|
|
// Reconnect with last_command_id = 2 (simulating agent that lost cmd 3):
|
|
// should receive cmd 3 and 4.
|
|
got, err = hub.Replay(ctx, nodeUUID, 2)
|
|
if err != nil {
|
|
t.Fatalf("Replay: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("Replay from 2: got %d commands, want 2 (cmds 3, 4)", len(got))
|
|
}
|
|
if got[0].CommandID != 3 || got[1].CommandID != 4 {
|
|
t.Errorf("Replay from 2: ids = [%d, %d], want [3, 4]",
|
|
got[0].CommandID, got[1].CommandID)
|
|
}
|
|
}
|
|
|
|
// ─── Cross-instance pub/sub ───────────────────────────────────────────────────
|
|
|
|
// TestHub_CrossInstance starts two Hub instances connected to the same Redis,
|
|
// subscribes a node on hub1, pushes a command via hub2, and verifies delivery.
|
|
// This is the key validation that cross-instance command routing works.
|
|
func TestHub_CrossInstance(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
|
|
newHub := func() *nodes.Hub {
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
t.Cleanup(func() { rdb.Close() })
|
|
return nodes.NewHub(rdb)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
hub1 := newHub()
|
|
hub2 := newHub()
|
|
hub1.Start(ctx)
|
|
hub2.Start(ctx)
|
|
|
|
const nodeUUID = "node-cross-instance"
|
|
ch, done := hub1.Register(nodeUUID)
|
|
defer done()
|
|
|
|
// Give the pub/sub goroutines a moment to subscribe to the Redis channel.
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
cmd := &agentv1.Command{
|
|
Type: agentv1.CommandTypeUpsert,
|
|
Upsert: &agentv1.UpsertPayload{Credential: &agentv1.Credential{DpUUID: "dp-cross"}},
|
|
}
|
|
if err := hub2.Push(ctx, nodeUUID, cmd); err != nil {
|
|
t.Fatalf("hub2.Push: %v", err)
|
|
}
|
|
|
|
select {
|
|
case received := <-ch:
|
|
if received.Upsert.Credential.DpUUID != "dp-cross" {
|
|
t.Errorf("got dp_uuid=%q, want dp-cross", received.Upsert.Credential.DpUUID)
|
|
}
|
|
t.Logf("cross-instance delivery: command_id=%d", received.CommandID)
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("timeout: cross-instance command not delivered")
|
|
}
|
|
}
|
|
|
|
// TestHub_CrossInstance_QueuePersisted verifies that a cross-instance push
|
|
// persists the command in Redis so Replay works even without a live subscriber.
|
|
func TestHub_CrossInstance_QueuePersisted(t *testing.T) {
|
|
mr := miniredis.RunT(t)
|
|
newHub := func() *nodes.Hub {
|
|
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
|
t.Cleanup(func() { rdb.Close() })
|
|
return nodes.NewHub(rdb)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
hub1 := newHub() // not started (no pubsub), no local subscriber
|
|
hub2 := newHub()
|
|
|
|
cmd := &agentv1.Command{
|
|
Type: agentv1.CommandTypeRevoke,
|
|
Revoke: &agentv1.RevokePayload{DpUUID: "dp-persist"},
|
|
}
|
|
if err := hub2.Push(ctx, "offline-node", cmd); err != nil {
|
|
t.Fatalf("hub2.Push: %v", err)
|
|
}
|
|
if cmd.CommandID == 0 {
|
|
t.Fatal("command_id was not assigned")
|
|
}
|
|
|
|
// hub1 replays from the persistent queue (simulating reconnect).
|
|
got, err := hub1.Replay(ctx, "offline-node", 0)
|
|
if err != nil {
|
|
t.Fatalf("hub1.Replay: %v", err)
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("got %d commands, want 1", len(got))
|
|
}
|
|
if got[0].Revoke.DpUUID != "dp-persist" {
|
|
t.Errorf("got dp_uuid=%q, want dp-persist", got[0].Revoke.DpUUID)
|
|
}
|
|
}
|
|
|
|
// TestHub_Broadcast delivers a command to multiple nodes.
|
|
func TestHub_Broadcast(t *testing.T) {
|
|
hub, _, _ := newTestHub(t)
|
|
ctx := context.Background()
|
|
|
|
const n = 3
|
|
nodes2 := make([]string, n)
|
|
channels := make([]<-chan *agentv1.Command, n)
|
|
dones := make([]func(), n)
|
|
|
|
for i := 0; i < n; i++ {
|
|
uuid := "node-bcast-" + string(rune('A'+i))
|
|
nodes2[i] = uuid
|
|
channels[i], dones[i] = hub.Register(uuid)
|
|
defer dones[i]()
|
|
}
|
|
|
|
cmd := &agentv1.Command{
|
|
Type: agentv1.CommandTypeLifecycle,
|
|
Lifecycle: &agentv1.LifecyclePayload{Action: agentv1.LifecycleActionDrain},
|
|
}
|
|
if err := hub.Broadcast(ctx, nodes2, cmd); err != nil {
|
|
t.Fatalf("Broadcast: %v", err)
|
|
}
|
|
|
|
for i, ch := range channels {
|
|
select {
|
|
case received := <-ch:
|
|
if received.Type != agentv1.CommandTypeLifecycle {
|
|
t.Errorf("node %d: wrong type %v", i, received.Type)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Errorf("node %d: timeout waiting for broadcast", i)
|
|
}
|
|
}
|
|
}
|