Skip to content

Commit ad082a6

Browse files
authored
Merge pull request #439 from linode/nodeipam/ipv6-stable-112-subprefix
[improvement][feat] Deterministic IPv6 /112 PodCIDR via :0:c::/112; update tests/docs
2 parents df34350 + 590d969 commit ad082a6

File tree

3 files changed

+112
-17
lines changed

3 files changed

+112
-17
lines changed

cloud/nodeipam/ipam/cloud_allocator.go

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ type cloudAllocator struct {
7272
disableIPv6NodeCIDRAllocation bool
7373
}
7474

75-
const providerIDPrefix = "linode://"
75+
const (
76+
providerIDPrefix = "linode://"
77+
ipv6BitLen = 128
78+
ipv6PodCIDRMaskSize = 112
79+
)
7680

7781
var _ CIDRAllocator = &cloudAllocator{}
7882

@@ -347,12 +351,49 @@ func getIPv6RangeFromLinodeInterface(iface linodego.LinodeInterface) string {
347351
return ""
348352
}
349353

354+
// getIPv6PodCIDR attempts to compute a stable IPv6 /112 PodCIDR
355+
// within the provided base IPv6 range using the mnemonic subprefix :0:c::/112.
356+
//
357+
// The mnemonic subprefix :0:c::/112 is constructed by setting hextets 5..7 to 0, c, 0
358+
// (i.e., bytes 8-13 set to 00 00 00 0c 00 00) within the /64 base, as implemented below.
359+
//
360+
// Rules:
361+
// - For a /64 base, return {base64}:0:c::/112
362+
// - Only applies when desiredMask is /112 and the result is fully contained
363+
// in the base range. Otherwise, returns (nil, false) to signal fallback.
364+
func getIPv6PodCIDR(ip net.IP, desiredMask int) (*net.IPNet, bool) {
365+
// Some validation checks
366+
if ip == nil || desiredMask != ipv6PodCIDRMaskSize {
367+
return nil, false
368+
}
369+
370+
// We need to make a copy so we don't mutate caller's backing array (net.IP is a slice)
371+
ipCopy := make(net.IP, len(ip))
372+
copy(ipCopy, ip)
373+
374+
// Keep first 64 bits (bytes 0..7) and set hextets 5..7 to 0, c, 0 respectively
375+
// Hextet index to byte mapping: h5->[8,9], h6->[10,11], h7->[12,13]
376+
ipCopy[8], ipCopy[9] = 0x00, 0x00 // :0
377+
ipCopy[10], ipCopy[11] = 0x00, 0x0c // :c
378+
ipCopy[12], ipCopy[13] = 0x00, 0x00 // :0
379+
// last hextet (bytes 14..15) will be zeroed by mask below
380+
381+
podMask := net.CIDRMask(desiredMask, ipv6BitLen)
382+
// Ensure the address is the network address for the desired mask
383+
ipCopy = ipCopy.Mask(podMask)
384+
podCIDR := &net.IPNet{IP: ipCopy, Mask: podMask}
385+
386+
return podCIDR, true
387+
}
388+
350389
// allocateIPv6CIDR allocates an IPv6 CIDR for the given node.
351390
// It retrieves the instance configuration for the node and extracts the IPv6 range.
352391
// It then creates a new net.IPNet with the IPv6 address and mask size defined
353392
// by nodeCIDRMaskSizeIPv6. The function returns an error if it fails to retrieve
354393
// the instance configuration or parse the IPv6 range.
355394
func (c *cloudAllocator) allocateIPv6CIDR(ctx context.Context, node *v1.Node) (*net.IPNet, error) {
395+
logger := klog.FromContext(ctx)
396+
356397
if node.Spec.ProviderID == "" {
357398
return nil, fmt.Errorf("node %s has no ProviderID set, cannot calculate ipv6 range for it", node.Name)
358399
}
@@ -410,18 +451,26 @@ func (c *cloudAllocator) allocateIPv6CIDR(ctx context.Context, node *v1.Node) (*
410451
}
411452
}
412453

