Skip to content
This repository was archived by the owner on May 18, 2026. It is now read-only.

Commit 3ed4f68

Browse files
committed
Add unit tests and test workflow
Extract computeIPCandidates as a pure function for testability. Tests cover /21, /24, /16, /28, /30 subnets with 1-100 peers and 1-5 containers, including edge cases like subnet overflow, byte boundary crossing, unknown node, and ordering invariance.
1 parent 902e041 commit 3ed4f68

3 files changed

Lines changed: 356 additions & 3 deletions

File tree

.github/workflows/test.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Test
2+
3+
permissions:
4+
contents: read
5+
6+
on: [push]
7+
8+
jobs:
9+
test:
10+
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v6
14+
15+
- name: Set up Go
16+
uses: actions/setup-go@v6
17+
with:
18+
go-version: "1.26"
19+
cache: false
20+
21+
- name: Install dependencies
22+
run: go get .
23+
- name: Test
24+
run: go test -v ./...

main.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -573,20 +573,25 @@ func (m *manager) highIPCandidates(networkName string, containerName string) []n
573573
for i, p := range networkInfo.Peers {
574574
peerIPs[i] = p.IP
575575
}
576+
577+
return computeIPCandidates(ipNet, peerIPs, m.nodeAddr, m.overlayContainers)
578+
}
579+
580+
func computeIPCandidates(ipNet *net.IPNet, peerIPs []string, nodeAddr string, overlayContainers int) []net.IP {
576581
sort.Strings(peerIPs)
577582

578583
peerIndex := len(peerIPs)
579584
for i, ip := range peerIPs {
580-
if ip == m.nodeAddr {
585+
if ip == nodeAddr {
581586
peerIndex = i
582587
break
583588
}
584589
}
585590

586591
broadcast := broadcastAddr(ipNet)
587-
startOffset := 1 + peerIndex*m.overlayContainers
592+
startOffset := 1 + peerIndex*overlayContainers
588593
var candidates []net.IP
589-
for i := range m.overlayContainers {
594+
for i := range overlayContainers {
590595
ip := addToIP(broadcast, -(startOffset + i))
591596
if !ipNet.Contains(ip) || ip.Equal(ipNet.IP) {
592597
break

main_test.go

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
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

Comments
 (0)