Skip to content

Commit ffb7bb2

Browse files
fix: validates PC IP is outside Load Balancer IP Range (#1001)
**What problem does this PR solve?**: webhook errors out if NUTANIX_ENDPOINT IP falls in Load Balancer IP Range. It only implements dumb check which compares PC IP with Load Balancer IP Range. It's complex to achieve with CEL so going with webhook as we'll need to use regex(isIP() func is not working for cluster variables) to extract IP from PC URL and do string compare which not robust as we can do error handling through webhook. **Which issue(s) this PR fixes**: Fixes # https://jira.nutanix.com/browse/NCN-102628 **How Has This Been Tested?**: <!-- Please describe the tests that you ran to verify your changes. Provide output from the tests and any manual steps needed to replicate the tests. --> **Special notes for your reviewer**: <!-- Use this to provide any additional information to the reviewers. This may include: - Best way to review the PR. - Where the author wants the most review attention on. - etc. --> ``` ✗ clusterctl generate cluster ${CLUSTER_NAME} \ --from ${CLUSTER_FILE} \ --kubernetes-version ${KUBERNETES_VERSION} \ --worker-machine-count 1 | \ kubectl apply --server-side -f - secret/nutanix-cluster-cilium-helm-addon-dockerhub-credentials serverside-applied secret/nutanix-cluster-cilium-helm-addon-pc-creds-for-csi serverside-applied Warning: Detected changes to resource nutanix-cluster-cilium-helm-addon-pc-creds which is currently being deleted. secret/nutanix-cluster-cilium-helm-addon-pc-creds serverside-applied Error from server (Forbidden): admission webhook "cluster-validator.caren.nutanix.com" denied the request: Prism Central IP "198.18.1.1" must not be part of MetalLB address range "198.18.1.1"-"198.18.1.10" ```
1 parent 8c18f88 commit ffb7bb2

File tree

9 files changed

+762
-1
lines changed

9 files changed

+762
-1
lines changed

api/v1alpha1/common_types.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,15 @@ type LocalObjectReference struct {
7777
// +kubebuilder:validation:MinLength=1
7878
Name string `json:"name"`
7979
}
80+
81+
func (s ControlPlaneEndpointSpec) VirtualIPAddress() string {
82+
// If specified, use the virtual IP address and/or port,
83+
// otherwise fall back to the control plane endpoint host and port.
84+
if s.VirtualIPSpec != nil &&
85+
s.VirtualIPSpec.Configuration != nil &&
86+
s.VirtualIPSpec.Configuration.Address != "" {
87+
return s.VirtualIPSpec.Configuration.Address
88+
}
89+
90+
return s.Host
91+
}

api/v1alpha1/common_types_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2023 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package v1alpha1
4+
5+
import (
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestVirtualIPAddress(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
spec ControlPlaneEndpointSpec
15+
expected string
16+
}{
17+
{
18+
name: "Virtual IP specified",
19+
spec: ControlPlaneEndpointSpec{
20+
VirtualIPSpec: &ControlPlaneVirtualIPSpec{
21+
Configuration: &ControlPlaneVirtualIPConfiguration{
22+
Address: "192.168.1.1",
23+
},
24+
},
25+
Host: "192.168.1.2",
26+
},
27+
expected: "192.168.1.1",
28+
},
29+
{
30+
name: "VirtualIPSpec struct not specified",
31+
spec: ControlPlaneEndpointSpec{
32+
VirtualIPSpec: nil,
33+
Host: "192.168.1.2",
34+
},
35+
expected: "192.168.1.2",
36+
},
37+
{
38+
name: "ControlPlaneVirtualIPConfiguration struct not specified",
39+
spec: ControlPlaneEndpointSpec{
40+
VirtualIPSpec: &ControlPlaneVirtualIPSpec{
41+
Configuration: nil,
42+
},
43+
Host: "192.168.1.2",
44+
},
45+
expected: "192.168.1.2",
46+
},
47+
{
48+
name: "Virtual IP specified as empty string",
49+
spec: ControlPlaneEndpointSpec{
50+
VirtualIPSpec: &ControlPlaneVirtualIPSpec{
51+
Configuration: &ControlPlaneVirtualIPConfiguration{
52+
Address: "",
53+
},
54+
},
55+
Host: "192.168.1.2",
56+
},
57+
expected: "192.168.1.2",
58+
},
59+
}
60+
61+
for _, tt := range tests {
62+
t.Run(tt.name, func(t *testing.T) {
63+
result := tt.spec.VirtualIPAddress()
64+
assert.Equal(t, tt.expected, result)
65+
})
66+
}
67+
}

api/v1alpha1/nutanix_clusterconfig_types.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package v1alpha1
55

66
import (
77
"fmt"
8+
"net/netip"
89
"net/url"
910
"strconv"
1011
)
@@ -57,7 +58,7 @@ type NutanixPrismCentralEndpointCredentials struct {
5758
//nolint:gocritic // No need for named return values
5859
func (s NutanixPrismCentralEndpointSpec) ParseURL() (string, uint16, error) {
5960
var prismCentralURL *url.URL
60-
prismCentralURL, err := url.Parse(s.URL)
61+
prismCentralURL, err := url.ParseRequestURI(s.URL)
6162
if err != nil {
6263
return "", 0, fmt.Errorf("error parsing Prism Central URL: %w", err)
6364
}
@@ -76,3 +77,17 @@ func (s NutanixPrismCentralEndpointSpec) ParseURL() (string, uint16, error) {
7677

7778
return hostname, uint16(port), nil
7879
}
80+
81+
func (s NutanixPrismCentralEndpointSpec) ParseIP() (netip.Addr, error) {
82+
pcHostname, _, err := s.ParseURL()
83+
if err != nil {
84+
return netip.Addr{}, err
85+
}
86+
87+
pcIP, err := netip.ParseAddr(pcHostname)
88+
if err != nil {
89+
return netip.Addr{}, fmt.Errorf("error parsing Prism Central IP: %w", err)
90+
}
91+
92+
return pcIP, nil
93+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright 2024 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package v1alpha1
5+
6+
import (
7+
"fmt"
8+
"net/netip"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestParseURL(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
spec NutanixPrismCentralEndpointSpec
19+
expectedHost string
20+
expectedPort uint16
21+
expectedErr error
22+
}{
23+
{
24+
name: "Valid URL with port",
25+
spec: NutanixPrismCentralEndpointSpec{
26+
URL: "https://192.168.1.1:9440",
27+
},
28+
expectedHost: "192.168.1.1",
29+
expectedPort: 9440,
30+
expectedErr: nil,
31+
},
32+
{
33+
name: "Valid URL without port",
34+
spec: NutanixPrismCentralEndpointSpec{
35+
URL: "https://192.168.1.1",
36+
},
37+
expectedHost: "192.168.1.1",
38+
expectedPort: 9440,
39+
expectedErr: nil,
40+
},
41+
{
42+
name: "Invalid URL",
43+
spec: NutanixPrismCentralEndpointSpec{
44+
URL: "invalid-url",
45+
},
46+
expectedHost: "",
47+
expectedPort: 0,
48+
expectedErr: fmt.Errorf(
49+
"error parsing Prism Central URL: parse %q: invalid URI for request",
50+
"invalid-url",
51+
),
52+
},
53+
{
54+
name: "Invalid port",
55+
spec: NutanixPrismCentralEndpointSpec{
56+
URL: "https://192.168.1.1:invalid-port",
57+
},
58+
expectedHost: "",
59+
expectedPort: 0,
60+
expectedErr: fmt.Errorf(
61+
"error parsing Prism Central URL: parse %q: invalid port %q after host",
62+
"https://192.168.1.1:invalid-port",
63+
":invalid-port",
64+
),
65+
},
66+
}
67+
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
host, port, err := tt.spec.ParseURL()
71+
if tt.expectedErr != nil {
72+
require.EqualError(t, err, tt.expectedErr.Error())
73+
} else {
74+
require.NoError(t, err)
75+
}
76+
assert.Equal(t, tt.expectedHost, host)
77+
assert.Equal(t, tt.expectedPort, port)
78+
})
79+
}
80+
}
81+
82+
func TestParseIP(t *testing.T) {
83+
tests := []struct {
84+
name string
85+
spec NutanixPrismCentralEndpointSpec
86+
expectedIP netip.Addr
87+
expectedErr error
88+
}{
89+
{
90+
name: "Valid IP",
91+
spec: NutanixPrismCentralEndpointSpec{
92+
URL: "https://192.168.1.1:9440",
93+
},
94+
expectedIP: netip.MustParseAddr("192.168.1.1"),
95+
expectedErr: nil,
96+
},
97+
{
98+
name: "Invalid URL",
99+
spec: NutanixPrismCentralEndpointSpec{
100+
URL: "invalid-url",
101+
},
102+
expectedIP: netip.Addr{},
103+
expectedErr: fmt.Errorf(
104+
"error parsing Prism Central URL: parse %q: invalid URI for request",
105+
"invalid-url",
106+
),
107+
},
108+
{
109+
name: "Invalid IP",
110+
spec: NutanixPrismCentralEndpointSpec{
111+
URL: "https://invalid-ip:9440",
112+
},
113+
expectedIP: netip.Addr{},
114+
expectedErr: fmt.Errorf(
115+
"error parsing Prism Central IP: ParseAddr(%q): unable to parse IP",
116+
"invalid-ip",
117+
),
118+
},
119+
}
120+
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
ip, err := tt.spec.ParseIP()
124+
if tt.expectedErr != nil {
125+
require.EqualError(t, err, tt.expectedErr.Error())
126+
} else {
127+
require.NoError(t, err)
128+
}
129+
assert.Equal(t, tt.expectedIP, ip)
130+
})
131+
}
132+
}

