Skip to content

Commit 8cce586

Browse files
committed
Add IPv6 and dual-stack support for SubnetIPReservation
Signed-off-by: Wenqi Qiu <wenqi.qiu@broadcom.com>
1 parent a590014 commit 8cce586

8 files changed

Lines changed: 321 additions & 56 deletions

File tree

Makefile

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ ENVTEST_K8S_VERSION = 1.22
55
LDFLAGS :=
66
GOFLAGS :=
77
BINDIR ?= $(CURDIR)/bin
8-
GO_FILES := $(shell find . -type d -name '.cache' -prune -o -type f -name '*.go' -print)
9-
108
GOLANGCI_LINT_VERSION := v2.11.4
119
GOLANGCI_LINT_BINDIR := $(CURDIR)/.golangci-bin
1210
GOLANGCI_LINT_BIN := $(GOLANGCI_LINT_BINDIR)/$(GOLANGCI_LINT_VERSION)/golangci-lint
@@ -56,7 +54,7 @@ generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and
5654
fmt: ## Run go fmt against code.
5755
@echo
5856
@echo "===> Formatting Go files <==="
59-
@gofmt -s -l -w $(GO_FILES)
57+
@go fmt ./...
6058

6159
.PHONY: vet
6260
vet: ## Run go vet against code.

build/yaml/crd/vpc/crd.nsx.vmware.com_subnetipreservations.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,9 @@ spec:
145145
type: array
146146
ips:
147147
description: |-
148-
List of reserved IPs.
149-
Supported formats include: ["192.168.1.1", "192.168.1.3-192.168.1.100", "192.168.2.0/28"]
148+
List of reserved IPs for both IPv4 and IPv6.
149+
Supported formats include: ["192.168.1.1", "192.168.1.3-192.168.1.100", "192.168.2.0/28",
150+
"2001:db8::1", "2001:db8::1-2001:db8::ff", "2001:db8::/64"]
150151
items:
151152
type: string
152153
type: array
Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,53 @@
1+
---
2+
# Example 1: Dynamic IPv4 reservation (numberOfIPs mode, default)
13
apiVersion: crd.nsx.vmware.com/v1alpha1
24
kind: SubnetIPReservation
35
metadata:
4-
name: subnet-ipa-1
6+
name: subnet-ipr-ipv4
57
spec:
68
subnet: subnet-1
79
numberOfIPs: 10
10+
---
11+
# Example 2: Dynamic IPv6 reservation (numberOfIPs mode)
12+
apiVersion: crd.nsx.vmware.com/v1alpha1
13+
kind: SubnetIPReservation
14+
metadata:
15+
name: subnet-ipr-ipv6
16+
spec:
17+
subnet: subnet-1-ipv6
18+
numberOfIPs: 10
19+
ipAddressType: IPV6
20+
---
21+
# Example 3: Dynamic dual-stack reservation — reserves 5 IPv4 IPs and 5 IPv6 IPs
22+
apiVersion: crd.nsx.vmware.com/v1alpha1
23+
kind: SubnetIPReservation
24+
metadata:
25+
name: subnet-ipr-dualstack
26+
spec:
27+
subnet: subnet-1-dualstack
28+
numberOfIPs: 5
29+
ipAddressType: IPV4IPV6
30+
---
31+
# Example 4: Static IPv4 reservation (reservedIPs mode)
32+
apiVersion: crd.nsx.vmware.com/v1alpha1
33+
kind: SubnetIPReservation
34+
metadata:
35+
name: subnet-sipr-ipv4
36+
spec:
37+
subnet: subnet-1
38+
reservedIPs:
39+
- "192.168.1.1"
40+
- "192.168.1.3-192.168.1.100"
41+
- "192.168.2.0/28"
42+
---
43+
# Example 5: Static IPv6 reservation (reservedIPs mode)
44+
apiVersion: crd.nsx.vmware.com/v1alpha1
45+
kind: SubnetIPReservation
46+
metadata:
47+
name: subnet-sipr-ipv6
48+
spec:
49+
subnet: subnet-1-ipv6
50+
reservedIPs:
51+
- "2001:db8::1"
52+
- "2001:db8::10-2001:db8::ff"
53+
- "2001:db8:1::/64"

