Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ go.work.sum
.scannerwork/
.coverage/
.golangci-bin/
vendor
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,9 @@ spec:
type: array
ips:
description: |-
List of reserved IPs.
Supported formats include: ["192.168.1.1", "192.168.1.3-192.168.1.100", "192.168.2.0/28"]
List of reserved IPs for both IPv4 and IPv6.
Supported formats include: ["192.168.1.1", "192.168.1.3-192.168.1.100", "192.168.2.0/28",
"2001:db8::1", "2001:db8::1-2001:db8::ff", "2001:db8::/64"]
items:
type: string
type: array
Expand Down
48 changes: 47 additions & 1 deletion build/yaml/samples/nsx_v1alpha1_subnetipreservation.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,53 @@
---
# Example 1: Dynamic IPv4 reservation (numberOfIPs mode, default)
apiVersion: crd.nsx.vmware.com/v1alpha1
kind: SubnetIPReservation
metadata:
name: subnet-ipa-1
name: subnet-ipr-ipv4
spec:
subnet: subnet-1
numberOfIPs: 10
---
# Example 2: Dynamic IPv6 reservation (numberOfIPs mode)
apiVersion: crd.nsx.vmware.com/v1alpha1
kind: SubnetIPReservation
metadata:
name: subnet-ipr-ipv6
spec:
subnet: subnet-1-ipv6
numberOfIPs: 10
ipAddressType: IPV6
---
# Example 3: Dynamic dual-stack reservation — reserves 5 IPv4 IPs and 5 IPv6 IPs
apiVersion: crd.nsx.vmware.com/v1alpha1
kind: SubnetIPReservation
metadata:
name: subnet-ipr-dualstack
spec:
subnet: subnet-1-dualstack
numberOfIPs: 5
ipAddressType: IPV4IPV6
---
# Example 4: Static IPv4 reservation (reservedIPs mode)
apiVersion: crd.nsx.vmware.com/v1alpha1
kind: SubnetIPReservation
metadata:
name: subnet-sipr-ipv4
spec:
subnet: subnet-1
reservedIPs:
- "192.168.1.1"
- "192.168.1.3-192.168.1.100"
- "192.168.2.0/28"
---
# Example 5: Static IPv6 reservation (reservedIPs mode)
apiVersion: crd.nsx.vmware.com/v1alpha1
kind: SubnetIPReservation
metadata:
name: subnet-sipr-ipv6
spec:
subnet: subnet-1-ipv6
reservedIPs:
- "2001:db8::1"
- "2001:db8::10-2001:db8::ff"
- "2001:db8:1::/64"
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ require (
github.com/vmware/govmomi v0.53.1
github.com/vmware/vsphere-automation-sdk-go/lib v0.8.0
github.com/vmware/vsphere-automation-sdk-go/runtime v0.8.0
github.com/vmware/vsphere-automation-sdk-go/services/nsxt v0.0.0-20260310075027-d32fca6a7b22
github.com/vmware/vsphere-automation-sdk-go/services/nsxt-mp v0.0.0-20260310075027-d32fca6a7b22
github.com/vmware/vsphere-automation-sdk-go/services/nsxt v0.0.0-20260506074423-13747423203f
github.com/vmware/vsphere-automation-sdk-go/services/nsxt-mp v0.0.0-20260506074423-13747423203f
go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.50.0
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,10 @@ github.com/vmware/vsphere-automation-sdk-go/lib v0.8.0 h1:u1SXOTM6D4Ygb3jeidj2Rd
github.com/vmware/vsphere-automation-sdk-go/lib v0.8.0/go.mod h1:8d5JTwjpM/Z03n/IZb0fwmXkJNWvWwuLXBqoakqYio4=
github.com/vmware/vsphere-automation-sdk-go/runtime v0.8.0 h1:KnDIX9LY0nru7iMQTg0sy9vChhyorPo5OdASM2MaAcI=
github.com/vmware/vsphere-automation-sdk-go/runtime v0.8.0/go.mod h1:DzLetYAmw1+vj7bqElRWEpuy40WYE/woL3alsymYa/c=
github.com/vmware/vsphere-automation-sdk-go/services/nsxt v0.0.0-20260310075027-d32fca6a7b22 h1:yDMJj+UG0u9aDdC0Q1byw8QEjfPd8gm7QKB2mo2oU1I=
github.com/vmware/vsphere-automation-sdk-go/services/nsxt v0.0.0-20260310075027-d32fca6a7b22/go.mod h1:C3JVOHRVLrGBQ8kTWAiGYlRz5UQC5qAcTdt3tvA+5P0=
github.com/vmware/vsphere-automation-sdk-go/services/nsxt-mp v0.0.0-20260310075027-d32fca6a7b22 h1:SKbUc9p+LFUwtPvjk9WCwrjstN6NpewgPx4eWSIZq+k=
github.com/vmware/vsphere-automation-sdk-go/services/nsxt-mp v0.0.0-20260310075027-d32fca6a7b22/go.mod h1:ugk9I4YM62SSAox57l5NAVBCRIkPQ1RNLb3URxyTADc=
github.com/vmware/vsphere-automation-sdk-go/services/nsxt v0.0.0-20260506074423-13747423203f h1:HvbZGTOUm9rJDG7ngNQSd5UC5ikiZI/M3cUai8u5+Jg=
github.com/vmware/vsphere-automation-sdk-go/services/nsxt v0.0.0-20260506074423-13747423203f/go.mod h1:C3JVOHRVLrGBQ8kTWAiGYlRz5UQC5qAcTdt3tvA+5P0=
github.com/vmware/vsphere-automation-sdk-go/services/nsxt-mp v0.0.0-20260506074423-13747423203f h1:dzC9XLdl0fdqZB/K97m/NMY9o/voA2Qa4shHRnK900Q=
github.com/vmware/vsphere-automation-sdk-go/services/nsxt-mp v0.0.0-20260506074423-13747423203f/go.mod h1:fDH7JI080OD5t6TGwjJx3mMX/g6W7t6Radlome6hze8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
Expand Down
5 changes: 3 additions & 2 deletions pkg/apis/vpc/v1alpha1/subnetipreservation_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ type SubnetIPReservationStatus struct {
// Conditions described if the SubnetIPReservation is configured on NSX or not.
// Condition type ""
Conditions []Condition `json:"conditions,omitempty"`
// List of reserved IPs.
// Supported formats include: ["192.168.1.1", "192.168.1.3-192.168.1.100", "192.168.2.0/28"]
// List of reserved IPs for both IPv4 and IPv6.
// Supported formats include: ["192.168.1.1", "192.168.1.3-192.168.1.100", "192.168.2.0/28",
// "2001:db8::1", "2001:db8::1-2001:db8::ff", "2001:db8::/64"]
IPs []string `json:"ips,omitempty"`
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"reflect"
"strings"
"time"

v1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -239,6 +240,15 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
return common.ResultNormal, nil
}

// For dynamic reservations (numberOfIPs), validate that the requested IP address family is
// supported by the parent Subnet.
if ipReservationCR.Spec.NumberOfIPs > 0 {
if err := validateIPAddressTypeCompatibility(subnetCR.Spec.IPAddressType, ipReservationCR.Spec.IPAddressType); err != nil {
r.StatusUpdater.UpdateFail(ctx, ipReservationCR, err, err.Error(), setReadyStatusFalse)
return common.ResultNormal, nil
}
}

nsxSubnet, err := r.SubnetService.GetSubnetByCR(subnetCR)
if err != nil {
log.Error(err, "failed to get NSX Subnet", "Namespace", subnetCR.Namespace, "Subnet", subnetCR.Name)
Expand Down Expand Up @@ -301,6 +311,38 @@ func (r *Reconciler) validateSubnet(ctx context.Context, ns, name string) (*v1al
return subnetCR, nil
}

// normalizeIPAddressType converts any valid IPAddressType value to its canonical Go-constant form.
// It accepts both the current mixed-case CRD enum values ("IPv4"/"IPv6"/"IPv4IPv6") and the
// legacy all-caps values ("IPV4"/"IPV6"/"IPV4IPV6") that may be stored in older Subnet CRs.
// An empty string defaults to IPv4.
func normalizeIPAddressType(t v1alpha1.IPAddressType) v1alpha1.IPAddressType {
switch strings.ToUpper(string(t)) {
case "IPV6":
return v1alpha1.IPAddressTypeIPv6
case "IPV4IPV6":
return v1alpha1.IPAddressTypeIPv4IPv6
default:
return v1alpha1.IPAddressTypeIPv4
}
}

// validateIPAddressTypeCompatibility checks that the SubnetIPReservation's IPAddressType is
// compatible with the parent Subnet's IPAddressType. This validation only applies to dynamic
// reservations (numberOfIPs mode); static reservations (reservedIPs mode) specify IPs directly.
// Both all-caps legacy values and mixed-case CRD enum values are accepted.
func validateIPAddressTypeCompatibility(subnetIPAddressType, reservationIPAddressType v1alpha1.IPAddressType) error {
normalizedSubnet := normalizeIPAddressType(subnetIPAddressType)
normalizedReservation := normalizeIPAddressType(reservationIPAddressType)
// A dual-stack Subnet supports all reservation IP families.
if normalizedSubnet == v1alpha1.IPAddressTypeIPv4IPv6 {
return nil
}
if normalizedSubnet != normalizedReservation {
return fmt.Errorf("SubnetIPReservation IPAddressType %q is incompatible with Subnet IPAddressType %q", reservationIPAddressType, subnetIPAddressType)
}
return nil
}

func setReadyStatusTrue(client client.Client, ctx context.Context, obj client.Object, transitionTime metav1.Time, _ ...interface{}) {
ipReservationCR := obj.(*v1alpha1.SubnetIPReservation)
newConditions := []v1alpha1.Condition{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,32 @@ func TestReconciler_Reconcile(t *testing.T) {
},
expectedResult: common.ResultNormal,
},
{
name: "IPAddressType incompatible with Subnet",
objects: []client.Object{&v1alpha1.SubnetIPReservation{
ObjectMeta: metav1.ObjectMeta{Name: "ipr-1", Namespace: "ns-1"},
Spec: v1alpha1.SubnetIPReservationSpec{
Subnet: "subnet-1",
NumberOfIPs: 5,
// Use the CRD enum value ("IPV6") that Kubernetes actually stores,
// not the Go constant IPAddressTypeIPv6 = "IPv6".
IPAddressType: "IPV6",
},
}},
preparedFunc: func(r *Reconciler) *gomonkey.Patches {
patches := gomonkey.ApplyPrivateMethod(reflect.TypeOf(r), "validateSubnet", func(_ *Reconciler, ctx context.Context, ns string, name string) (*v1alpha1.Subnet, *errorWithRetry) {
return &v1alpha1.Subnet{
// Use the CRD enum value ("IPV4") that Kubernetes actually stores.
Spec: v1alpha1.SubnetSpec{IPAddressType: "IPV4"},
}, nil
})
patches.ApplyMethod(reflect.TypeOf(r.IPReservationService.NSXClient), "NSXCheckVersion", func(_ *nsx.Client, feature int) bool {
return true
})
return patches
},
expectedResult: common.ResultNormal,
},
}
for _, tc := range tests {
r := createFakeReconciler(tc.objects...)
Expand Down Expand Up @@ -508,6 +534,111 @@ func TestReconcile_validateSubnet(t *testing.T) {
}
}

func TestValidateIPAddressTypeCompatibility(t *testing.T) {
// All inputs use the CRD enum values ("IPV4"/"IPV6"/"IPV4IPV6", all-caps) that Kubernetes
// actually stores — NOT the v1alpha1 Go constants which use a different casing ("IPv4", etc.).
tests := []struct {
name string
subnetType v1alpha1.IPAddressType
reservationType v1alpha1.IPAddressType
expectedErrSubstr string
}{
{
name: "IPv4 subnet with IPv4 reservation",
subnetType: "IPV4",
reservationType: "IPV4",
},
{
name: "IPv4 subnet with empty reservation defaults to IPv4",
subnetType: "IPV4",
reservationType: "",
},
{
name: "IPv4 subnet with IPv6 reservation - incompatible",
subnetType: "IPV4",
reservationType: "IPV6",
expectedErrSubstr: "incompatible",
},
{
name: "IPv4 subnet with dual-stack reservation - incompatible",
subnetType: "IPV4",
reservationType: "IPV4IPV6",
expectedErrSubstr: "incompatible",
},
{
name: "IPv6 subnet with IPv6 reservation",
subnetType: "IPV6",
reservationType: "IPV6",
},
{
name: "IPv6 subnet with IPv4 reservation - incompatible",
subnetType: "IPV6",
reservationType: "IPV4",
expectedErrSubstr: "incompatible",
},
{
name: "Dual-stack subnet with IPv4 reservation",
subnetType: "IPV4IPV6",
reservationType: "IPV4",
},
{
name: "Dual-stack subnet with IPv6 reservation",
subnetType: "IPV4IPV6",
reservationType: "IPV6",
},
{
name: "Dual-stack subnet with dual-stack reservation",
subnetType: "IPV4IPV6",
reservationType: "IPV4IPV6",
},
{
name: "Empty subnet type defaults to IPv4, IPv4 reservation",
subnetType: "",
reservationType: "IPV4",
},
{
name: "Empty subnet type defaults to IPv4, IPv6 reservation - incompatible",
subnetType: "",
reservationType: "IPV6",
expectedErrSubstr: "incompatible",
},
// Cross-casing: legacy all-caps Subnet vs mixed-case SubnetIPReservation (the real-world bug).
{
name: "Legacy IPV6 subnet (all-caps) with mixed-case IPv6 reservation",
subnetType: "IPV6",
reservationType: "IPv6",
},
{
name: "Legacy IPV4 subnet (all-caps) with mixed-case IPv4 reservation",
subnetType: "IPV4",
reservationType: "IPv4",
},
{
name: "Legacy IPV4 subnet (all-caps) with mixed-case IPv6 reservation - incompatible",
subnetType: "IPV4",
reservationType: "IPv6",
expectedErrSubstr: "incompatible",
},
{
name: "Legacy IPV4IPV6 subnet (all-caps) with mixed-case IPv6 reservation",
subnetType: "IPV4IPV6",
reservationType: "IPv6",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateIPAddressTypeCompatibility(tc.subnetType, tc.reservationType)
if tc.expectedErrSubstr != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrSubstr)
} else {
assert.NoError(t, err)
}
})
}
}

