Skip to content

Commit a4fc2a0

Browse files
committed
feat(controller): add carveGroupCIDRs pure function for network group CIDR allocation
1 parent d2958f6 commit a4fc2a0

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

internal/controller/group_cidr.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package controller
2+
3+
import (
4+
"encoding/binary"
5+
"fmt"
6+
"net"
7+
"sort"
8+
9+
impdevv1alpha1 "github.com/syscode-labs/imp/api/v1alpha1"
10+
)
11+
12+
// carveGroupCIDRs derives one subnet per group from parentCIDR.
13+
// Groups are sorted alphabetically for deterministic output.
14+
// Each group gets the smallest subnet that fits ExpectedSize hosts
15+
// (minimum /30 for 0 or 1 host). Subnets are placed consecutively,
16+
// aligned to their natural block boundary.
17+
// Returns an error if the parent subnet is exhausted.
18+
func carveGroupCIDRs(parentCIDR string, groups []impdevv1alpha1.NetworkGroupSpec) ([]impdevv1alpha1.GroupCIDR, error) {
19+
if len(groups) == 0 {
20+
return nil, nil
21+
}
22+
23+
_, parent, err := net.ParseCIDR(parentCIDR)
24+
if err != nil {
25+
return nil, fmt.Errorf("parse subnet %q: %w", parentCIDR, err)
26+
}
27+
parentStart := ipToUint32(parent.IP.To4())
28+
parentPrefix, _ := parent.Mask.Size()
29+
parentSize := uint32(1) << (32 - parentPrefix)
30+
31+
// Sort groups by name for determinism.
32+
sorted := make([]impdevv1alpha1.NetworkGroupSpec, len(groups))
33+
copy(sorted, groups)
34+
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
35+
36+
result := make([]impdevv1alpha1.GroupCIDR, 0, len(sorted))
37+
cursor := parentStart
38+
39+
for _, g := range sorted {
40+
prefix := groupPrefixLen(g.ExpectedSize)
41+
blockSize := uint32(1) << (32 - prefix)
42+
43+
// Align cursor to block boundary.
44+
aligned := (cursor + blockSize - 1) &^ (blockSize - 1)
45+
if aligned < parentStart || aligned-parentStart+blockSize > parentSize {
46+
return nil, fmt.Errorf("group %q: no space left in %s", g.Name, parentCIDR)
47+
}
48+
49+
ip := uint32ToIP(aligned)
50+
result = append(result, impdevv1alpha1.GroupCIDR{
51+
Name: g.Name,
52+
CIDR: fmt.Sprintf("%s/%d", ip.String(), prefix),
53+
})
54+
cursor = aligned + blockSize
55+
}
56+
return result, nil
57+
}
58+
59+
// groupPrefixLen returns the smallest CIDR prefix that fits n hosts.
60+
// Minimum is /30 (2 usable addresses). Identical semantics to
61+
// network.sizeToCIDRPrefix in the agent package.
62+
func groupPrefixLen(n int32) int {
63+
if n <= 2 {
64+
return 30
65+
}
66+
needed := int(n) + 2 // +2 for network and broadcast
67+
prefix := 30
68+
for (1 << (32 - prefix)) < needed {
69+
prefix--
70+
}
71+
return prefix
72+
}
73+
74+
func ipToUint32(ip net.IP) uint32 {
75+
ip = ip.To4()
76+
return binary.BigEndian.Uint32(ip)
77+
}
78+
79+
func uint32ToIP(n uint32) net.IP {
80+
ip := make(net.IP, 4)
81+
binary.BigEndian.PutUint32(ip, n)
82+
return ip
83+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package controller
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
impdevv1alpha1 "github.com/syscode-labs/imp/api/v1alpha1"
10+
)
11+
12+
func TestCarveGroupCIDRs_TwoGroups(t *testing.T) {
13+
groups := []impdevv1alpha1.NetworkGroupSpec{
14+
{Name: "workers", ExpectedSize: 14}, // → /28 (16 addresses)
15+
{Name: "controllers", ExpectedSize: 4}, // → /29 (8 addresses)
16+
}
17+
// controllers < workers alphabetically; controllers gets the first block.
18+
result, err := carveGroupCIDRs("10.44.0.0/24", groups)
19+
require.NoError(t, err)
20+
require.Len(t, result, 2)
21+
22+
// Map by name for order-independent assertions.
23+
byName := make(map[string]string, len(result))
24+
for _, gc := range result {
25+
byName[gc.Name] = gc.CIDR
26+
}
27+
28+
// controllers (sorted first): /29 aligned at 0 → 10.44.0.0/29
29+
assert.Equal(t, "10.44.0.0/29", byName["controllers"])
30+
// workers: /28 must start at a 16-address aligned boundary after controllers' block (ends at .8)
31+
// Next /28-aligned address ≥ 10.44.0.8 is 10.44.0.16.
32+
assert.Equal(t, "10.44.0.16/28", byName["workers"])
33+
}
34+
35+
func TestCarveGroupCIDRs_SingleGroup(t *testing.T) {
36+
groups := []impdevv1alpha1.NetworkGroupSpec{
37+
{Name: "all", ExpectedSize: 30}, // → /27 (32 addresses)
38+
}
39+
result, err := carveGroupCIDRs("10.44.0.0/24", groups)
40+
require.NoError(t, err)
41+
require.Len(t, result, 1)
42+
assert.Equal(t, "10.44.0.0/27", result[0].CIDR)
43+
}
44+
45+
func TestCarveGroupCIDRs_NoGroups(t *testing.T) {
46+
result, err := carveGroupCIDRs("10.44.0.0/24", nil)
47+
require.NoError(t, err)
48+
assert.Empty(t, result)
49+
}
50+
51+
func TestCarveGroupCIDRs_Overflow(t *testing.T) {
52+
// /30 parent (4 addresses = 2 usable) cannot fit a /28 (16 addresses).
53+
groups := []impdevv1alpha1.NetworkGroupSpec{
54+
{Name: "big", ExpectedSize: 14}, // → /28
55+
}
56+
_, err := carveGroupCIDRs("10.44.0.0/30", groups)
57+
require.Error(t, err)
58+
}
59+
60+
func TestCarveGroupCIDRs_Deterministic(t *testing.T) {
61+
// Same groups in different order should produce identical output (sorted by name).
62+
groups1 := []impdevv1alpha1.NetworkGroupSpec{
63+
{Name: "z", ExpectedSize: 2},
64+
{Name: "a", ExpectedSize: 2},
65+
}
66+
groups2 := []impdevv1alpha1.NetworkGroupSpec{
67+
{Name: "a", ExpectedSize: 2},
68+
{Name: "z", ExpectedSize: 2},
69+
}
70+
r1, err1 := carveGroupCIDRs("10.0.0.0/24", groups1)
71+
r2, err2 := carveGroupCIDRs("10.0.0.0/24", groups2)
72+
require.NoError(t, err1)
73+
require.NoError(t, err2)
74+
assert.Equal(t, r1[0].CIDR, r2[0].CIDR) // "a" gets same CIDR regardless of input order
75+
}

0 commit comments

Comments
 (0)