pkg/apis/vpc/v1alpha1/subnetipreservation_types.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ type SubnetIPReservationStatus struct {
4545
// Conditions described if the SubnetIPReservation is configured on NSX or not.
4646
// Condition type ""
4747
Conditions []Condition `json:"conditions,omitempty"`
48-
// List of reserved IPs.
49-
// Supported formats include: ["192.168.1.1", "192.168.1.3-192.168.1.100", "192.168.2.0/28"]
48+
// List of reserved IPs for both IPv4 and IPv6.
49+
// Supported formats include: ["192.168.1.1", "192.168.1.3-192.168.1.100", "192.168.2.0/28",
50+
// "2001:db8::1", "2001:db8::1-2001:db8::ff", "2001:db8::/64"]
5051
IPs []string `json:"ips,omitempty"`
5152
}
5253

pkg/controllers/subnetipreservation/subnetipreservation_controller.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
239239
return common.ResultNormal, nil
240240
}
241241

242+
// For dynamic reservations (numberOfIPs), validate that the requested IP address family is
243+
// supported by the parent Subnet.
244+
if ipReservationCR.Spec.NumberOfIPs > 0 {
245+
if err := validateIPAddressTypeCompatibility(subnetCR.Spec.IPAddressType, ipReservationCR.Spec.IPAddressType); err != nil {
246+
r.StatusUpdater.UpdateFail(ctx, ipReservationCR, err, err.Error(), setReadyStatusFalse)
247+
return common.ResultNormal, nil
248+
}
249+
}
250+
242251
nsxSubnet, err := r.SubnetService.GetSubnetByCR(subnetCR)
243252
if err != nil {
244253
log.Error(err, "failed to get NSX Subnet", "Namespace", subnetCR.Namespace, "Subnet", subnetCR.Name)
@@ -301,6 +310,20 @@ func (r *Reconciler) validateSubnet(ctx context.Context, ns, name string) (*v1al
301310
return subnetCR, nil
302311
}
303312

313+
// validateIPAddressTypeCompatibility checks that the SubnetIPReservation's IPAddressType is
314+
// compatible with the parent Subnet's IPAddressType. This validation only applies to dynamic
315+
// reservations (numberOfIPs mode); static reservations (reservedIPs mode) specify IPs directly.
316+
func validateIPAddressTypeCompatibility(subnetIPAddressType, reservationIPAddressType v1alpha1.IPAddressType) error {
317+
// A dual-stack Subnet supports all reservation IP families.
318+
if subnetIPAddressType == v1alpha1.IPAddressTypeIPv4IPv6 {
319+
return nil
320+
}
321+
if subnetIPAddressType != reservationIPAddressType {
322+
return fmt.Errorf("SubnetIPReservation IPAddressType %q is incompatible with Subnet IPAddressType %q", reservationIPAddressType, subnetIPAddressType)
323+
}
324+
return nil
325+
}
326+
304327
func setReadyStatusTrue(client client.Client, ctx context.Context, obj client.Object, transitionTime metav1.Time, _ ...interface{}) {
305328
ipReservationCR := obj.(*v1alpha1.SubnetIPReservation)
306329
newConditions := []v1alpha1.Condition{

pkg/controllers/subnetipreservation/subnetipreservation_controller_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,32 @@ func TestReconciler_Reconcile(t *testing.T) {
398398
},
399399
expectedResult: common.ResultNormal,
400400
},
401+
{
402+
name: "IPAddressType incompatible with Subnet",
403+
objects: []client.Object{&v1alpha1.SubnetIPReservation{
404+
ObjectMeta: metav1.ObjectMeta{Name: "ipr-1", Namespace: "ns-1"},
405+
Spec: v1alpha1.SubnetIPReservationSpec{
406+
Subnet: "subnet-1",
407+
NumberOfIPs: 5,
408+
// Use the CRD enum value ("IPV6") that Kubernetes actually stores,
409+
// not the Go constant IPAddressTypeIPv6 = "IPv6".
410+
IPAddressType: "IPV6",
411+
},
412+
}},
413+
preparedFunc: func(r *Reconciler) *gomonkey.Patches {
414+
patches := gomonkey.ApplyPrivateMethod(reflect.TypeOf(r), "validateSubnet", func(_ *Reconciler, ctx context.Context, ns string, name string) (*v1alpha1.Subnet, *errorWithRetry) {
415+
return &v1alpha1.Subnet{
416+
// Use the CRD enum value ("IPV4") that Kubernetes actually stores.
417+
Spec: v1alpha1.SubnetSpec{IPAddressType: "IPV4"},
418+
}, nil
419+
})
420+
patches.ApplyMethod(reflect.TypeOf(r.IPReservationService.NSXClient), "NSXCheckVersion", func(_ *nsx.Client, feature int) bool {
421+
return true
422+
})
423+
return patches
424+
},
425+
expectedResult: common.ResultNormal,
426+
},
401427
}
402428
for _, tc := range tests {
403429
r := createFakeReconciler(tc.objects...)
@@ -508,6 +534,111 @@ func TestReconcile_validateSubnet(t *testing.T) {
508534
}
509535
}
510536

537+
func TestValidateIPAddressTypeCompatibility(t *testing.T) {
538+
// All inputs use the CRD enum values ("IPV4"/"IPV6"/"IPV4IPV6", all-caps) that Kubernetes
539+
// actually stores — NOT the v1alpha1 Go constants which use a different casing ("IPv4", etc.).
540+
tests := []struct {
541+
name string
542+
subnetType v1alpha1.IPAddressType
543+
reservationType v1alpha1.IPAddressType
544+
expectedErrSubstr string
545+
}{
546+
{
547+
name: "IPv4 subnet with IPv4 reservation",
548+
subnetType: "IPV4",
549+
reservationType: "IPV4",
550+
},
551+
{
552+
name: "IPv4 subnet with empty reservation defaults to IPv4",
553+
subnetType: "IPV4",
554+
reservationType: "",
555+
},
556+
{
557+
name: "IPv4 subnet with IPv6 reservation - incompatible",
558+
subnetType: "IPV4",
559+
reservationType: "IPV6",
560+
expectedErrSubstr: "incompatible",
561+
},
562+
{
563+
name: "IPv4 subnet with dual-stack reservation - incompatible",
564+
subnetType: "IPV4",
565+
reservationType: "IPV4IPV6",
566+
expectedErrSubstr: "incompatible",
567+
},
568+
{
569+
name: "IPv6 subnet with IPv6 reservation",
570+
subnetType: "IPV6",
571+
reservationType: "IPV6",
572+
},
573+
{
574+
name: "IPv6 subnet with IPv4 reservation - incompatible",
575+
subnetType: "IPV6",
576+
reservationType: "IPV4",
577+
expectedErrSubstr: "incompatible",
578+
},
579+
{
580+
name: "Dual-stack subnet with IPv4 reservation",
581+
subnetType: "IPV4IPV6",
582+
reservationType: "IPV4",
583+
},
584+
{
585+
name: "Dual-stack subnet with IPv6 reservation",
586+
subnetType: "IPV4IPV6",
587+
reservationType: "IPV6",
588+
},
589+
{
590+
name: "Dual-stack subnet with dual-stack reservation",
591+
subnetType: "IPV4IPV6",
592+
reservationType: "IPV4IPV6",
593+
},
594+
{
595+
name: "Empty subnet type defaults to IPv4, IPv4 reservation",
596+
subnetType: "",
597+
reservationType: "IPV4",
598+
},
599+
{
600+
name: "Empty subnet type defaults to IPv4, IPv6 reservation - incompatible",
601+
subnetType: "",
602+
reservationType: "IPV6",
603+
expectedErrSubstr: "incompatible",
604+
},
605+
// Cross-casing: legacy all-caps Subnet vs mixed-case SubnetIPReservation (the real-world bug).
606+
{
607+
name: "Legacy IPV6 subnet (all-caps) with mixed-case IPv6 reservation",
608+
subnetType: "IPV6",
609+
reservationType: "IPv6",
610+
},
611+
{
612+
name: "Legacy IPV4 subnet (all-caps) with mixed-case IPv4 reservation",
613+
subnetType: "IPV4",
614+
reservationType: "IPv4",
615+
},
616+
{
617+
name: "Legacy IPV4 subnet (all-caps) with mixed-case IPv6 reservation - incompatible",
618+
subnetType: "IPV4",
619+
reservationType: "IPv6",
620+
expectedErrSubstr: "incompatible",
621+
},
622+
{
623+
name: "Legacy IPV4IPV6 subnet (all-caps) with mixed-case IPv6 reservation",
624+
subnetType: "IPV4IPV6",
625+
reservationType: "IPv6",
626+
},
627+
}
628+
629+
for _, tc := range tests {
630+
t.Run(tc.name, func(t *testing.T) {
631+
err := validateIPAddressTypeCompatibility(tc.subnetType, tc.reservationType)
632+
if tc.expectedErrSubstr != "" {
633+
assert.Error(t, err)
634+
assert.Contains(t, err.Error(), tc.expectedErrSubstr)
635+
} else {
636+
assert.NoError(t, err)
637+
}
638+
})
639+
}
640+
}
641+
511642
func TestReconcile_CollectGarbage(t *testing.T) {
512643
ipr1 := &v1alpha1.SubnetIPReservation{
513644
ObjectMeta: metav1.ObjectMeta{

pkg/nsx/services/subnetipreservation/builder.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,36 @@ import (
1010
"github.com/vmware-tanzu/nsx-operator/pkg/util"
1111
)
1212

13+
// NSX DynamicIpAddressReservation IP address type values.
14+
// The CRD uses "IPV4IPV6" (no underscore) while NSX uses "IPV4_IPV6" (with underscore).
15+
const (
16+
nsxIPAddressTypeIPv4 = "IPV4"
17+
nsxIPAddressTypeIPv6 = "IPV6"
18+
nsxIPAddressTypeIPv4IPv6 = "IPV4_IPV6"
19+
)
20+
21+
// ipAddressTypeToNSX maps an IPAddressType to the NSX DynamicIpAddressReservation IpAddressType.
22+
// The CRD uses "IPV4IPV6" (no underscore) while NSX uses "IPV4_IPV6" (with underscore).
23+
func ipAddressTypeToNSX(ipAddressType v1alpha1.IPAddressType) string {
24+
switch ipAddressType {
25+
case v1alpha1.IPAddressTypeIPv6:
26+
return nsxIPAddressTypeIPv6
27+
case v1alpha1.IPAddressTypeIPv4IPv6:
28+
return nsxIPAddressTypeIPv4IPv6
29+
default:
30+
return nsxIPAddressTypeIPv4
31+
}
32+
}
33+
1334
func (s *IPReservationService) buildDynamicIPReservation(ipReservation *v1alpha1.SubnetIPReservation, subnetPath string) *model.DynamicIpAddressReservation {
1435
tags := util.BuildBasicTags(getCluster(s), ipReservation, "")
36+
ipAddressType := ipAddressTypeToNSX(ipReservation.Spec.IPAddressType)
1537
nsxIPReservation := &model.DynamicIpAddressReservation{
16-
NumberOfIps: common.Int64(int64(ipReservation.Spec.NumberOfIPs)),
17-
Tags: tags,
18-
Id: common.String(s.buildIPReservationID(ipReservation, subnetPath)),
19-
DisplayName: common.String(ipReservation.Name),
38+
NumberOfIps: common.Int64(int64(ipReservation.Spec.NumberOfIPs)),
39+
Tags: tags,
40+
Id: common.String(s.buildIPReservationID(ipReservation, subnetPath)),
41+
DisplayName: common.String(ipReservation.Name),
42+
IpAddressType: &ipAddressType,
2043
}
2144
return nsxIPReservation
2245
}

0 commit comments

Comments
 (0)