func TestReconcile_CollectGarbage(t *testing.T) {
ipr1 := &v1alpha1.SubnetIPReservation{
ObjectMeta: metav1.ObjectMeta{
Expand Down
42 changes: 30 additions & 12 deletions pkg/nsx/services/ipblocksinfo/ipblocksinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,11 @@ func (s *IPBlocksInfoService) ResetPeriodicSync() {
}

// mergeIPCidrs merges target CIDRs into source CIDRs if not already covered by source.
// Only considers IPv4, assumes no overlaps and all CIDRs are valid.
// Assume there were no duplicate cidr in target,
// None of the elements in target will be a subset of another element
// consider using radix tree or sort + binary search for large scale
// Supports both IPv4 and IPv6 CIDRs. IPv4 and IPv6 CIDRs are treated as disjoint address
// spaces and will never be considered to cover each other.
// Assumes no overlaps within source and all CIDRs are valid.
// Assumes there are no duplicate CIDRs in target, and no element in target is a subset of another.
// Consider using a radix tree or sort + binary search for large-scale inputs.
func (s *IPBlocksInfoService) mergeIPCidrs(source []string, target []string) []string {
if len(source) == 0 {
return target
Expand Down Expand Up @@ -258,14 +259,26 @@ func (s *IPBlocksInfoService) getSharedSubnetsCIDRs(vpcConfigList []v1alpha1.VPC
continue
}

switch *subnet.AccessMode {
case model.VpcSubnet_ACCESS_MODE_PUBLIC:
externalIPCIDRs = append(externalIPCIDRs, subnet.IpAddresses...)

case model.VpcSubnet_ACCESS_MODE_PRIVATE_TGW:
project := fmt.Sprintf("/orgs/%s/projects/%s", vpcInfo.OrgID, vpcInfo.ProjectID)
if project == s.defaultProject {
privateTGWIPCIDRs = append(privateTGWIPCIDRs, subnet.IpAddresses...)
for _, cidr := range subnet.IpAddresses {
ip, _, parseErr := net.ParseCIDR(cidr)
if parseErr != nil {
log.Warn("failed to parse subnet CIDR", "cidr", cidr, "err", parseErr)
continue
}
if ip.To4() == nil {
// IPv6 CIDRs are always public with no access mode
externalIPCIDRs = append(externalIPCIDRs, cidr)
continue
}
// IPv4: apply access mode check
switch *subnet.AccessMode {
case model.VpcSubnet_ACCESS_MODE_PUBLIC:
externalIPCIDRs = append(externalIPCIDRs, cidr)
case model.VpcSubnet_ACCESS_MODE_PRIVATE_TGW:
project := fmt.Sprintf("/orgs/%s/projects/%s", vpcInfo.OrgID, vpcInfo.ProjectID)
if project == s.defaultProject {
privateTGWIPCIDRs = append(privateTGWIPCIDRs, cidr)
}
}
}
}
Expand Down Expand Up @@ -370,6 +383,11 @@ func (s *IPBlocksInfoService) getIPBlockCIDRsByVPCConfig(vpcConfigList []v1alpha
for _, externalIPBlock := range vpcConnectivityProfile.ExternalIpBlocks {
externalIPBlockPaths.Insert(externalIPBlock)
}
// Ipv6Blocks are external-visibility IPv6 blocks; merge them into the external set so their
// CIDRs/ranges appear in ExternalIPCIDRs/ExternalIPRanges without any CRD schema change.
for _, ipv6Block := range vpcConnectivityProfile.Ipv6Blocks {
externalIPBlockPaths.Insert(ipv6Block)
}
// save private_tgw_ip_blocks path in set for profile associated with default project
if isDefault {
for _, privateTgwIpBlocks := range vpcConnectivityProfile.PrivateTgwIpBlocks {
Expand Down
Loading
Loading