Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion api/v1alpha1/nutanix_clusterconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type NutanixPrismCentralEndpointCredentials struct {
//nolint:gocritic // No need for named return values
func (s NutanixPrismCentralEndpointSpec) ParseURL() (string, uint16, error) {
var prismCentralURL *url.URL
prismCentralURL, err := url.Parse(s.URL)
prismCentralURL, err := url.ParseRequestURI(s.URL)
if err != nil {
return "", 0, fmt.Errorf("error parsing Prism Central URL: %w", err)
}
Expand Down
26 changes: 26 additions & 0 deletions pkg/helpers/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2024 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package helpers

import (
"fmt"
"net/netip"
)

// IsIPInRange checks if the target IP falls within the start and end IP range (inclusive).
func IsIPInRange(startIP, endIP, targetIP string) (bool, error) {
start, err := netip.ParseAddr(startIP)
if err != nil {
return false, fmt.Errorf("invalid start IP: %w", err)
}
end, err := netip.ParseAddr(endIP)
if err != nil {
return false, fmt.Errorf("invalid end IP: %w", err)
}
target, err := netip.ParseAddr(targetIP)
if err != nil {
return false, fmt.Errorf("invalid target IP: %w", err)
}

return start.Compare(target) <= 0 && end.Compare(target) >= 0, nil
}
116 changes: 116 additions & 0 deletions pkg/helpers/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2024 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package helpers

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
)

func TestIsIPInRange(t *testing.T) {
tests := []struct {
name string
startIP string
endIP string
targetIP string
expectedInRange bool
expectedErr error
}{
{
name: "Valid range - target within range",
startIP: "192.168.1.1",
endIP: "192.168.1.10",
targetIP: "192.168.1.5",
expectedInRange: true,
expectedErr: nil,
},
{
name: "Valid range - target same as start IP",
startIP: "192.168.1.1",
endIP: "192.168.1.10",
targetIP: "192.168.1.1",
expectedInRange: true,
expectedErr: nil,
},
{
name: "Valid range - target same as end IP",
startIP: "192.168.1.1",
endIP: "192.168.1.10",
targetIP: "192.168.1.10",
expectedInRange: true,
expectedErr: nil,
},
{
name: "Valid range - target outside range",
startIP: "192.168.1.1",
endIP: "192.168.1.10",
targetIP: "192.168.1.15",
expectedInRange: false,
expectedErr: nil,
},
{
name: "Invalid start IP",
startIP: "invalid-ip",
endIP: "192.168.1.10",
targetIP: "192.168.1.5",
expectedInRange: false,
expectedErr: fmt.Errorf(
"invalid start IP: ParseAddr(%q): unable to parse IP",
"invalid-ip",
),
},
{
name: "Invalid end IP",
startIP: "192.168.1.1",
endIP: "invalid-ip",
targetIP: "192.168.1.5",
expectedInRange: false,
expectedErr: fmt.Errorf(
"invalid end IP: ParseAddr(%q): unable to parse IP",
"invalid-ip",
),
},
{
name: "Invalid target IP",
startIP: "192.168.1.1",
endIP: "192.168.1.10",
targetIP: "invalid-ip",
expectedInRange: false,
expectedErr: fmt.Errorf(
"invalid target IP: ParseAddr(%q): unable to parse IP",
"invalid-ip",
),
},
{
name: "IPv6 range - target within range",
startIP: "2001:db8::1",
endIP: "2001:db8::10",
targetIP: "2001:db8::5",
expectedInRange: true,
expectedErr: nil,
},
{
name: "IPv6 range - target outside range",
startIP: "2001:db8::1",
endIP: "2001:db8::10",
targetIP: "2001:db8::11",
expectedInRange: false,
expectedErr: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := IsIPInRange(tt.startIP, tt.endIP, tt.targetIP)
assert.Equal(t, tt.expectedInRange, got)
if tt.expectedErr != nil {
assert.EqualError(t, err, tt.expectedErr.Error())
} else {
assert.NoError(t, err)
}
})
}
}
127 changes: 127 additions & 0 deletions pkg/webhook/cluster/nutanix_validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright 2024 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package cluster

import (
"context"
"fmt"
"net"
"net/http"

v1 "k8s.io/api/admission/v1"
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/variables"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/utils"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/helpers"
)

type nutanixValidator struct {
client ctrlclient.Client
decoder admission.Decoder
}

func NewNutanixValidator(
client ctrlclient.Client, decoder admission.Decoder,
) *nutanixValidator {
return &nutanixValidator{
client: client,
decoder: decoder,
}
}

func (a *nutanixValidator) Validator() admission.HandlerFunc {
return a.validate
}