413-
ip, _, err := net.ParseCIDR(ipv6Range)
454+
ip, base, err := net.ParseCIDR(ipv6Range)
414455
if err != nil {
415456
return nil, fmt.Errorf("failed parsing ipv6 range %s: %w", ipv6Range, err)
416457
}
417458

418-
mask := net.CIDRMask(c.nodeCIDRMaskSizeIPv6, 128)
419-
ipv6Embedded := &net.IPNet{
420-
IP: ip.Mask(mask),
421-
Mask: mask,
459+
// get pod cidr using stable mnemonic subprefix :0:c::/112
460+
if podCIDR, ok := getIPv6PodCIDR(ip, c.nodeCIDRMaskSizeIPv6); ok {
461+
logger.V(4).Info("Using stable IPv6 PodCIDR subprefix :0:c::/112", "ip", ip, "podCIDR", podCIDR)
462+
// Verify the /112 PodCIDR is fully contained within the base /64 range
463+
if !base.Contains(podCIDR.IP) {
464+
return nil, fmt.Errorf("stable IPv6 PodCIDR %s is not contained in base range %s", podCIDR, base)
465+
}
466+
return podCIDR, nil
422467
}
423468

424-
return ipv6Embedded, nil
469+
// Fallback to the original behavior: mask base IP directly to desired size
470+
podMask := net.CIDRMask(c.nodeCIDRMaskSizeIPv6, 128)
471+
fallbackPodCIDR := &net.IPNet{IP: ip.Mask(podMask), Mask: podMask}
472+
logger.V(4).Info("Falling back to start-of-range IPv6 PodCIDR", "ip", ip, "podCIDR", fallbackPodCIDR)
473+
return fallbackPodCIDR, nil
425474
}
426475

427476
// WARNING: If you're adding any return calls or defer any more work from this

cloud/nodeipam/ipam/cloud_allocator_test.go

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,52 @@ type testCase struct {
5050
instance *linodego.Instance
5151
}
5252

