228 lines
8.1 KiB
Go
228 lines
8.1 KiB
Go
|
|
package agent
|
||
|
|
|
||
|
|
import (
|
||
|
|
"net"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
// allReady is a convenience isReady that says yes to every pod.
|
||
|
|
func allReady(_, _ string) bool { return true }
|
||
|
|
|
||
|
|
// readyOnly returns an isReady that only says yes to the named pods.
|
||
|
|
func readyOnly(want ...string) func(string, string) bool {
|
||
|
|
set := map[string]struct{}{}
|
||
|
|
for _, n := range want {
|
||
|
|
set[n] = struct{}{}
|
||
|
|
}
|
||
|
|
return func(_, name string) bool {
|
||
|
|
_, ok := set[name]
|
||
|
|
return ok
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestResolveAnycastTargets_OnePodOneAnycast(t *testing.T) {
|
||
|
|
allocs := []Allocation{{
|
||
|
|
ContainerID: "c1", Namespace: "ns", PodName: "pod-a",
|
||
|
|
State: StateCommitted,
|
||
|
|
IP6: "2001:db8::1",
|
||
|
|
Anycast: []string{"2001:db8:a::1"},
|
||
|
|
}}
|
||
|
|
out := resolveAnycastTargets(allocs, allReady, nil)
|
||
|
|
if len(out) != 1 {
|
||
|
|
t.Fatalf("expected 1 anycast IP, got %d", len(out))
|
||
|
|
}
|
||
|
|
tgt, ok := out["2001:db8:a::1"]
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("missing target")
|
||
|
|
}
|
||
|
|
if len(tgt.nexthops) != 1 {
|
||
|
|
t.Fatalf("expected 1 nexthop, got %d", len(tgt.nexthops))
|
||
|
|
}
|
||
|
|
if !tgt.nexthops[0].via.Equal(net.ParseIP("2001:db8::1")) {
|
||
|
|
t.Fatalf("nexthop via wrong: %v", tgt.nexthops[0].via)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Two pods on the same node binding the same anycast IP must produce a
|
||
|
|
// SINGLE target with TWO nexthops. The previous behaviour (overwriting)
|
||
|
|
// was the bug this whole change exists to fix.
|
||
|
|
func TestResolveAnycastTargets_TwoPodsSameAnycast_MultiNexthop(t *testing.T) {
|
||
|
|
allocs := []Allocation{
|
||
|
|
{ContainerID: "c1", Namespace: "ns", PodName: "pod-a",
|
||
|
|
State: StateCommitted, IP6: "2001:db8::2",
|
||
|
|
Anycast: []string{"2001:db8:a::1"}},
|
||
|
|
{ContainerID: "c2", Namespace: "ns", PodName: "pod-b",
|
||
|
|
State: StateCommitted, IP6: "2001:db8::1",
|
||
|
|
Anycast: []string{"2001:db8:a::1"}},
|
||
|
|
}
|
||
|
|
out := resolveAnycastTargets(allocs, allReady, nil)
|
||
|
|
tgt := out["2001:db8:a::1"]
|
||
|
|
if len(tgt.nexthops) != 2 {
|
||
|
|
t.Fatalf("expected 2 nexthops, got %d", len(tgt.nexthops))
|
||
|
|
}
|
||
|
|
// Order should be sorted by canonical(via) — ::1 before ::2.
|
||
|
|
if !tgt.nexthops[0].via.Equal(net.ParseIP("2001:db8::1")) {
|
||
|
|
t.Fatalf("nexthops not sorted by via; got %v first", tgt.nexthops[0].via)
|
||
|
|
}
|
||
|
|
if !tgt.nexthops[1].via.Equal(net.ParseIP("2001:db8::2")) {
|
||
|
|
t.Fatalf("nexthops not sorted by via; got %v second", tgt.nexthops[1].via)
|
||
|
|
}
|
||
|
|
// HostIface differs per pod (different containerID → different FNV).
|
||
|
|
if tgt.nexthops[0].hostIface == tgt.nexthops[1].hostIface {
|
||
|
|
t.Fatalf("expected distinct hostIfaces, both %q", tgt.nexthops[0].hostIface)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// When one of the contributing pods goes NotReady, only the remaining
|
||
|
|
// Ready pod should appear in the target's nexthop set.
|
||
|
|
func TestResolveAnycastTargets_NotReadyDropped(t *testing.T) {
|
||
|
|
allocs := []Allocation{
|
||
|
|
{ContainerID: "c1", Namespace: "ns", PodName: "pod-a",
|
||
|
|
State: StateCommitted, IP6: "2001:db8::1",
|
||
|
|
Anycast: []string{"2001:db8:a::1"}},
|
||
|
|
{ContainerID: "c2", Namespace: "ns", PodName: "pod-b",
|
||
|
|
State: StateCommitted, IP6: "2001:db8::2",
|
||
|
|
Anycast: []string{"2001:db8:a::1"}},
|
||
|
|
}
|
||
|
|
out := resolveAnycastTargets(allocs, readyOnly("pod-a"), nil)
|
||
|
|
tgt := out["2001:db8:a::1"]
|
||
|
|
if len(tgt.nexthops) != 1 {
|
||
|
|
t.Fatalf("expected 1 nexthop after NotReady drop, got %d", len(tgt.nexthops))
|
||
|
|
}
|
||
|
|
if !tgt.nexthops[0].via.Equal(net.ParseIP("2001:db8::1")) {
|
||
|
|
t.Fatalf("wrong surviving nexthop: %v", tgt.nexthops[0].via)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Pods that haven't reached Ready are excluded entirely from the target
|
||
|
|
// set. If no pod is Ready for an anycast IP, that IP is absent from the
|
||
|
|
// output (BIRD will withdraw from BGP, kernel route will be removed).
|
||
|
|
func TestResolveAnycastTargets_NoReadyPodsOmitsIP(t *testing.T) {
|
||
|
|
allocs := []Allocation{
|
||
|
|
{ContainerID: "c1", Namespace: "ns", PodName: "pod-a",
|
||
|
|
State: StateCommitted, IP6: "2001:db8::1",
|
||
|
|
Anycast: []string{"2001:db8:a::1"}},
|
||
|
|
}
|
||
|
|
out := resolveAnycastTargets(allocs, readyOnly( /* none */ ), nil)
|
||
|
|
if _, ok := out["2001:db8:a::1"]; ok {
|
||
|
|
t.Fatalf("anycast should be absent when no pod ready")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Pending allocations (CNI ADD partway through) are skipped even if the
|
||
|
|
// pod is Ready — we don't program kernel routes for partial setups.
|
||
|
|
func TestResolveAnycastTargets_PendingSkipped(t *testing.T) {
|
||
|
|
allocs := []Allocation{
|
||
|
|
{ContainerID: "c1", Namespace: "ns", PodName: "pod-a",
|
||
|
|
State: StatePending, IP6: "2001:db8::1",
|
||
|
|
Anycast: []string{"2001:db8:a::1"}},
|
||
|
|
}
|
||
|
|
out := resolveAnycastTargets(allocs, allReady, nil)
|
||
|
|
if len(out) != 0 {
|
||
|
|
t.Fatalf("pending allocations must be skipped")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Mixed v6+v4 anycast on the same pod produces two separate target
|
||
|
|
// entries, one per family, each anchored on the matching unicast IP.
|
||
|
|
func TestResolveAnycastTargets_MixedFamilies(t *testing.T) {
|
||
|
|
allocs := []Allocation{{
|
||
|
|
ContainerID: "c1", Namespace: "ns", PodName: "pod-a",
|
||
|
|
State: StateCommitted,
|
||
|
|
IP6: "2001:db8::1",
|
||
|
|
IP4: "10.0.0.1",
|
||
|
|
Anycast: []string{"2001:db8:a::1", "10.255.0.1"},
|
||
|
|
}}
|
||
|
|
out := resolveAnycastTargets(allocs, allReady, nil)
|
||
|
|
if !out["2001:db8:a::1"].nexthops[0].via.Equal(net.ParseIP("2001:db8::1")) {
|
||
|
|
t.Fatalf("v6 anycast should resolve via v6 unicast")
|
||
|
|
}
|
||
|
|
if !out["10.255.0.1"].nexthops[0].via.Equal(net.ParseIP("10.0.0.1").To4()) {
|
||
|
|
t.Fatalf("v4 anycast should resolve via v4 unicast")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// An anycast whose family has no matching unicast on the pod is skipped
|
||
|
|
// with a warning. Other anycast IPs on the same pod are unaffected.
|
||
|
|
func TestResolveAnycastTargets_FamilyMismatchWarns(t *testing.T) {
|
||
|
|
allocs := []Allocation{{
|
||
|
|
ContainerID: "c1", Namespace: "ns", PodName: "pod-a",
|
||
|
|
State: StateCommitted,
|
||
|
|
IP6: "2001:db8::1", // v6 only
|
||
|
|
Anycast: []string{"2001:db8:a::1", "10.255.0.1"},
|
||
|
|
}}
|
||
|
|
var warns []string
|
||
|
|
out := resolveAnycastTargets(allocs, allReady, func(s string) { warns = append(warns, s) })
|
||
|
|
if _, has := out["2001:db8:a::1"]; !has {
|
||
|
|
t.Fatalf("v6 anycast should have been programmed")
|
||
|
|
}
|
||
|
|
if _, has := out["10.255.0.1"]; has {
|
||
|
|
t.Fatalf("v4 anycast should have been skipped")
|
||
|
|
}
|
||
|
|
if len(warns) != 1 {
|
||
|
|
t.Fatalf("expected 1 warning, got %d: %v", len(warns), warns)
|
||
|
|
}
|
||
|
|
if !strings.Contains(warns[0], "10.255.0.1") {
|
||
|
|
t.Fatalf("warning should mention skipped IP: %q", warns[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Determinism: the same input must produce nexthops in the same order.
|
||
|
|
func TestResolveAnycastTargets_Determinism(t *testing.T) {
|
||
|
|
allocs := []Allocation{
|
||
|
|
{ContainerID: "z-late", Namespace: "ns", PodName: "z",
|
||
|
|
State: StateCommitted, IP6: "2001:db8::5",
|
||
|
|
Anycast: []string{"2001:db8:a::1"}},
|
||
|
|
{ContainerID: "a-early", Namespace: "ns", PodName: "a",
|
||
|
|
State: StateCommitted, IP6: "2001:db8::3",
|
||
|
|
Anycast: []string{"2001:db8:a::1"}},
|
||
|
|
{ContainerID: "m-mid", Namespace: "ns", PodName: "m",
|
||
|
|
State: StateCommitted, IP6: "2001:db8::4",
|
||
|
|
Anycast: []string{"2001:db8:a::1"}},
|
||
|
|
}
|
||
|
|
a := resolveAnycastTargets(allocs, allReady, nil)
|
||
|
|
b := resolveAnycastTargets(allocs, allReady, nil)
|
||
|
|
if !a["2001:db8:a::1"].equal(b["2001:db8:a::1"]) {
|
||
|
|
t.Fatalf("same input produced unequal targets")
|
||
|
|
}
|
||
|
|
// Sorted by canonical(via): ::3, ::4, ::5
|
||
|
|
via := a["2001:db8:a::1"].nexthops
|
||
|
|
if !via[0].via.Equal(net.ParseIP("2001:db8::3")) ||
|
||
|
|
!via[1].via.Equal(net.ParseIP("2001:db8::4")) ||
|
||
|
|
!via[2].via.Equal(net.ParseIP("2001:db8::5")) {
|
||
|
|
t.Fatalf("nexthops not stably sorted: %v %v %v", via[0].via, via[1].via, via[2].via)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// equal()'s contract — different orderings are still considered equal
|
||
|
|
// AS LONG AS both sides have been canonicalised by resolveAnycastTargets.
|
||
|
|
// Across-call comparisons of resolver outputs must always match for the
|
||
|
|
// same logical input.
|
||
|
|
func TestAnycastTarget_Equal(t *testing.T) {
|
||
|
|
a := anycastTarget{nexthops: []anycastNexthop{
|
||
|
|
{hostIface: "f1", via: net.ParseIP("2001:db8::1")},
|
||
|
|
{hostIface: "f2", via: net.ParseIP("2001:db8::2")},
|
||
|
|
}}
|
||
|
|
b := anycastTarget{nexthops: []anycastNexthop{
|
||
|
|
{hostIface: "f1", via: net.ParseIP("2001:db8::1")},
|
||
|
|
{hostIface: "f2", via: net.ParseIP("2001:db8::2")},
|
||
|
|
}}
|
||
|
|
if !a.equal(b) {
|
||
|
|
t.Fatalf("equal targets reported unequal")
|
||
|
|
}
|
||
|
|
c := anycastTarget{nexthops: []anycastNexthop{
|
||
|
|
{hostIface: "f1", via: net.ParseIP("2001:db8::1")},
|
||
|
|
}}
|
||
|
|
if a.equal(c) {
|
||
|
|
t.Fatalf("targets with different lengths reported equal")
|
||
|
|
}
|
||
|
|
d := anycastTarget{nexthops: []anycastNexthop{
|
||
|
|
{hostIface: "f1", via: net.ParseIP("2001:db8::1")},
|
||
|
|
{hostIface: "f2", via: net.ParseIP("2001:db8::3")}, // diff IP
|
||
|
|
}}
|
||
|
|
if a.equal(d) {
|
||
|
|
t.Fatalf("targets with different vias reported equal")
|
||
|
|
}
|
||
|
|
}
|