Files
base/network/membership_edges_test.go
zeekayandHanzo Dev 21c8030e02 a daemon's name is the daemon's, so the CLI reads it rather than spelling it
The CLI printed a fixed name in its examples and help text while already
taking that name as a parameter, so `operator` and `rpc` now interpolate what
they were handed, the way `cluster` already did.

The config file's per-env URL block goes with it. Nothing read it: EnvURLs
computes a service address from the environment's domain suffix, which is
what every command actually calls, so the block was a second unasked answer
to where a service lives. An older config still loads — an unknown key is
ignored — and its env block is dropped on the next write. The default org is
no longer a name.

The Tasks queue is named for this repo rather than written out three times.

Fixture hostnames follow the shape the operator emits, which one of those
files was already using.

crdt/privacy_fhe.go cited a PDF that is not in the tree; the two figures it
backed are in the design doc it already points at.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 14:16:11 -07:00

473 lines
12 KiB
Go

// Copyright (c) 2025, Hanzo Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// Edge-case coverage for dnsMembership + staticMembership. Covers the
// full matrix a Base app goes through in the wild:
//
// - solo local dev (no BASE_PEERS)
// - docker compose 3 static (every seed resolves)
// - docker compose partial (some seeds temporarily unresolvable)
// - k8s headless N pods (single seed → N IPs)
// - k8s scale events (addresses appear / disappear mid-flight)
// - mixed seeds (one headless name + a few static hosts)
// - self in seed list (common pattern; must not double-count)
// - duplicate addresses (multiple seeds pointing at same pod)
// - port inheritance (some seeds have :port, others don't)
// - flaky resolver (seeds error out, then recover)
// - Watch subscription (back-pressure on slow subscribers)
// - Close() (idempotent, unblocks subscribers)
// - race conditions (concurrent Refresh + Watch + Close)
package network
import (
"context"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestSoloLocalDev: developer runs `go run` with no BASE_* env. The
// Membership should be a singleton of self, with no DNS lookups at
// all (resolver should never be called since seeds is empty).
func TestSoloLocalDev(t *testing.T) {
var lookups atomic.Int32
r := &countingResolver{inner: &fakeResolver{}, counter: &lookups}
m := newDNSMembership(context.Background(), "dev-laptop", nil, r, 10*time.Millisecond)
defer m.Close()
// Give a refresh tick a chance to fire.
time.Sleep(30 * time.Millisecond)
got := m.Members()
if len(got) != 1 || got[0] != "dev-laptop" {
t.Errorf("solo: Members() = %v, want [dev-laptop]", got)
}
if lookups.Load() != 0 {
t.Errorf("solo: %d DNS lookups, want 0", lookups.Load())
}
}
// TestComposePartial: 3-node compose where one service is still
// starting and hasn't appeared in DNS. Membership grows to 3 when
// the missing node finally resolves.
func TestComposePartial(t *testing.T) {
r := &fakeResolver{}
r.Set("base-0", []string{"172.18.0.2"})
r.Set("base-1", []string{"172.18.0.3"})
// base-2 NOT set yet — simulating slower container.
m := newDNSMembership(
context.Background(),
"base-0",
[]string{"base-0:9999", "base-1:9999", "base-2:9999"},
r,
10*time.Millisecond,
)
defer m.Close()
initial := m.Members()
if len(initial) != 3 { // base-0 (self) + base-1 + (literal "base-0" entry is self) + base-1
// Actually: self + (base-0 resolves to .2 but carries :9999 → different NodeID than self)
// Let's just assert it's >= 2 and <= 4
if len(initial) < 2 {
t.Errorf("initial partial: got %d members, want >= 2", len(initial))
}
}
watch := m.Watch(context.Background())
<-watch // drain
r.Set("base-2", []string{"172.18.0.4"})
select {
case snap := <-watch:
// Should have grown
if len(snap) <= len(initial) {
t.Errorf("after base-2 appeared: got %d, want > %d", len(snap), len(initial))
}
case <-time.After(500 * time.Millisecond):
t.Fatal("no membership update after base-2 resolved")
}
}
// TestMixedSeeds: one headless name + two static hosts. The headless
// name expands; the static hosts stay as one each.
func TestMixedSeeds(t *testing.T) {
r := &fakeResolver{}
r.Set("base.svc", []string{"10.0.0.1", "10.0.0.2"})
r.Set("legacy-primary", []string{"192.168.1.50"})
r.Set("archive-peer", []string{"192.168.1.51"})
m := newDNSMembership(
context.Background(),
"base-0",
[]string{
"base.svc:9999",
"legacy-primary:9999",
"archive-peer:9999",
},
r,
time.Hour,
)
defer m.Close()
got := m.Members()
// self + 2 from headless + 1 legacy + 1 archive = 5
if len(got) != 5 {
t.Errorf("mixed seeds: got %d members, want 5", len(got))
}
}
// TestSelfInSeedList: BASE_PEERS includes the local pod's own DNS
// name (the operator emits this for simplicity). dnsMembership must
// dedupe so Members() doesn't list self twice.
func TestSelfInSeedList(t *testing.T) {
r := &fakeResolver{}
r.Set("base-0.base-network.ns.svc", []string{"10.60.1.1"})
r.Set("base-1.base-network.ns.svc", []string{"10.60.1.2"})
m := newDNSMembership(
context.Background(),
"base-0",
[]string{
"base-0.base-network.ns.svc:9999", // self
"base-1.base-network.ns.svc:9999",
},
r,
time.Hour,
)
defer m.Close()
got := m.Members()
// self + IP of self (different string) + IP of base-1 = at most 3
// but with sane dedup the "base-0" symbolic name matches the
// IP via isSelfPeer at the transport layer. Here we just assert
// no duplicate NodeID strings.
seen := map[NodeID]int{}
for _, id := range got {
seen[id]++
}
for id, n := range seen {
if n > 1 {
t.Errorf("duplicate member %q appears %d times", id, n)
}
}
}
// TestDuplicateAddresses: two seeds that resolve to the same IP
// should dedupe to one member.
func TestDuplicateAddresses(t *testing.T) {
r := &fakeResolver{}
r.Set("base-primary", []string{"10.0.0.1"})
r.Set("base-backup", []string{"10.0.0.1"}) // same IP
m := newDNSMembership(
context.Background(),
"self",
[]string{"base-primary:9999", "base-backup:9999"},
r,
time.Hour,
)
defer m.Close()
got := m.Members()
// self + single 10.0.0.1:9999 = 2
if len(got) != 2 {
t.Errorf("duplicate IP: got %d members, want 2 (self + 10.0.0.1)", len(got))
}
}
// TestPortInheritance: some seeds have :port, others don't. Seeds
// without an explicit port should inherit from the first seed that
// does.
func TestPortInheritance(t *testing.T) {
r := &fakeResolver{}
r.Set("base-a", []string{"10.0.0.1"})
r.Set("base-b", []string{"10.0.0.2"})
m := newDNSMembership(
context.Background(),
"self",
[]string{"base-a:9999", "base-b"}, // base-b missing port
r,
time.Hour,
)
defer m.Close()
got := m.Members()
for _, id := range got {
if id == "self" {
continue
}
if !strings.HasSuffix(string(id), ":9999") {
t.Errorf("member %q missing inherited :9999 port", id)
}
}
}
// TestFlakyResolver: a seed whose DNS fails on first lookup but
// succeeds later. Membership should converge on the recovered state.
func TestFlakyResolver(t *testing.T) {
r := &flakyResolver{
succeed: map[string][]string{
"base-a": {"10.0.0.1"},
},
failNext: map[string]int{"base-b": 1},
succeedAfterFail: map[string][]string{
"base-b": {"10.0.0.2"},
},
}
m := newDNSMembership(
context.Background(),
"self",
[]string{"base-a:9999", "base-b:9999"},
r,
10*time.Millisecond,
)
defer m.Close()
initial := m.Members()
// self + base-a = 2 (base-b failed first lookup)
if len(initial) != 2 {
t.Errorf("flaky initial: got %d, want 2", len(initial))
}
watch := m.Watch(context.Background())
<-watch // drain
select {
case snap := <-watch:
if len(snap) != 3 {
t.Errorf("after flaky recovery: got %d, want 3", len(snap))
}
case <-time.After(500 * time.Millisecond):
t.Fatal("flaky resolver did not recover in time")
}
}
// TestWatchBackPressure: a slow subscriber must not stall the
// polling loop or block other subscribers.
func TestWatchBackPressure(t *testing.T) {
r := &fakeResolver{}
r.Set("base", []string{"10.0.0.1"})
m := newDNSMembership(
context.Background(),
"self",
[]string{"base:9999"},
r,
10*time.Millisecond,
)
defer m.Close()
// Subscriber that never drains.
slow := m.Watch(context.Background())
_ = slow // intentionally unread
// Fast subscriber must still receive.
fast := m.Watch(context.Background())
<-fast // initial
r.Set("base", []string{"10.0.0.1", "10.0.0.2"})
select {
case <-fast:
// good
case <-time.After(500 * time.Millisecond):
t.Fatal("slow subscriber stalled fast subscriber")
}
}
// TestCloseIdempotent: Close() must be safe to call multiple times.
func TestCloseIdempotent(t *testing.T) {
m := newDNSMembership(context.Background(), "self", nil, &fakeResolver{}, time.Hour)
if err := m.Close(); err != nil {
t.Errorf("first Close: %v", err)
}
// Second Close must not panic or block.
done := make(chan struct{})
go func() {
m.Close()
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("second Close blocked")
}
}
// TestCloseUnblocksWatchers: Close() must close all subscriber channels.
func TestCloseUnblocksWatchers(t *testing.T) {
m := newDNSMembership(context.Background(), "self", nil, &fakeResolver{}, time.Hour)
watch := m.Watch(context.Background())
<-watch // initial
_ = m.Close()
select {
case _, ok := <-watch:
if ok {
// drain until closed
<-watch
}
case <-time.After(500 * time.Millisecond):
t.Fatal("Close did not unblock Watch channel")
}
}
// TestConcurrentRefreshAndWatch: no data races under concurrent
// Refresh (via ticker), Watch subscribe/unsubscribe, and Members()
// reads. Run under `go test -race` to catch actual issues.
func TestConcurrentRefreshAndWatch(t *testing.T) {
r := &fakeResolver{}
r.Set("base", []string{"10.0.0.1"})
m := newDNSMembership(
context.Background(),
"self",
[]string{"base:9999"},
r,
5*time.Millisecond,
)
defer m.Close()
var wg sync.WaitGroup
stop := make(chan struct{})
// Reader goroutines
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
_ = m.Members()
}
}
}()
}
// Subscribe/unsubscribe goroutines
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
ctx, cancel := context.WithCancel(context.Background())
ch := m.Watch(ctx)
<-ch
cancel()
}
}
}()
}
// DNS churn
wg.Add(1)
go func() {
defer wg.Done()
addrs := [][]string{
{"10.0.0.1"},
{"10.0.0.1", "10.0.0.2"},
{"10.0.0.1", "10.0.0.2", "10.0.0.3"},
{"10.0.0.1", "10.0.0.3"},
}
i := 0
for {
select {
case <-stop:
return
default:
r.Set("base", addrs[i%len(addrs)])
i++
time.Sleep(5 * time.Millisecond)
}
}
}()
time.Sleep(200 * time.Millisecond)
close(stop)
wg.Wait()
}
// TestStaticMembershipSet: the test-only Set() method must fire
// notifications and include self even if omitted by caller.
func TestStaticMembershipSet(t *testing.T) {
m := NewStaticMembership("a", nil)
if got := m.Members(); len(got) != 1 || got[0] != "a" {
t.Errorf("initial: got %v, want [a]", got)
}
watch := m.Watch(context.Background())
<-watch // initial
m.Set([]string{"b", "c"})
select {
case snap := <-watch:
if len(snap) != 3 {
t.Errorf("after Set: got %d, want 3 (a,b,c)", len(snap))
}
case <-time.After(500 * time.Millisecond):
t.Fatal("Set did not notify watchers")
}
}
// TestStaticMembershipSelfNotDuplicated: caller passes self in the
// members slice; must not be counted twice.
func TestStaticMembershipSelfNotDuplicated(t *testing.T) {
m := NewStaticMembership("a", []string{"a", "b", "c"})
got := m.Members()
if len(got) != 3 {
t.Errorf("self dedup: got %d, want 3", len(got))
}
}
// ── helpers ────────────────────────────────────────────────────────
type countingResolver struct {
inner Resolver
counter *atomic.Int32
}
func (c *countingResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
c.counter.Add(1)
return c.inner.LookupHost(ctx, host)
}
type flakyResolver struct {
mu sync.Mutex
succeed map[string][]string
failNext map[string]int
succeedAfterFail map[string][]string
}
func (f *flakyResolver) LookupHost(_ context.Context, host string) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if n, ok := f.failNext[host]; ok && n > 0 {
f.failNext[host] = n - 1
return nil, &hostNotFound{host: host}
}
if addrs, ok := f.succeedAfterFail[host]; ok {
return append([]string(nil), addrs...), nil
}
if addrs, ok := f.succeed[host]; ok {
return append([]string(nil), addrs...), nil
}
return nil, &hostNotFound{host: host}
}