53+
func TestComputeStableIPv6PodCIDR(t *testing.T) {
54+
for _, tc := range []struct {
55+
name string
56+
baseCIDR string
57+
desiredMask int
58+
wantCIDR string
59+
wantOK bool
60+
}{
61+
{name: "nil base", baseCIDR: "", desiredMask: 112, wantOK: false},
62+
{name: "non-/112 desired", baseCIDR: "2300:5800:2:1::/64", desiredMask: 120, wantOK: false},
63+
{name: "success /64 -> mnemonic /112", baseCIDR: "2300:5800:2:1::/64", desiredMask: 112, wantCIDR: "2300:5800:2:1:0:c::/112", wantOK: true},
64+
} {
65+
t.Run(tc.name, func(t *testing.T) {
66+
var baseIP net.IP
67+
if tc.baseCIDR != "" {
68+
ip, _, err := net.ParseCIDR(tc.baseCIDR)
69+
if err != nil {
70+
t.Fatalf("parse base cidr: %v", err)
71+
}
72+
baseIP = ip
73+
}
74+
got, ok := getIPv6PodCIDR(baseIP, tc.desiredMask)
75+
if ok != tc.wantOK {
76+
t.Fatalf("ok mismatch: got %v want %v (gotCIDR=%v)", ok, tc.wantOK, func() string {
77+
if got != nil {
78+
return got.String()
79+
}
80+
return ""
81+
}())
82+
}
83+
if !tc.wantOK {
84+
if got != nil {
85+
t.Fatalf("expected nil cidr on failure, got %v", got.String())
86+
}
87+
return
88+
}
89+
if got == nil {
90+
t.Fatalf("expected non-nil cidr")
91+
}
92+
if got.String() != tc.wantCIDR {
93+
t.Fatalf("cidr mismatch: got %s want %s", got.String(), tc.wantCIDR)
94+
}
95+
})
96+
}
97+
}
98+
5399
func TestGetIPv6RangeFromLinodeInterface(t *testing.T) {
54100
for _, tc := range []struct {
55101
iface linodego.LinodeInterface
@@ -249,7 +295,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) {
249295
},
250296
expectedAllocatedCIDR: map[int]string{
251297
0: "127.123.234.0/30",
252-
1: "2300:5800:2:1::/112",
298+
1: "2300:5800:2:1:0:c::/112",
253299
},
254300
},
255301
{
@@ -292,7 +338,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) {
292338
// it should return first /30 CIDR after service range
293339
expectedAllocatedCIDR: map[int]string{
294340
0: "127.123.234.64/30",
295-
1: "2300:5800:2:1::/112",
341+
1: "2300:5800:2:1:0:c::/112",
296342
},
297343
},
298344
{
@@ -337,7 +383,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) {
337383
},
338384
expectedAllocatedCIDR: map[int]string{
339385
0: "127.123.234.76/30",
340-
1: "2300:5800:2:1::/112",
386+
1: "2300:5800:2:1:0:c::/112",
341387
},
342388
},
343389
{
@@ -410,7 +456,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) {
410456
},
411457
expectedAllocatedCIDR: map[int]string{
412458
0: "10.10.1.0/24",
413-
1: "2300:5800:2:1::/112",
459+
1: "2300:5800:2:1:0:c::/112",
414460
},
415461
},
416462
{
@@ -449,7 +495,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) {
449495
},
450496
expectedAllocatedCIDR: map[int]string{
451497
0: "127.123.234.0/30",
452-
1: "2300:5800:2:1::/112",
498+
1: "2300:5800:2:1:0:c::/112",
453499
},
454500
},
455501
{
@@ -492,7 +538,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) {
492538
// it should return first /30 CIDR after service range
493539
expectedAllocatedCIDR: map[int]string{
494540
0: "127.123.234.64/30",
495-
1: "2300:5800:2:1::/112",
541+
1: "2300:5800:2:1:0:c::/112",
496542
},
497543
},
498544
{
@@ -537,7 +583,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) {
537583
},
538584
expectedAllocatedCIDR: map[int]string{
539585
0: "127.123.234.76/30",
540-
1: "2300:5800:2:1::/112",
586+
1: "2300:5800:2:1:0:c::/112",
541587
},
542588
},
543589
{
@@ -610,7 +656,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) {
610656
},
611657
expectedAllocatedCIDR: map[int]string{
612658
0: "10.10.1.0/24",
613-
1: "2300:5800:2:1::/112",
659+
1: "2300:5800:2:1:0:c::/112",
614660
},
615661
},
616662
}

docs/configuration/nodeipam.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Note:
2121
Make sure node IPAM allocation is disabled in kube-controller-manager to avoid both controllers competing to assign CIDRs to nodes. To make sure its disabled, check and make sure kube-controller-manager is not started with `--allocate-node-cidrs` flag.
2222

2323
## Allocated subnet size
24-
By default, CCM allocates /24 subnet for IPv4 addresses and /112 for IPv6 addresses to nodes. For IPv6 CIDR allocation using CCM, linodes should have IPv6 ranges configured on their interfaces. If one wants different subnet range, it can be configured by using `--node-cidr-mask-size-ipv4` and `--node-cidr-mask-size-ipv6` flags.
24+
By default, CCM allocates /24 subnet for IPv4 addresses and /112 for IPv6 addresses to nodes. For IPv6, CCM derives /112 PodCIDRs from the node's /64 IPv6 range using the stable mnemonic subprefix `:0:c::/112`. For IPv6 CIDR allocation using CCM, Linodes should have IPv6 ranges configured on their interfaces. If one wants different subnet range, it can be configured by using `--node-cidr-mask-size-ipv4` and `--node-cidr-mask-size-ipv6` flags.
2525

2626
```yaml
2727
spec:
@@ -33,7 +33,7 @@ spec:
3333
- --allocate-node-cidrs=true
3434
- --cluster-cidr=10.192.0.0/10
3535
- --node-cidr-mask-size-ipv4=25
36-
- --node-cidr-mask-size-ipv6=64
36+
- --node-cidr-mask-size-ipv6=112
3737
```
3838

3939
## Disabling ipv6 ipam allocation

0 commit comments

Comments
 (0)