pkg/helpers/helpers.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2024 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package helpers
4+
5+
import (
6+
"fmt"
7+
"net/netip"
8+
)
9+
10+
// IsIPInRange checks if the target IP falls within the start and end IP range (inclusive).
11+
func IsIPInRange(startIP, endIP, targetIP string) (bool, error) {
12+
start, err := netip.ParseAddr(startIP)
13+
if err != nil {
14+
return false, fmt.Errorf("invalid start IP: %w", err)
15+
}
16+
end, err := netip.ParseAddr(endIP)
17+
if err != nil {
18+
return false, fmt.Errorf("invalid end IP: %w", err)
19+
}
20+
target, err := netip.ParseAddr(targetIP)
21+
if err != nil {
22+
return false, fmt.Errorf("invalid target IP: %w", err)
23+
}
24+
25+
return start.Compare(target) <= 0 && end.Compare(target) >= 0, nil
26+
}

pkg/helpers/helpers_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright 2024 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package helpers
5+
6+
import (
7+
"fmt"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestIsIPInRange(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
startIP string
17+
endIP string
18+
targetIP string
19+
expectedInRange bool
20+
expectedErr error
21+
}{
22+
{
23+
name: "Valid range - target within range",
24+
startIP: "192.168.1.1",
25+
endIP: "192.168.1.10",
26+
targetIP: "192.168.1.5",
27+
expectedInRange: true,
28+
expectedErr: nil,
29+
},
30+
{
31+
name: "Valid range - target same as start IP",
32+
startIP: "192.168.1.1",
33+
endIP: "192.168.1.10",
34+
targetIP: "192.168.1.1",
35+
expectedInRange: true,
36+
expectedErr: nil,
37+
},
38+
{
39+
name: "Valid range - target same as end IP",
40+
startIP: "192.168.1.1",
41+
endIP: "192.168.1.10",
42+
targetIP: "192.168.1.10",
43+
expectedInRange: true,
44+
expectedErr: nil,
45+
},
46+
{
47+
name: "Valid range - target outside range",
48+
startIP: "192.168.1.1",
49+
endIP: "192.168.1.10",
50+
targetIP: "192.168.1.15",
51+
expectedInRange: false,
52+
expectedErr: nil,
53+
},
54+
{
55+
name: "Invalid start IP",
56+
startIP: "invalid-ip",
57+
endIP: "192.168.1.10",
58+
targetIP: "192.168.1.5",
59+
expectedInRange: false,
60+
expectedErr: fmt.Errorf(
61+
"invalid start IP: ParseAddr(%q): unable to parse IP",
62+
"invalid-ip",
63+
),
64+
},
65+
{
66+
name: "Invalid end IP",
67+
startIP: "192.168.1.1",
68+
endIP: "invalid-ip",
69+
targetIP: "192.168.1.5",
70+
expectedInRange: false,
71+
expectedErr: fmt.Errorf(
72+
"invalid end IP: ParseAddr(%q): unable to parse IP",
73+
"invalid-ip",
74+
),
75+
},
76+
{
77+
name: "Invalid target IP",
78+
startIP: "192.168.1.1",
79+
endIP: "192.168.1.10",
80+
targetIP: "invalid-ip",
81+
expectedInRange: false,
82+
expectedErr: fmt.Errorf(
83+
"invalid target IP: ParseAddr(%q): unable to parse IP",
84+
"invalid-ip",
85+
),
86+
},
87+
{
88+
name: "IPv6 range - target within range",
89+
startIP: "2001:db8::1",
90+
endIP: "2001:db8::10",
91+
targetIP: "2001:db8::5",
92+
expectedInRange: true,
93+
expectedErr: nil,
94+
},
95+
{
96+
name: "IPv6 range - target outside range",
97+
startIP: "2001:db8::1",
98+
endIP: "2001:db8::10",
99+
targetIP: "2001:db8::11",
100+
expectedInRange: false,
101+
expectedErr: nil,
102+
},
103+
}
104+
105+
for _, tt := range tests {
106+
t.Run(tt.name, func(t *testing.T) {
107+
got, err := IsIPInRange(tt.startIP, tt.endIP, tt.targetIP)
108+
assert.Equal(t, tt.expectedInRange, got)
109+
if tt.expectedErr != nil {
110+
assert.EqualError(t, err, tt.expectedErr.Error())
111+
} else {
112+
assert.NoError(t, err)
113+
}
114+
})
115+
}
116+
}

0 commit comments

Comments
 (0)