Skip to content

Commit 4ef05d7

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

7 files changed

Lines changed: 279 additions & 53 deletions

File tree

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: 30 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,27 @@ 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+
// Normalize empty/unset values to IPv4 defaults.
318+
if subnetIPAddressType == "" {
319+
subnetIPAddressType = v1alpha1.IPAddressTypeIPv4
320+
}
321+
if reservationIPAddressType == "" {
322+
reservationIPAddressType = v1alpha1.IPAddressTypeIPv4
323+
}
324+
// A dual-stack Subnet supports all reservation IP families.
325+
if subnetIPAddressType == v1alpha1.IPAddressTypeIPv4IPv6 {
326+
return nil
327+
}
328+
if subnetIPAddressType != reservationIPAddressType {
329+
return fmt.Errorf("SubnetIPReservation IPAddressType %q is incompatible with Subnet IPAddressType %q", reservationIPAddressType, subnetIPAddressType)
330+
}
331+
return nil
332+
}
333+
304334
func setReadyStatusTrue(client client.Client, ctx context.Context, obj client.Object, transitionTime metav1.Time, _ ...interface{}) {
305335
ipReservationCR := obj.(*v1alpha1.SubnetIPReservation)
306336
newConditions := []v1alpha1.Condition{

pkg/controllers/subnetipreservation/subnetipreservation_controller_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,29 @@ 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+
IPAddressType: v1alpha1.IPAddressTypeIPv6,
409+
},
410+
}},
411+
preparedFunc: func(r *Reconciler) *gomonkey.Patches {
412+
patches := gomonkey.ApplyPrivateMethod(reflect.TypeOf(r), "validateSubnet", func(_ *Reconciler, ctx context.Context, ns string, name string) (*v1alpha1.Subnet, *errorWithRetry) {
413+
return &v1alpha1.Subnet{
414+
Spec: v1alpha1.SubnetSpec{IPAddressType: v1alpha1.IPAddressTypeIPv4},
415+
}, nil
416+
})
417+
patches.ApplyMethod(reflect.TypeOf(r.IPReservationService.NSXClient), "NSXCheckVersion", func(_ *nsx.Client, feature int) bool {
418+
return true
419+
})
420+
return patches
421+
},
422+
expectedResult: common.ResultNormal,
423+
},
401424
}
402425
for _, tc := range tests {
403426
r := createFakeReconciler(tc.objects...)
@@ -508,6 +531,87 @@ func TestReconcile_validateSubnet(t *testing.T) {
508531
}
509532
}
510533

