diff --git a/cloud/nodeipam/ipam/cloud_allocator.go b/cloud/nodeipam/ipam/cloud_allocator.go index a848e25a..9b0f903e 100644 --- a/cloud/nodeipam/ipam/cloud_allocator.go +++ b/cloud/nodeipam/ipam/cloud_allocator.go @@ -72,7 +72,11 @@ type cloudAllocator struct { disableIPv6NodeCIDRAllocation bool } -const providerIDPrefix = "linode://" +const ( + providerIDPrefix = "linode://" + ipv6BitLen = 128 + ipv6PodCIDRMaskSize = 112 +) var _ CIDRAllocator = &cloudAllocator{} @@ -347,12 +351,49 @@ func getIPv6RangeFromLinodeInterface(iface linodego.LinodeInterface) string { return "" } +// getIPv6PodCIDR attempts to compute a stable IPv6 /112 PodCIDR +// within the provided base IPv6 range using the mnemonic subprefix :0:c::/112. +// +// The mnemonic subprefix :0:c::/112 is constructed by setting hextets 5..7 to 0, c, 0 +// (i.e., bytes 8-13 set to 00 00 00 0c 00 00) within the /64 base, as implemented below. +// +// Rules: +// - For a /64 base, return {base64}:0:c::/112 +// - Only applies when desiredMask is /112 and the result is fully contained +// in the base range. Otherwise, returns (nil, false) to signal fallback. +func getIPv6PodCIDR(ip net.IP, desiredMask int) (*net.IPNet, bool) { + // Some validation checks + if ip == nil || desiredMask != ipv6PodCIDRMaskSize { + return nil, false + } + + // We need to make a copy so we don't mutate caller's backing array (net.IP is a slice) + ipCopy := make(net.IP, len(ip)) + copy(ipCopy, ip) + + // Keep first 64 bits (bytes 0..7) and set hextets 5..7 to 0, c, 0 respectively + // Hextet index to byte mapping: h5->[8,9], h6->[10,11], h7->[12,13] + ipCopy[8], ipCopy[9] = 0x00, 0x00 // :0 + ipCopy[10], ipCopy[11] = 0x00, 0x0c // :c + ipCopy[12], ipCopy[13] = 0x00, 0x00 // :0 + // last hextet (bytes 14..15) will be zeroed by mask below + + podMask := net.CIDRMask(desiredMask, ipv6BitLen) + // Ensure the address is the network address for the desired mask + ipCopy = ipCopy.Mask(podMask) + podCIDR := &net.IPNet{IP: ipCopy, Mask: podMask} + + return podCIDR, true +} + // allocateIPv6CIDR allocates an IPv6 CIDR for the given node. // It retrieves the instance configuration for the node and extracts the IPv6 range. // It then creates a new net.IPNet with the IPv6 address and mask size defined // by nodeCIDRMaskSizeIPv6. The function returns an error if it fails to retrieve // the instance configuration or parse the IPv6 range. func (c *cloudAllocator) allocateIPv6CIDR(ctx context.Context, node *v1.Node) (*net.IPNet, error) { + logger := klog.FromContext(ctx) + if node.Spec.ProviderID == "" { return nil, fmt.Errorf("node %s has no ProviderID set, cannot calculate ipv6 range for it", node.Name) } @@ -410,18 +451,26 @@ func (c *cloudAllocator) allocateIPv6CIDR(ctx context.Context, node *v1.Node) (* } } - ip, _, err := net.ParseCIDR(ipv6Range) + ip, base, err := net.ParseCIDR(ipv6Range) if err != nil { return nil, fmt.Errorf("failed parsing ipv6 range %s: %w", ipv6Range, err) } - mask := net.CIDRMask(c.nodeCIDRMaskSizeIPv6, 128) - ipv6Embedded := &net.IPNet{ - IP: ip.Mask(mask), - Mask: mask, + // get pod cidr using stable mnemonic subprefix :0:c::/112 + if podCIDR, ok := getIPv6PodCIDR(ip, c.nodeCIDRMaskSizeIPv6); ok { + logger.V(4).Info("Using stable IPv6 PodCIDR subprefix :0:c::/112", "ip", ip, "podCIDR", podCIDR) + // Verify the /112 PodCIDR is fully contained within the base /64 range + if !base.Contains(podCIDR.IP) { + return nil, fmt.Errorf("stable IPv6 PodCIDR %s is not contained in base range %s", podCIDR, base) + } + return podCIDR, nil } - return ipv6Embedded, nil + // Fallback to the original behavior: mask base IP directly to desired size + podMask := net.CIDRMask(c.nodeCIDRMaskSizeIPv6, 128) + fallbackPodCIDR := &net.IPNet{IP: ip.Mask(podMask), Mask: podMask} + logger.V(4).Info("Falling back to start-of-range IPv6 PodCIDR", "ip", ip, "podCIDR", fallbackPodCIDR) + return fallbackPodCIDR, nil } // WARNING: If you're adding any return calls or defer any more work from this diff --git a/cloud/nodeipam/ipam/cloud_allocator_test.go b/cloud/nodeipam/ipam/cloud_allocator_test.go index d3bce604..b9097ccc 100644 --- a/cloud/nodeipam/ipam/cloud_allocator_test.go +++ b/cloud/nodeipam/ipam/cloud_allocator_test.go @@ -50,6 +50,52 @@ type testCase struct { instance *linodego.Instance } +func TestComputeStableIPv6PodCIDR(t *testing.T) { + for _, tc := range []struct { + name string + baseCIDR string + desiredMask int + wantCIDR string + wantOK bool + }{ + {name: "nil base", baseCIDR: "", desiredMask: 112, wantOK: false}, + {name: "non-/112 desired", baseCIDR: "2300:5800:2:1::/64", desiredMask: 120, wantOK: false}, + {name: "success /64 -> mnemonic /112", baseCIDR: "2300:5800:2:1::/64", desiredMask: 112, wantCIDR: "2300:5800:2:1:0:c::/112", wantOK: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var baseIP net.IP + if tc.baseCIDR != "" { + ip, _, err := net.ParseCIDR(tc.baseCIDR) + if err != nil { + t.Fatalf("parse base cidr: %v", err) + } + baseIP = ip + } + got, ok := getIPv6PodCIDR(baseIP, tc.desiredMask) + if ok != tc.wantOK { + t.Fatalf("ok mismatch: got %v want %v (gotCIDR=%v)", ok, tc.wantOK, func() string { + if got != nil { + return got.String() + } + return "" + }()) + } + if !tc.wantOK { + if got != nil { + t.Fatalf("expected nil cidr on failure, got %v", got.String()) + } + return + } + if got == nil { + t.Fatalf("expected non-nil cidr") + } + if got.String() != tc.wantCIDR { + t.Fatalf("cidr mismatch: got %s want %s", got.String(), tc.wantCIDR) + } + }) + } +} + func TestGetIPv6RangeFromLinodeInterface(t *testing.T) { for _, tc := range []struct { iface linodego.LinodeInterface @@ -249,7 +295,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) { }, expectedAllocatedCIDR: map[int]string{ 0: "127.123.234.0/30", - 1: "2300:5800:2:1::/112", + 1: "2300:5800:2:1:0:c::/112", }, }, { @@ -292,7 +338,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) { // it should return first /30 CIDR after service range expectedAllocatedCIDR: map[int]string{ 0: "127.123.234.64/30", - 1: "2300:5800:2:1::/112", + 1: "2300:5800:2:1:0:c::/112", }, }, { @@ -337,7 +383,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) { }, expectedAllocatedCIDR: map[int]string{ 0: "127.123.234.76/30", - 1: "2300:5800:2:1::/112", + 1: "2300:5800:2:1:0:c::/112", }, }, { @@ -410,7 +456,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) { }, expectedAllocatedCIDR: map[int]string{ 0: "10.10.1.0/24", - 1: "2300:5800:2:1::/112", + 1: "2300:5800:2:1:0:c::/112", }, }, { @@ -449,7 +495,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) { }, expectedAllocatedCIDR: map[int]string{ 0: "127.123.234.0/30", - 1: "2300:5800:2:1::/112", + 1: "2300:5800:2:1:0:c::/112", }, }, { @@ -492,7 +538,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) { // it should return first /30 CIDR after service range expectedAllocatedCIDR: map[int]string{ 0: "127.123.234.64/30", - 1: "2300:5800:2:1::/112", + 1: "2300:5800:2:1:0:c::/112", }, }, { @@ -537,7 +583,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) { }, expectedAllocatedCIDR: map[int]string{ 0: "127.123.234.76/30", - 1: "2300:5800:2:1::/112", + 1: "2300:5800:2:1:0:c::/112", }, }, { @@ -610,7 +656,7 @@ func TestAllocateOrOccupyCIDRSuccess(t *testing.T) { }, expectedAllocatedCIDR: map[int]string{ 0: "10.10.1.0/24", - 1: "2300:5800:2:1::/112", + 1: "2300:5800:2:1:0:c::/112", }, }, } diff --git a/docs/configuration/nodeipam.md b/docs/configuration/nodeipam.md index 3737d47b..5b0e3920 100644 --- a/docs/configuration/nodeipam.md +++ b/docs/configuration/nodeipam.md @@ -21,7 +21,7 @@ Note: 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. ## Allocated subnet size -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. +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. ```yaml spec: @@ -33,7 +33,7 @@ spec: - --allocate-node-cidrs=true - --cluster-cidr=10.192.0.0/10 - --node-cidr-mask-size-ipv4=25 - - --node-cidr-mask-size-ipv6=64 + - --node-cidr-mask-size-ipv6=112 ``` ## Disabling ipv6 ipam allocation