|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "net" |
| 5 | + "testing" |
| 6 | +) |
| 7 | + |
| 8 | +func TestBroadcastAddr(t *testing.T) { |
| 9 | + tests := []struct { |
| 10 | + name string |
| 11 | + cidr string |
| 12 | + broadcast string |
| 13 | + }{ |
| 14 | + {"slash 24", "10.0.1.0/24", "10.0.1.255"}, |
| 15 | + {"slash 21", "10.0.64.0/21", "10.0.71.255"}, |
| 16 | + {"slash 16", "172.16.0.0/16", "172.16.255.255"}, |
| 17 | + {"slash 28", "192.168.1.0/28", "192.168.1.15"}, |
| 18 | + {"slash 30", "10.0.0.0/30", "10.0.0.3"}, |
| 19 | + {"slash 8", "10.0.0.0/8", "10.255.255.255"}, |
| 20 | + } |
| 21 | + |
| 22 | + for _, tt := range tests { |
| 23 | + t.Run(tt.name, func(t *testing.T) { |
| 24 | + _, ipNet, err := net.ParseCIDR(tt.cidr) |
| 25 | + if err != nil { |
| 26 | + t.Fatalf("bad CIDR: %v", err) |
| 27 | + } |
| 28 | + got := broadcastAddr(ipNet).String() |
| 29 | + if got != tt.broadcast { |
| 30 | + t.Errorf("broadcastAddr(%s) = %s, want %s", tt.cidr, got, tt.broadcast) |
| 31 | + } |
| 32 | + }) |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +func TestAddToIP(t *testing.T) { |
| 37 | + tests := []struct { |
| 38 | + name string |
| 39 | + ip string |
| 40 | + delta int |
| 41 | + want string |
| 42 | + }{ |
| 43 | + {"minus 1", "10.0.71.255", -1, "10.0.71.254"}, |
| 44 | + {"minus 2", "10.0.71.255", -2, "10.0.71.253"}, |
| 45 | + {"minus 255", "10.0.71.255", -255, "10.0.71.0"}, |
| 46 | + {"byte boundary", "10.0.71.255", -256, "10.0.70.255"}, |
| 47 | + {"byte boundary minus 1", "10.0.71.255", -257, "10.0.70.254"}, |
| 48 | + {"two byte boundary", "10.0.0.0", -1, "9.255.255.255"}, |
| 49 | + {"plus 1", "10.0.0.0", 1, "10.0.0.1"}, |
| 50 | + {"zero delta", "10.0.0.1", 0, "10.0.0.1"}, |
| 51 | + {"large negative", "10.0.71.255", -512, "10.0.69.255"}, |
| 52 | + } |
| 53 | + |
| 54 | + for _, tt := range tests { |
| 55 | + t.Run(tt.name, func(t *testing.T) { |
| 56 | + ip := net.ParseIP(tt.ip).To4() |
| 57 | + got := addToIP(ip, tt.delta).String() |
| 58 | + if got != tt.want { |
| 59 | + t.Errorf("addToIP(%s, %d) = %s, want %s", tt.ip, tt.delta, got, tt.want) |
| 60 | + } |
| 61 | + }) |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +func mustParseCIDR(t *testing.T, cidr string) *net.IPNet { |
| 66 | + t.Helper() |
| 67 | + _, ipNet, err := net.ParseCIDR(cidr) |
| 68 | + if err != nil { |
| 69 | + t.Fatalf("bad CIDR: %v", err) |
| 70 | + } |
| 71 | + return ipNet |
| 72 | +} |
| 73 | + |
| 74 | +func ipsToStrings(ips []net.IP) []string { |
| 75 | + result := make([]string, len(ips)) |
| 76 | + for i, ip := range ips { |
| 77 | + result[i] = ip.String() |
| 78 | + } |
| 79 | + return result |
| 80 | +} |
| 81 | + |
| 82 | +func TestComputeIPCandidates(t *testing.T) { |
| 83 | + t.Run("plan example: /21, 4 peers, 2 containers", func(t *testing.T) { |
| 84 | + ipNet := mustParseCIDR(t, "10.0.64.0/21") |
| 85 | + peers := []string{"10.0.0.4", "10.0.0.1", "10.0.0.3", "10.0.0.2"} |
| 86 | + |
| 87 | + // After sorting: [10.0.0.1, 10.0.0.2, 10.0.0.3, 10.0.0.4] |
| 88 | + expectations := []struct { |
| 89 | + nodeAddr string |
| 90 | + want []string |
| 91 | + }{ |
| 92 | + {"10.0.0.1", []string{"10.0.71.254", "10.0.71.253"}}, |
| 93 | + {"10.0.0.2", []string{"10.0.71.252", "10.0.71.251"}}, |
| 94 | + {"10.0.0.3", []string{"10.0.71.250", "10.0.71.249"}}, |
| 95 | + {"10.0.0.4", []string{"10.0.71.248", "10.0.71.247"}}, |
| 96 | + } |
| 97 | + |
| 98 | + for _, e := range expectations { |
| 99 | + got := ipsToStrings(computeIPCandidates(ipNet, peers, e.nodeAddr, 2)) |
| 100 | + if len(got) != len(e.want) { |
| 101 | + t.Errorf("node %s: got %d IPs, want %d", e.nodeAddr, len(got), len(e.want)) |
| 102 | + continue |
| 103 | + } |
| 104 | + for i := range got { |
| 105 | + if got[i] != e.want[i] { |
| 106 | + t.Errorf("node %s IP[%d]: got %s, want %s", e.nodeAddr, i, got[i], e.want[i]) |
| 107 | + } |
| 108 | + } |
| 109 | + } |
| 110 | + }) |
| 111 | + |
| 112 | + t.Run("plan example: /24, 48 peers, 2 containers", func(t *testing.T) { |
| 113 | + ipNet := mustParseCIDR(t, "10.0.1.0/24") |
| 114 | + peers := make([]string, 48) |
| 115 | + for i := range peers { |
| 116 | + peers[i] = net.IPv4(192, 168, 0, byte(i+1)).String() |
| 117 | + } |
| 118 | + |
| 119 | + // Lexicographic sort: 192.168.0.1 is first (index 0), 192.168.0.9 is last (index 47) |
| 120 | + first := ipsToStrings(computeIPCandidates(ipNet, peers, "192.168.0.1", 2)) |
| 121 | + if first[0] != "10.0.1.254" || first[1] != "10.0.1.253" { |
| 122 | + t.Errorf("first peer: got %v, want [10.0.1.254 10.0.1.253]", first) |
| 123 | + } |
| 124 | + |
| 125 | + // 192.168.0.9 is at index 47 after lexicographic sort, startOffset = 1 + 47*2 = 95 |
| 126 | + last := ipsToStrings(computeIPCandidates(ipNet, peers, "192.168.0.9", 2)) |
| 127 | + if last[0] != "10.0.1.160" || last[1] != "10.0.1.159" { |
| 128 | + t.Errorf("last peer: got %v, want [10.0.1.160 10.0.1.159]", last) |
| 129 | + } |
| 130 | + }) |
| 131 | + |
| 132 | + t.Run("single node, single container", func(t *testing.T) { |
| 133 | + ipNet := mustParseCIDR(t, "10.0.1.0/24") |
| 134 | + got := ipsToStrings(computeIPCandidates(ipNet, []string{"10.0.0.1"}, "10.0.0.1", 1)) |
| 135 | + if len(got) != 1 || got[0] != "10.0.1.254" { |
| 136 | + t.Errorf("got %v, want [10.0.1.254]", got) |
| 137 | + } |
| 138 | + }) |
| 139 | + |
| 140 | + t.Run("single node, 5 containers", func(t *testing.T) { |
| 141 | + ipNet := mustParseCIDR(t, "10.0.1.0/24") |
| 142 | + got := ipsToStrings(computeIPCandidates(ipNet, []string{"10.0.0.1"}, "10.0.0.1", 5)) |
| 143 | + want := []string{"10.0.1.254", "10.0.1.253", "10.0.1.252", "10.0.1.251", "10.0.1.250"} |
| 144 | + if len(got) != len(want) { |
| 145 | + t.Fatalf("got %d IPs, want %d", len(got), len(want)) |
| 146 | + } |
| 147 | + for i := range got { |
| 148 | + if got[i] != want[i] { |
| 149 | + t.Errorf("IP[%d]: got %s, want %s", i, got[i], want[i]) |
| 150 | + } |
| 151 | + } |
| 152 | + }) |
| 153 | + |
| 154 | + t.Run("large cluster: /16, 100 peers, 3 containers", func(t *testing.T) { |
| 155 | + ipNet := mustParseCIDR(t, "172.16.0.0/16") |
| 156 | + peers := make([]string, 100) |
| 157 | + for i := range peers { |
| 158 | + peers[i] = net.IPv4(10, 0, byte(i/256), byte(i%256+1)).String() |
| 159 | + } |
| 160 | + |
| 161 | + // First peer: broadcast is 172.16.255.255, gets .254, .253, .252 |
| 162 | + first := ipsToStrings(computeIPCandidates(ipNet, peers, peers[0], 3)) |
| 163 | + if first[0] != "172.16.255.254" { |
| 164 | + t.Errorf("first peer IP[0]: got %s, want 172.16.255.254", first[0]) |
| 165 | + } |
| 166 | + if len(first) != 3 { |
| 167 | + t.Errorf("first peer: got %d IPs, want 3", len(first)) |
| 168 | + } |
| 169 | + |
| 170 | + // Last peer (index 99): startOffset = 1 + 99*3 = 298 |
| 171 | + // broadcast - 298 = 172.16.255.255 - 298 = 172.16.254.213 |
| 172 | + last := ipsToStrings(computeIPCandidates(ipNet, peers, peers[99], 3)) |
| 173 | + if len(last) != 3 { |
| 174 | + t.Fatalf("last peer: got %d IPs, want 3", len(last)) |
| 175 | + } |
| 176 | + if last[0] != "172.16.254.213" { |
| 177 | + t.Errorf("last peer IP[0]: got %s, want 172.16.254.213", last[0]) |
| 178 | + } |
| 179 | + |
| 180 | + // Total band = 300 IPs out of 65534 usable — plenty of room |
| 181 | + }) |
| 182 | + |
| 183 | + t.Run("large cluster: /21, 50 peers, 4 containers", func(t *testing.T) { |
| 184 | + ipNet := mustParseCIDR(t, "10.0.64.0/21") |
| 185 | + peers := make([]string, 50) |
| 186 | + for i := range peers { |
| 187 | + peers[i] = net.IPv4(10, 1, 0, byte(i+1)).String() |
| 188 | + } |
| 189 | + |
| 190 | + // Band = 200 IPs. Subnet has 2046 usable. Leaves 1846 for swarm. |
| 191 | + first := ipsToStrings(computeIPCandidates(ipNet, peers, peers[0], 4)) |
| 192 | + if len(first) != 4 || first[0] != "10.0.71.254" { |
| 193 | + t.Errorf("first peer: got %v", first) |
| 194 | + } |
| 195 | + |
| 196 | + // Last peer (index 49): startOffset = 1 + 49*4 = 197 |
| 197 | + last := ipsToStrings(computeIPCandidates(ipNet, peers, peers[49], 4)) |
| 198 | + if len(last) != 4 { |
| 199 | + t.Fatalf("last peer: got %d IPs, want 4", len(last)) |
| 200 | + } |
| 201 | + if last[0] != "10.0.71.58" { |
| 202 | + t.Errorf("last peer IP[0]: got %s, want 10.0.71.58", last[0]) |
| 203 | + } |
| 204 | + }) |
| 205 | + |
| 206 | + t.Run("node not in peers list", func(t *testing.T) { |
| 207 | + ipNet := mustParseCIDR(t, "10.0.1.0/24") |
| 208 | + peers := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"} |
| 209 | + |
| 210 | + // Unknown node gets index = len(peers) = 3 |
| 211 | + got := ipsToStrings(computeIPCandidates(ipNet, peers, "10.0.0.99", 2)) |
| 212 | + // startOffset = 1 + 3*2 = 7 → .248, .247 |
| 213 | + if len(got) != 2 || got[0] != "10.0.1.248" || got[1] != "10.0.1.247" { |
| 214 | + t.Errorf("unknown node: got %v, want [10.0.1.248 10.0.1.247]", got) |
| 215 | + } |
| 216 | + }) |
| 217 | + |
| 218 | + t.Run("peers sorted regardless of input order", func(t *testing.T) { |
| 219 | + ipNet := mustParseCIDR(t, "10.0.1.0/24") |
| 220 | + |
| 221 | + // Same peers, different order — same node should get same IPs |
| 222 | + order1 := computeIPCandidates(ipNet, []string{"10.0.0.3", "10.0.0.1", "10.0.0.2"}, "10.0.0.2", 2) |
| 223 | + order2 := computeIPCandidates(ipNet, []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}, "10.0.0.2", 2) |
| 224 | + order3 := computeIPCandidates(ipNet, []string{"10.0.0.2", "10.0.0.3", "10.0.0.1"}, "10.0.0.2", 2) |
| 225 | + |
| 226 | + got1 := ipsToStrings(order1) |
| 227 | + got2 := ipsToStrings(order2) |
| 228 | + got3 := ipsToStrings(order3) |
| 229 | + |
| 230 | + for i := range got1 { |
| 231 | + if got1[i] != got2[i] || got2[i] != got3[i] { |
| 232 | + t.Errorf("order matters: %v vs %v vs %v", got1, got2, got3) |
| 233 | + break |
| 234 | + } |
| 235 | + } |
| 236 | + }) |
| 237 | + |
| 238 | + t.Run("no overlap between peers", func(t *testing.T) { |
| 239 | + ipNet := mustParseCIDR(t, "10.0.64.0/21") |
| 240 | + peers := make([]string, 20) |
| 241 | + for i := range peers { |
| 242 | + peers[i] = net.IPv4(10, 0, 0, byte(i+1)).String() |
| 243 | + } |
| 244 | + |
| 245 | + seen := map[string]int{} |
| 246 | + for peerIdx, peer := range peers { |
| 247 | + candidates := computeIPCandidates(ipNet, peers, peer, 3) |
| 248 | + for _, ip := range candidates { |
| 249 | + if prev, exists := seen[ip.String()]; exists { |
| 250 | + t.Errorf("IP %s assigned to both peer %d and peer %d", ip, prev, peerIdx) |
| 251 | + } |
| 252 | + seen[ip.String()] = peerIdx |
| 253 | + } |
| 254 | + } |
| 255 | + }) |
| 256 | + |
| 257 | + t.Run("tiny subnet /28 with too many peers truncates", func(t *testing.T) { |
| 258 | + // /28 has 14 usable IPs (16 - network - broadcast) |
| 259 | + ipNet := mustParseCIDR(t, "192.168.1.0/28") |
| 260 | + peers := make([]string, 10) |
| 261 | + for i := range peers { |
| 262 | + peers[i] = net.IPv4(10, 0, 0, byte(i+1)).String() |
| 263 | + } |
| 264 | + |
| 265 | + // First peer: .14, .13 — fits |
| 266 | + first := ipsToStrings(computeIPCandidates(ipNet, peers, peers[0], 2)) |
| 267 | + if len(first) != 2 || first[0] != "192.168.1.14" || first[1] != "192.168.1.13" { |
| 268 | + t.Errorf("first peer: got %v, want [192.168.1.14 192.168.1.13]", first) |
| 269 | + } |
| 270 | + |
| 271 | + // Peer at index 7: startOffset = 1 + 7*2 = 15, broadcast - 15 = .0 = network addr → no candidates |
| 272 | + late := computeIPCandidates(ipNet, peers, peers[7], 2) |
| 273 | + if len(late) != 0 { |
| 274 | + t.Errorf("peer 7 on /28: got %v, want empty (exceeds subnet)", ipsToStrings(late)) |
| 275 | + } |
| 276 | + }) |
| 277 | + |
| 278 | + t.Run("byte boundary crossing in band", func(t *testing.T) { |
| 279 | + // Subnet where the band crosses a .0 boundary |
| 280 | + ipNet := mustParseCIDR(t, "10.0.0.0/21") |
| 281 | + // Broadcast is 10.0.7.255, peer index 3 with 4 containers: |
| 282 | + // startOffset = 1 + 3*4 = 13 → 10.0.7.242, .241, .240, .239 |
| 283 | + peers := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.4"} |
| 284 | + got := ipsToStrings(computeIPCandidates(ipNet, peers, "10.0.0.4", 4)) |
| 285 | + want := []string{"10.0.7.242", "10.0.7.241", "10.0.7.240", "10.0.7.239"} |
| 286 | + if len(got) != len(want) { |
| 287 | + t.Fatalf("got %d IPs, want %d", len(got), len(want)) |
| 288 | + } |
| 289 | + for i := range got { |
| 290 | + if got[i] != want[i] { |
| 291 | + t.Errorf("IP[%d]: got %s, want %s", i, got[i], want[i]) |
| 292 | + } |
| 293 | + } |
| 294 | + }) |
| 295 | + |
| 296 | + t.Run("/30 minimal subnet, 1 peer, 1 container", func(t *testing.T) { |
| 297 | + // /30 has 2 usable IPs: .1 and .2 |
| 298 | + ipNet := mustParseCIDR(t, "10.0.0.0/30") |
| 299 | + got := ipsToStrings(computeIPCandidates(ipNet, []string{"10.0.0.1"}, "10.0.0.1", 1)) |
| 300 | + if len(got) != 1 || got[0] != "10.0.0.2" { |
| 301 | + t.Errorf("got %v, want [10.0.0.2]", got) |
| 302 | + } |
| 303 | + }) |
| 304 | + |
| 305 | + t.Run("/30 minimal subnet, 1 peer, 2 containers — only 1 fits", func(t *testing.T) { |
| 306 | + // /30 has 2 usable IPs, but broadcast-1 = .2, broadcast-2 = .1 which |
| 307 | + // equals the network+1... actually .1 is still in the subnet. Let's check: |
| 308 | + // network = .0, broadcast = .3, usable = .1 and .2 |
| 309 | + // startOffset = 1 → candidate .2 (ok), then .1 — .1 != network .0 so it's valid |
| 310 | + ipNet := mustParseCIDR(t, "10.0.0.0/30") |
| 311 | + got := ipsToStrings(computeIPCandidates(ipNet, []string{"10.0.0.1"}, "10.0.0.1", 2)) |
| 312 | + if len(got) != 2 || got[0] != "10.0.0.2" || got[1] != "10.0.0.1" { |
| 313 | + t.Errorf("got %v, want [10.0.0.2 10.0.0.1]", got) |
| 314 | + } |
| 315 | + }) |
| 316 | + |
| 317 | + t.Run("zero overlay containers returns nil", func(t *testing.T) { |
| 318 | + ipNet := mustParseCIDR(t, "10.0.1.0/24") |
| 319 | + got := computeIPCandidates(ipNet, []string{"10.0.0.1"}, "10.0.0.1", 0) |
| 320 | + if got != nil { |
| 321 | + t.Errorf("got %v, want nil", got) |
| 322 | + } |
| 323 | + }) |
| 324 | +} |
0 commit comments