Skip to content

Commit e775ae4

Browse files
feat(preflight): Add VM Image kubernetes version check
This check ensures images that have the standard NKP naming scheme have the image with the same kubernetes version as the cluster version.
1 parent 3ef53dc commit e775ae4

File tree

4 files changed

+552
-37
lines changed

4 files changed

+552
-37
lines changed

pkg/webhook/preflight/nutanix/checker.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import (
1818
)
1919

2020
var Checker = &nutanixChecker{
21-
configurationCheckFactory: newConfigurationCheck,
22-
credentialsCheckFactory: newCredentialsCheck,
23-
vmImageChecksFactory: newVMImageChecks,
24-
storageContainerChecksFactory: newStorageContainerChecks,
21+
configurationCheckFactory: newConfigurationCheck,
22+
credentialsCheckFactory: newCredentialsCheck,
23+
vmImageChecksFactory: newVMImageChecks,
24+
vmImageKubernetesVersionChecksFactory: newVMImageKubernetesVersionChecks,
25+
storageContainerChecksFactory: newStorageContainerChecks,
2526
}
2627

2728
type nutanixChecker struct {
@@ -39,6 +40,10 @@ type nutanixChecker struct {
3940
cd *checkDependencies,
4041
) []preflight.Check
4142

43+
vmImageKubernetesVersionChecksFactory func(
44+
cd *checkDependencies,
45+
) []preflight.Check
46+
4247
storageContainerChecksFactory func(
4348
cd *checkDependencies,
4449
) []preflight.Check
@@ -74,6 +79,7 @@ func (n *nutanixChecker) Init(
7479
}
7580

7681
checks = append(checks, n.vmImageChecksFactory(cd)...)
82+
checks = append(checks, n.vmImageKubernetesVersionChecksFactory(cd)...)
7783
checks = append(checks, n.storageContainerChecksFactory(cd)...)
7884

7985
// Add more checks here as needed.

pkg/webhook/preflight/nutanix/checker_test.go

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,26 @@ func (m *mockCheck) Run(ctx context.Context) preflight.CheckResult {
3333

3434
func TestNutanixChecker_Init(t *testing.T) {
3535
tests := []struct {
36-
name string
37-
nutanixConfig *carenv1.NutanixClusterConfigSpec
38-
workerNodeConfigs map[string]*carenv1.NutanixWorkerNodeConfigSpec
39-
expectedCheckCount int
40-
expectedFirstCheckName string
41-
expectedSecondCheckName string
42-
vmImageCheckCount int
43-
storageContainerCheckCount int
36+
name string
37+
nutanixConfig *carenv1.NutanixClusterConfigSpec
38+
workerNodeConfigs map[string]*carenv1.NutanixWorkerNodeConfigSpec
39+
expectedCheckCount int
40+
expectedFirstCheckName string
41+
expectedSecondCheckName string
42+
vmImageCheckCount int
43+
vmImageKubernetesVersionCheckCount int
44+
storageContainerCheckCount int
4445
}{
4546
{
46-
name: "basic initialization with no configs",
47-
nutanixConfig: nil,
48-
workerNodeConfigs: nil,
49-
expectedCheckCount: 2, // config check and credentials check
50-
expectedFirstCheckName: "NutanixConfiguration",
51-
expectedSecondCheckName: "NutanixCredentials",
52-
vmImageCheckCount: 0,
53-
storageContainerCheckCount: 0,
47+
name: "basic initialization with no configs",
48+
nutanixConfig: nil,
49+
workerNodeConfigs: nil,
50+
expectedCheckCount: 2, // config check and credentials check
51+
expectedFirstCheckName: "NutanixConfiguration",
52+
expectedSecondCheckName: "NutanixCredentials",
53+
vmImageCheckCount: 0,
54+
vmImageKubernetesVersionCheckCount: 0,
55+
storageContainerCheckCount: 0,
5456
},
5557
{
5658
name: "initialization with control plane config",
@@ -59,12 +61,13 @@ func TestNutanixChecker_Init(t *testing.T) {
5961
Nutanix: &carenv1.NutanixNodeSpec{},
6062
},
6163
},
62-
workerNodeConfigs: nil,
63-
expectedCheckCount: 4, // config check, credentials check, 1 VM image check, 1 storage container check
64-
expectedFirstCheckName: "NutanixConfiguration",
65-
expectedSecondCheckName: "NutanixCredentials",
66-
vmImageCheckCount: 1,
67-
storageContainerCheckCount: 1,
64+
workerNodeConfigs: nil,
65+
expectedCheckCount: 5, //nolint:lll // config check, credentials check, 1 VM image check, 1 storage container check, 1 VM image Kubernetes version check
66+
expectedFirstCheckName: "NutanixConfiguration",
67+
expectedSecondCheckName: "NutanixCredentials",
68+
vmImageCheckCount: 1,
69+
vmImageKubernetesVersionCheckCount: 1,
70+
storageContainerCheckCount: 1,
6871
},
6972
{
7073
name: "initialization with worker node configs",
@@ -77,11 +80,12 @@ func TestNutanixChecker_Init(t *testing.T) {
7780
Nutanix: &carenv1.NutanixNodeSpec{},
7881
},
7982
},
80-
expectedCheckCount: 6, // config check, credentials check, 2 VM image checks, 2 storage container checks
81-
expectedFirstCheckName: "NutanixConfiguration",
82-
expectedSecondCheckName: "NutanixCredentials",
83-
vmImageCheckCount: 2,
84-
storageContainerCheckCount: 2,
83+
expectedCheckCount: 8, //nolint:lll // config check, credentials check, 2 VM image checks, 2 storage container checks, 2 VM image Kubernetes version checks
84+
expectedFirstCheckName: "NutanixConfiguration",
85+
expectedSecondCheckName: "NutanixCredentials",
86+
vmImageCheckCount: 2,
87+
vmImageKubernetesVersionCheckCount: 2,
88+
storageContainerCheckCount: 2,
8589
},
8690
{
8791
name: "initialization with both control plane and worker node configs",
@@ -95,12 +99,12 @@ func TestNutanixChecker_Init(t *testing.T) {
9599
Nutanix: &carenv1.NutanixNodeSpec{},
96100
},
97101
},
98-
// config check, credentials check, 2 VM image checks (1 CP + 1 worker), 2 storage container checks (1 CP + 1 worker)
99-
expectedCheckCount: 6,
100-
expectedFirstCheckName: "NutanixConfiguration",
101-
expectedSecondCheckName: "NutanixCredentials",
102-
vmImageCheckCount: 2,
103-
storageContainerCheckCount: 2,
102+
expectedCheckCount: 8, //nolint:lll // config check, credentials check, 2 VM image checks (1 CP + 1 worker), 2 storage container checks (1 CP + 1 worker), 2 VM image Kubernetes version checks
103+
expectedFirstCheckName: "NutanixConfiguration",
104+
expectedSecondCheckName: "NutanixCredentials",
105+
vmImageCheckCount: 2,
106+
vmImageKubernetesVersionCheckCount: 2,
107+
storageContainerCheckCount: 2,
104108
},
105109
}
106110