534+
func TestValidateIPAddressTypeCompatibility(t *testing.T) {
535+
tests := []struct {
536+
name string
537+
subnetType v1alpha1.IPAddressType
538+
reservationType v1alpha1.IPAddressType
539+
expectedErrSubstr string
540+
}{
541+
{
542+
name: "IPv4 subnet with IPv4 reservation",
543+
subnetType: v1alpha1.IPAddressTypeIPv4,
544+
reservationType: v1alpha1.IPAddressTypeIPv4,
545+
},
546+
{
547+
name: "IPv4 subnet with empty reservation defaults to IPv4",
548+
subnetType: v1alpha1.IPAddressTypeIPv4,
549+
reservationType: "",
550+
},
551+
{
552+
name: "IPv4 subnet with IPv6 reservation - incompatible",
553+
subnetType: v1alpha1.IPAddressTypeIPv4,
554+
reservationType: v1alpha1.IPAddressTypeIPv6,
555+
expectedErrSubstr: "incompatible",
556+
},
557+
{
558+
name: "IPv4 subnet with dual-stack reservation - incompatible",
559+
subnetType: v1alpha1.IPAddressTypeIPv4,
560+
reservationType: v1alpha1.IPAddressTypeIPv4IPv6,
561+
expectedErrSubstr: "incompatible",
562+
},
563+
{
564+
name: "IPv6 subnet with IPv6 reservation",
565+
subnetType: v1alpha1.IPAddressTypeIPv6,
566+
reservationType: v1alpha1.IPAddressTypeIPv6,
567+
},
568+
{
569+
name: "IPv6 subnet with IPv4 reservation - incompatible",
570+
subnetType: v1alpha1.IPAddressTypeIPv6,
571+
reservationType: v1alpha1.IPAddressTypeIPv4,
572+
expectedErrSubstr: "incompatible",
573+
},
574+
{
575+
name: "Dual-stack subnet with IPv4 reservation",
576+
subnetType: v1alpha1.IPAddressTypeIPv4IPv6,
577+
reservationType: v1alpha1.IPAddressTypeIPv4,
578+
},
579+
{
580+
name: "Dual-stack subnet with IPv6 reservation",
581+
subnetType: v1alpha1.IPAddressTypeIPv4IPv6,
582+
reservationType: v1alpha1.IPAddressTypeIPv6,
583+
},
584+
{
585+
name: "Dual-stack subnet with dual-stack reservation",
586+
subnetType: v1alpha1.IPAddressTypeIPv4IPv6,
587+
reservationType: v1alpha1.IPAddressTypeIPv4IPv6,
588+
},
589+
{
590+
name: "Empty subnet type defaults to IPv4, IPv4 reservation",
591+
subnetType: "",
592+
reservationType: v1alpha1.IPAddressTypeIPv4,
593+
},
594+
{
595+
name: "Empty subnet type defaults to IPv4, IPv6 reservation - incompatible",
596+
subnetType: "",
597+
reservationType: v1alpha1.IPAddressTypeIPv6,
598+
expectedErrSubstr: "incompatible",
599+
},
600+
}
601+
602+
for _, tc := range tests {
603+
t.Run(tc.name, func(t *testing.T) {
604+
err := validateIPAddressTypeCompatibility(tc.subnetType, tc.reservationType)
605+
if tc.expectedErrSubstr != "" {
606+
assert.Error(t, err)
607+
assert.Contains(t, err.Error(), tc.expectedErrSubstr)
608+
} else {
609+
assert.NoError(t, err)
610+
}
611+
})
612+
}
613+
}
614+
511615
func TestReconcile_CollectGarbage(t *testing.T) {
512616
ipr1 := &v1alpha1.SubnetIPReservation{
513617
ObjectMeta: metav1.ObjectMeta{

pkg/nsx/services/subnetipreservation/builder.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,35 @@ 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 the CRD IPAddressType to the NSX DynamicIpAddressReservation IpAddressType.
22+
func ipAddressTypeToNSX(ipAddressType v1alpha1.IPAddressType) string {
23+
switch ipAddressType {
24+
case v1alpha1.IPAddressTypeIPv6:
25+
return nsxIPAddressTypeIPv6
26+
case v1alpha1.IPAddressTypeIPv4IPv6:
27+
return nsxIPAddressTypeIPv4IPv6
28+
default:
29+
return nsxIPAddressTypeIPv4
30+
}
31+
}
32+
1333
func (s *IPReservationService) buildDynamicIPReservation(ipReservation *v1alpha1.SubnetIPReservation, subnetPath string) *model.DynamicIpAddressReservation {
1434
tags := util.BuildBasicTags(getCluster(s), ipReservation, "")
35+
ipAddressType := ipAddressTypeToNSX(ipReservation.Spec.IPAddressType)
1536
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),
37+
NumberOfIps: common.Int64(int64(ipReservation.Spec.NumberOfIPs)),
38+
Tags: tags,
39+
Id: common.String(s.buildIPReservationID(ipReservation, subnetPath)),
40+
DisplayName: common.String(ipReservation.Name),
41+
IpAddressType: &ipAddressType,
2042
}
2143
return nsxIPReservation
2244
}

0 commit comments

Comments
 (0)