Skip to content

Commit 7deb42c

Browse files
committed
fix: validates CONTROL_PLANE_ENDPOINT_IP and NUTANIX_ENDPOINT are distinct
1 parent b519340 commit 7deb42c

File tree

4 files changed

+243
-5
lines changed

4 files changed

+243
-5
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) ControlPlaneEndpointIP() 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 TestControlPlaneEndpointIP(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.ControlPlaneEndpointIP()
64+
assert.Equal(t, tt.expected, result)
65+
})
66+
}
67+
}

pkg/webhook/cluster/nutanix_validator.go

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"net"
1010
"net/http"
11+
"net/netip"
1112

1213
v1 "k8s.io/api/admission/v1"
1314
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
@@ -69,15 +70,23 @@ func (a *nutanixValidator) validate(
6970
)
7071
}
7172

72-
if clusterConfig.Nutanix != nil &&
73-
clusterConfig.Addons != nil {
74-
// Check if Prism Central IP is in MetalLB Load Balancer IP range.
75-
if err := checkIfPrismCentralIPInLoadBalancerIPRange(
73+
if clusterConfig.Nutanix != nil {
74+
if err := checkIfPrismCentralAndControlPlaneIPSame(
7675
clusterConfig.Nutanix.PrismCentralEndpoint,
77-
clusterConfig.Addons.ServiceLoadBalancer,
76+
clusterConfig.Nutanix.ControlPlaneEndpoint,
7877
); err != nil {
7978
return admission.Denied(err.Error())
8079
}
80+
81+
if clusterConfig.Addons != nil {
82+
// Check if Prism Central IP is in MetalLB Load Balancer IP range.
83+
if err := checkIfPrismCentralIPInLoadBalancerIPRange(
84+
clusterConfig.Nutanix.PrismCentralEndpoint,
85+
clusterConfig.Addons.ServiceLoadBalancer,
86+
); err != nil {
87+
return admission.Denied(err.Error())
88+
}
89+
}
8190
}
8291

8392
return admission.Allowed("")
@@ -125,3 +134,35 @@ func checkIfPrismCentralIPInLoadBalancerIPRange(
125134

126135
return nil
127136
}
137+
138+
// checkIfPrismCentralAndControlPlaneIPSame checks if Prism Central and Control Plane IP are same.
139+
// It compares strictly IP addresses(no FQDN) and doesn't involve any network calls.
140+
// This is a temporary check until we have a better way to handle this by reserving IPs
141+
// using IPAM provider.
142+
func checkIfPrismCentralAndControlPlaneIPSame(
143+
pcEndpoint v1alpha1.NutanixPrismCentralEndpointSpec,
144+
controlPlaneEndpointSpec v1alpha1.ControlPlaneEndpointSpec,
145+
) error {
146+
controlPlaneEndpointIP, err := netip.ParseAddr(
147+
controlPlaneEndpointSpec.ControlPlaneEndpointIP(),
148+
)
149+
if err != nil {
150+
// controlPlaneEndpointIP is strictly accepted as an IP address from user so
151+
// if it is not an IP address, it is invalid.
152+
return fmt.Errorf("invalid Nutanix control plane endpoint IP: %w", err)
153+
}
154+
155+
pcHostname, _, err := pcEndpoint.ParseURL()
156+
if err != nil {
157+
return err
158+
}
159+
160+
pcIP, err := netip.ParseAddr(pcHostname)
161+
// PC URL can contain IP/FQDN, so compare only if PC is an IP address i.e. error is nil.
162+
if err == nil && pcIP.Compare(controlPlaneEndpointIP) == 0 {
163+
return fmt.Errorf("prism central and control plane endpoint cannot have the same IP %q",
164+
pcIP.String())
165+
}
166+
167+
return nil
168+
}

pkg/webhook/cluster/nutanix_validator_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,121 @@ func TestCheckIfPrismCentralIPInLoadBalancerIPRange(t *testing.T) {
112112
})
113113
}
114114
}
115+
116+
func TestCheckIfPrismCentralAndControlPlaneIPSame(t *testing.T) {
117+
tests := []struct {
118+
name string
119+
pcEndpoint v1alpha1.NutanixPrismCentralEndpointSpec
120+
controlPlaneEndpointSpec v1alpha1.ControlPlaneEndpointSpec
121+
expectedErr error
122+
}{
123+
{
124+
name: "Different IPs",
125+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
126+
URL: "https://192.168.1.1:9440",
127+
},
128+
controlPlaneEndpointSpec: v1alpha1.ControlPlaneEndpointSpec{
129+
Host: "192.168.1.2",
130+
},
131+
expectedErr: nil,
132+
},
133+
{
134+
name: "Same IPs",
135+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
136+
URL: "https://192.168.1.1:9440",
137+
},
138+
controlPlaneEndpointSpec: v1alpha1.ControlPlaneEndpointSpec{
139+
Host: "192.168.1.1",
140+
},
141+
expectedErr: fmt.Errorf(
142+
"prism central and control plane endpoint cannot have the same IP %q",
143+
"192.168.1.1",
144+
),
145+
},
146+
{
147+
name: "Invalid Control Plane IP",
148+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
149+
URL: "https://192.168.1.1:9440",
150+
},
151+
controlPlaneEndpointSpec: v1alpha1.ControlPlaneEndpointSpec{
152+
Host: "invalid-ip",
153+
},
154+
expectedErr: fmt.Errorf(
155+
"invalid Nutanix control plane endpoint IP: ParseAddr(%q): unable to parse IP",
156+
"invalid-ip",
157+
),
158+
},
159+
{
160+
name: "Invalid Prism Central URL",
161+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
162+
URL: "invalid-url",
163+
},
164+
controlPlaneEndpointSpec: v1alpha1.ControlPlaneEndpointSpec{
165+
Host: "192.168.1.2",
166+
},
167+
expectedErr: fmt.Errorf(
168+
"error parsing Prism Central URL: parse %q: invalid URI for request",
169+
"invalid-url",
170+
),
171+
},
172+
{
173+
name: "Prism Central URL is FQDN",
174+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
175+
URL: "https://example.com:9440",
176+
},
177+
controlPlaneEndpointSpec: v1alpha1.ControlPlaneEndpointSpec{
178+
Host: "192.168.1.2",
179+
},
180+
expectedErr: nil,
181+
},
182+
{
183+
name: "With KubeVIP ovveride and same PC and Control Plane IPs",
184+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
185+
URL: "https://192.168.1.1:9440",
186+
},
187+
controlPlaneEndpointSpec: v1alpha1.ControlPlaneEndpointSpec{
188+
Host: "192.168.1.2",
189+
VirtualIPSpec: &v1alpha1.ControlPlaneVirtualIPSpec{
190+
Provider: "KubeVIP",
191+
Configuration: &v1alpha1.ControlPlaneVirtualIPConfiguration{
192+
Address: "192.168.1.1",
193+
},
194+
},
195+
},
196+
expectedErr: fmt.Errorf(
197+
"prism central and control plane endpoint cannot have the same IP %q",
198+
"192.168.1.1",
199+
),
200+
},
201+
{
202+
name: "With KubeVIP ovveride and different PC and Control Plane IPs",
203+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
204+
URL: "https://192.168.1.2:9440",
205+
},
206+
controlPlaneEndpointSpec: v1alpha1.ControlPlaneEndpointSpec{
207+
Host: "192.168.1.2",
208+
VirtualIPSpec: &v1alpha1.ControlPlaneVirtualIPSpec{
209+
Provider: "KubeVIP",
210+
Configuration: &v1alpha1.ControlPlaneVirtualIPConfiguration{
211+
Address: "192.168.1.1",
212+
},
213+
},
214+
},
215+
expectedErr: nil,
216+
},
217+
}
218+
219+
for _, tt := range tests {
220+
t.Run(tt.name, func(t *testing.T) {
221+
err := checkIfPrismCentralAndControlPlaneIPSame(
222+
tt.pcEndpoint,
223+
tt.controlPlaneEndpointSpec,
224+
)
225+
if tt.expectedErr != nil {
226+
assert.Equal(t, tt.expectedErr.Error(), err.Error())
227+
} else {
228+
assert.NoError(t, err)
229+
}
230+
})
231+
}
232+
}

0 commit comments

Comments
 (0)