@@ -114,6 +118,7 @@ func TestNutanixChecker_Init(t *testing.T) {
114118
credsCheckCalled := false
115119
vmImageCheckCount := 0
116120
storageContainerCheckCount := 0
121+
vmImageKubernetesVersionCheckCount := 0
117122

118123
checker.configurationCheckFactory = func(cd *checkDependencies) preflight.Check {
119124
configCheckCalled = true
@@ -167,6 +172,22 @@ func TestNutanixChecker_Init(t *testing.T) {
167172
return checks
168173
}
169174

175+
checker.vmImageKubernetesVersionChecksFactory = func(cd *checkDependencies) []preflight.Check {
176+
checks := []preflight.Check{}
177+
for i := 0; i < tt.vmImageKubernetesVersionCheckCount; i++ {
178+
vmImageKubernetesVersionCheckCount++
179+
checks = append(checks,
180+
&mockCheck{
181+
name: fmt.Sprintf("NutanixVMImageKubernetesVersion-%d", i),
182+
result: preflight.CheckResult{
183+
Allowed: true,
184+
},
185+
},
186+
)
187+
}
188+
return checks
189+
}
190+
170191
// Call Init
171192
ctx := context.Background()
172193
checks := checker.Init(ctx, nil, &clusterv1.Cluster{
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright 2025 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package nutanix
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"regexp"
10+
"strings"
11+
12+
vmmv4 "github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4/models/vmm/v4/content"
13+
14+
carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1"
15+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight"
16+
)
17+
18+
// Examples: nkp-ubuntu-22.04-vgpu-1.32.3-20250604180644, nkp-rocky-9.5-release-cis-1.32.3-20250430150550.
19+
var kubernetesVersionRegex = regexp.MustCompile(`-(\d+\.\d+\.\d+)-\d{14}$`)
20+
21+
type imageKubernetesVersionCheck struct {
22+
machineDetails *carenv1.NutanixMachineDetails
23+
field string
24+
nclient client
25+
clusterK8sVersion string
26+
}
27+
28+
func (c *imageKubernetesVersionCheck) Name() string {
29+
return "NutanixVMImageKubernetesVersion"
30+
}
31+
32+
func (c *imageKubernetesVersionCheck) Run(ctx context.Context) preflight.CheckResult {
33+
if c.machineDetails.ImageLookup != nil {
34+
return preflight.CheckResult{
35+
Allowed: true,
36+
Warnings: []string{fmt.Sprintf("%s uses imageLookup, which is not yet supported by checks", c.field)},
37+
}
38+
}
39+
40+
if c.machineDetails.Image != nil {
41+
images, err := getVMImages(c.nclient, c.machineDetails.Image)
42+
if err != nil {
43+
return preflight.CheckResult{
44+
Allowed: false,
45+
Error: true,
46+
Causes: []preflight.Cause{
47+
{
48+
Message: fmt.Sprintf("failed to get VM Image: %s", err),
49+
Field: c.field,
50+
},
51+
},
52+
}
53+
}
54+
55+
if len(images) == 0 {
56+
return preflight.CheckResult{
57+
Allowed: true,
58+
Warnings: []string{"expected to find 1 VM Image, found none"},
59+
}
60+
}
61+
62+
if err := c.checkKubernetesVersion(&images[0]); err != nil {
63+
return preflight.CheckResult{
64+
Allowed: false,
65+
Error: true,
66+
Causes: []preflight.Cause{
67+
{
68+
Message: err.Error(),
69+
Field: c.field,
70+
},
71+
},
72+
}
73+
}
74+
}
75+
76+
return preflight.CheckResult{Allowed: true}
77+
}
78+
79+
func (c *imageKubernetesVersionCheck) checkKubernetesVersion(image *vmmv4.Image) error {
80+
imageName := ""
81+
if image.Name != nil {
82+
imageName = *image.Name
83+
}
84+
85+
if imageName == "" {
86+
return fmt.Errorf("VM image name is empty")
87+
}
88+
89+
imageK8sVersion, err := extractKubernetesVersionFromImageName(imageName)
90+
if err != nil {
91+
return fmt.Errorf("failed to extract Kubernetes version from image name '%s': %s. "+
92+
"This check assumes NKP image naming convention. "+
93+
"You can opt out of this check if using custom image naming", imageName, err)
94+
}
95+
96+
if imageK8sVersion != c.clusterK8sVersion {
97+
return fmt.Errorf(
98+
"kubernetes version mismatch: cluster version '%s' does not match image version '%s' (from image name '%s')",
99+
c.clusterK8sVersion,
100+
imageK8sVersion,
101+
imageName,
102+
)
103+
}
104+
105+
return nil
106+
}
107+
108+
// Examples: nkp-ubuntu-22.04-vgpu-1.32.3-20250604180644 -> 1.32.3.
109+
func extractKubernetesVersionFromImageName(imageName string) (string, error) {
110+
matches := kubernetesVersionRegex.FindStringSubmatch(imageName)
111+
if len(matches) < 2 {
112+
return "", fmt.Errorf(
113+
"image name does not match expected NKP naming convention (expected pattern: *-<k8s-version>-<timestamp>)",
114+
)
115+
}
116+
return matches[1], nil
117+
}
118+
119+
func newVMImageKubernetesVersionChecks(
120+
cd *checkDependencies,
121+
) []preflight.Check {
122+
checks := make([]preflight.Check, 0)
123+
124+
if cd.nclient == nil {
125+
return checks
126+
}
127+
128+
// Get cluster Kubernetes version for version matching
129+
clusterK8sVersion := ""
130+
if cd.cluster != nil && cd.cluster.Spec.Topology != nil && cd.cluster.Spec.Topology.Version != "" {
131+
clusterK8sVersion = strings.TrimPrefix(cd.cluster.Spec.Topology.Version, "v")
132+
}
133+
134+
// If cluster Kubernetes version is not specified, skip the check.
135+
if clusterK8sVersion == "" {
136+
return checks
137+
}
138+
139+
if cd.nutanixClusterConfigSpec != nil && cd.nutanixClusterConfigSpec.ControlPlane != nil &&
140+
cd.nutanixClusterConfigSpec.ControlPlane.Nutanix != nil {
141+
checks = append(checks,
142+
&imageKubernetesVersionCheck{
143+
machineDetails: &cd.nutanixClusterConfigSpec.ControlPlane.Nutanix.MachineDetails,
144+
field: "cluster.spec.topology.variables[.name=clusterConfig]" +
145+
".value.nutanix.controlPlane.machineDetails",
146+
nclient: cd.nclient,
147+
clusterK8sVersion: clusterK8sVersion,
148+
},
149+
)
150+
}
151+
152+
for mdName, nutanixWorkerNodeConfigSpec := range cd.nutanixWorkerNodeConfigSpecByMachineDeploymentName {
153+
if nutanixWorkerNodeConfigSpec.Nutanix != nil {
154+
checks = append(checks,
155+
&imageKubernetesVersionCheck{
156+
machineDetails: &nutanixWorkerNodeConfigSpec.Nutanix.MachineDetails,
157+
field: fmt.Sprintf("cluster.spec.topology.workers.machineDeployments[.name=%s]"+
158+
".variables[.name=workerConfig].value.nutanix.machineDetails", mdName),
159+
nclient: cd.nclient,
160+
clusterK8sVersion: clusterK8sVersion,
161+
},
162+
)
163+
}
164+
}
165+
166+
return checks
167+
}

0 commit comments

Comments
 (0)