func (a *nutanixValidator) validate(
ctx context.Context,
req admission.Request,
) admission.Response {
if req.Operation == v1.Delete {
return admission.Allowed("")
}

cluster := &clusterv1.Cluster{}
err := a.decoder.Decode(req, cluster)
if err != nil {
return admission.Errored(http.StatusBadRequest, err)
}

if cluster.Spec.Topology == nil {
return admission.Allowed("")
}

if utils.GetProvider(cluster) != "nutanix" {
return admission.Allowed("")
}

clusterConfig, err := variables.UnmarshalClusterConfigVariable(cluster.Spec.Topology.Variables)
if err != nil {
return admission.Denied(
fmt.Errorf("failed to unmarshal cluster topology variable %q: %w",
v1alpha1.ClusterConfigVariableName,
err).Error(),
)
}

if clusterConfig.Nutanix != nil &&
clusterConfig.Addons != nil {
// Check if Prism Central IP is in MetalLB Load Balancer IP range.
if err := checkIfPrismCentralIPInLoadBalancerIPRange(
clusterConfig.Nutanix.PrismCentralEndpoint,
clusterConfig.Addons.ServiceLoadBalancer,
); err != nil {
return admission.Denied(err.Error())
}
}

return admission.Allowed("")
}

// checkIfPrismCentralIPInLoadBalancerIPRange checks if the Prism Central IP is in the MetalLB Load Balancer IP range.
// Errors out if Prism Central IP is in the Load Balancer IP range.
func checkIfPrismCentralIPInLoadBalancerIPRange(
pcEndpoint v1alpha1.NutanixPrismCentralEndpointSpec,
serviceLoadBalancerConfiguration *v1alpha1.ServiceLoadBalancer,
) error {
if serviceLoadBalancerConfiguration == nil ||
serviceLoadBalancerConfiguration.Provider != v1alpha1.ServiceLoadBalancerProviderMetalLB ||
serviceLoadBalancerConfiguration.Configuration == nil {
return nil
}

pcHostname, _, err := pcEndpoint.ParseURL()
if err != nil {
return err
}

pcIP := net.ParseIP(pcHostname)
// PC URL can contain IP/FQDN, so compare only if PC is an IP address.
if pcIP == nil {
return nil
}

for _, pool := range serviceLoadBalancerConfiguration.Configuration.AddressRanges {
isIPInRange, err := helpers.IsIPInRange(pool.Start, pool.End, pcIP.String())
if err != nil {
return fmt.Errorf(
"error while checking if Prism Central IP %q is part of MetalLB address range %q-%q: %w",
pcIP,
pool.Start,
pool.End,
err,
)
}
if isIPInRange {
return fmt.Errorf("prism central IP %q must not be part of MetalLB address range %q-%q",
pcIP, pool.Start, pool.End)
}
}

return nil
}
114 changes: 114 additions & 0 deletions pkg/webhook/cluster/nutanix_validator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright 2024 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package cluster

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"

"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1"
)

func TestCheckIfPrismCentralIPInLoadBalancerIPRange(t *testing.T) {
tests := []struct {
name string
pcEndpoint v1alpha1.NutanixPrismCentralEndpointSpec
serviceLoadBalancerConfiguration *v1alpha1.ServiceLoadBalancer
expectedErr error
}{
{
name: "PC IP not in range",
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
URL: "https://192.168.1.1:9440",
},
serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{
Provider: v1alpha1.ServiceLoadBalancerProviderMetalLB,
Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{
AddressRanges: []v1alpha1.AddressRange{
{Start: "192.168.1.10", End: "192.168.1.20"},
},
},
},
expectedErr: nil,
},
{
name: "PC IP in range",
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
URL: "https://192.168.1.15:9440",
},
serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{
Provider: v1alpha1.ServiceLoadBalancerProviderMetalLB,
Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{
AddressRanges: []v1alpha1.AddressRange{
{Start: "192.168.1.10", End: "192.168.1.20"},
},
},
},
expectedErr: fmt.Errorf(
"prism central IP %q must not be part of MetalLB address range %q-%q",
"192.168.1.15",
"192.168.1.10",
"192.168.1.20",
),
},
{
name: "Invalid Prism Central URL",
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
URL: "invalid-url",
},
serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{
Provider: v1alpha1.ServiceLoadBalancerProviderMetalLB,
Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{
AddressRanges: []v1alpha1.AddressRange{
{Start: "192.168.1.10", End: "192.168.1.20"},
},
},
},
expectedErr: fmt.Errorf(
"error parsing Prism Central URL: parse %q: invalid URI for request",
"invalid-url",
),
},
{
name: "Service Load Balancer Configuration is nil",
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
URL: "https://192.168.1.1:9440",
},
serviceLoadBalancerConfiguration: nil,
expectedErr: nil,
},
{
name: "Provider is not MetalLB",
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
URL: "https://192.168.1.1:9440",
},
serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{
Provider: "other-provider",
Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{
AddressRanges: []v1alpha1.AddressRange{
{Start: "192.168.1.10", End: "192.168.1.20"},
},
},
},
expectedErr: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkIfPrismCentralIPInLoadBalancerIPRange(
tt.pcEndpoint,
tt.serviceLoadBalancerConfiguration,
)

if tt.expectedErr != nil {
assert.Equal(t, tt.expectedErr.Error(), err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
Loading
Loading