Skip to content

Commit d7d0170

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 cc32a9c commit d7d0170

File tree

4 files changed

+569
-30
lines changed

4 files changed

+569
-30
lines changed

pkg/webhook/preflight/nutanix/checker.go

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

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

2627
type nutanixChecker struct {
@@ -37,6 +38,10 @@ type nutanixChecker struct {
3738
vmImageChecksFactory func(
3839
cd *checkDependencies,
3940
) []preflight.Check
41+
42+
vmImageKubernetesVersionChecksFactory func(
43+
cd *checkDependencies,
44+
) []preflight.Check
4045
}
4146

4247
type checkDependencies struct {
@@ -69,6 +74,7 @@ func (n *nutanixChecker) Init(
6974
}
7075

7176
checks = append(checks, n.vmImageChecksFactory(cd)...)
77+
checks = append(checks, n.vmImageKubernetesVersionChecksFactory(cd)...)
7278

7379
// Add more checks here as needed.
7480

pkg/webhook/preflight/nutanix/checker_test.go

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,24 @@ 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
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
4344
}{
4445
{
45-
name: "basic initialization with no configs",
46-
nutanixConfig: nil,
47-
workerNodeConfigs: nil,
48-
expectedCheckCount: 2, // config check and credentials check
49-
expectedFirstCheckName: "NutanixConfiguration",
50-
expectedSecondCheckName: "NutanixCredentials",
51-
vmImageCheckCount: 0,
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+
vmImageKubernetesVersionCheckCount: 0,
5254
},
5355
{
5456
name: "initialization with control plane config",
@@ -57,11 +59,12 @@ func TestNutanixChecker_Init(t *testing.T) {
5759
Nutanix: &carenv1.NutanixNodeSpec{},
5860
},
5961
},
60-
workerNodeConfigs: nil,
61-
expectedCheckCount: 3, // config check, credentials check, 1 VM image check
62-
expectedFirstCheckName: "NutanixConfiguration",
63-
expectedSecondCheckName: "NutanixCredentials",
64-
vmImageCheckCount: 1,
62+
workerNodeConfigs: nil,
63+
expectedCheckCount: 4, // config check, credentials check, 1 VM image check
64+
expectedFirstCheckName: "NutanixConfiguration",
65+
expectedSecondCheckName: "NutanixCredentials",
66+
vmImageCheckCount: 1,
67+
vmImageKubernetesVersionCheckCount: 1,
6568
},
6669
{
6770
name: "initialization with worker node configs",
@@ -74,10 +77,11 @@ func TestNutanixChecker_Init(t *testing.T) {
7477
Nutanix: &carenv1.NutanixNodeSpec{},
7578
},
7679
},
77-
expectedCheckCount: 4, // config check, credentials check, 2 VM image checks
78-
expectedFirstCheckName: "NutanixConfiguration",
79-
expectedSecondCheckName: "NutanixCredentials",
80-
vmImageCheckCount: 2,
80+
expectedCheckCount: 6, // config check, credentials check, 2 VM image checks
81+
expectedFirstCheckName: "NutanixConfiguration",
82+
expectedSecondCheckName: "NutanixCredentials",
83+
vmImageCheckCount: 2,
84+
vmImageKubernetesVersionCheckCount: 2,
8185
},
8286
{
8387
name: "initialization with both control plane and worker node configs",
@@ -91,10 +95,11 @@ func TestNutanixChecker_Init(t *testing.T) {
9195
Nutanix: &carenv1.NutanixNodeSpec{},
9296
},
9397
},
94-
expectedCheckCount: 4, // config check, credentials check, 2 VM image checks (1 CP + 1 worker)
95-
expectedFirstCheckName: "NutanixConfiguration",
96-
expectedSecondCheckName: "NutanixCredentials",
97-
vmImageCheckCount: 2,
98+
expectedCheckCount: 6, // config check, credentials check, 2 VM image checks (1 CP + 1 worker)
99+
expectedFirstCheckName: "NutanixConfiguration",
100+
expectedSecondCheckName: "NutanixCredentials",
101+
vmImageCheckCount: 2,
102+
vmImageKubernetesVersionCheckCount: 2,
98103
},
99104
}
100105

@@ -107,6 +112,7 @@ func TestNutanixChecker_Init(t *testing.T) {
107112
configCheckCalled := false
108113
credsCheckCalled := false
109114
vmImageCheckCount := 0
115+
vmImageKubernetesVersionCheckCount := 0
110116

111117
checker.configurationCheckFactory = func(cd *checkDependencies) preflight.Check {
112118
configCheckCalled = true
@@ -144,6 +150,22 @@ func TestNutanixChecker_Init(t *testing.T) {
144150
return checks
145151
}
146152

153+
checker.vmImageKubernetesVersionChecksFactory = func(cd *checkDependencies) []preflight.Check {
154+
checks := []preflight.Check{}
155+
for i := 0; i < tt.vmImageKubernetesVersionCheckCount; i++ {
156+
vmImageKubernetesVersionCheckCount++
157+
checks = append(checks,
158+
&mockCheck{
159+
name: fmt.Sprintf("NutanixVMImageKubernetesVersion-%d", i),
160+
result: preflight.CheckResult{
161+
Allowed: true,
162+
},
163+
},
164+
)
165+
}
166+
return checks
167+
}
168+
147169
// Call Init
148170
ctx := context.Background()
149171
checks := checker.Init(ctx, nil, &clusterv1.Cluster{
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
result := preflight.CheckResult{
34+
Allowed: false,
35+
}
36+
37+
if c.machineDetails.ImageLookup != nil {
38+
result.Allowed = true
39+
result.Warnings = append(
40+
result.Warnings,
41+
fmt.Sprintf("%s uses imageLookup, which is not yet supported by checks", c.field),
42+
)
43+
return result
44+
}
45+
46+
if c.machineDetails.Image != nil {
47+
images, err := getVMImages(c.nclient, c.machineDetails.Image)
48+
if err != nil {
49+
result.Allowed = false
50+
result.Error = true
51+
result.Causes = append(result.Causes, preflight.Cause{
52+
Message: fmt.Sprintf("failed to get VM Image: %s", err),
53+
Field: c.field,
54+
})
55+
return result
56+
}
57+
58+
if len(images) != 1 {
59+
result.Allowed = false
60+
result.Causes = append(result.Causes, preflight.Cause{
61+
Message: fmt.Sprintf("expected to find 1 VM Image, found %d", len(images)),
62+
Field: c.field,
63+
})
64+
return result
65+
}
66+
67+
// Check Kubernetes version if cluster version is provided
68+
if c.clusterK8sVersion != "" {
69+
if err := c.checkKubernetesVersion(&images[0]); err != nil {
70+
result.Allowed = false
71+
result.Error = true
72+
result.Causes = append(result.Causes, preflight.Cause{
73+
Message: err.Error(),
74+
Field: c.field,
75+
})
76+
return result
77+
}
78+
}
79+
80+
result.Allowed = true
81+
return result
82+
}
83+
84+
// Neither ImageLookup nor Image is specified.
85+
return result
86+
}
87+
88+
func (c *imageKubernetesVersionCheck) checkKubernetesVersion(image *vmmv4.Image) error {
89+
imageName := ""
90+
if image.Name != nil {
91+
imageName = *image.Name
92+
}
93+
94+
if imageName == "" {
95+
return fmt.Errorf("VM image name is empty")
96+
}
97+
98+
imageK8sVersion, err := extractKubernetesVersionFromImageName(imageName)
99+
if err != nil {
100+
return fmt.Errorf("failed to extract Kubernetes version from image name '%s': %s. "+
101+
"This check assumes NKP image naming convention. "+
102+
"You can opt out of this check if using custom image naming", imageName, err)
103+
}
104+
105+
if imageK8sVersion != c.clusterK8sVersion {
106+
return fmt.Errorf(
107+
"kubernetes version mismatch: cluster version '%s' does not match image version '%s' (from image name '%s')",
108+
c.clusterK8sVersion,
109+
imageK8sVersion,
110+
imageName,
111+
)
112+
}
113+
114+
return nil
115+
}
116+
117+
// Examples: nkp-ubuntu-22.04-vgpu-1.32.3-20250604180644 -> 1.32.3.
118+
func extractKubernetesVersionFromImageName(imageName string) (string, error) {
119+
matches := kubernetesVersionRegex.FindStringSubmatch(imageName)
120+
if len(matches) < 2 {
121+
return "", fmt.Errorf(
122+
"image name does not match expected NKP naming convention (expected pattern: *-<k8s-version>-<timestamp>)",
123+
)
124+
}
125+
return matches[1], nil
126+
}
127+
128+
func newVMImageKubernetesVersionChecks(
129+
cd *checkDependencies,
130+
) []preflight.Check {
131+
checks := make([]preflight.Check, 0)
132+
133+
if cd.nclient == nil {
134+
return checks
135+
}
136+
137+
// Get cluster Kubernetes version for version matching
138+
clusterK8sVersion := ""
139+
if cd.cluster != nil && cd.cluster.Spec.Topology != nil && cd.cluster.Spec.Topology.Version != "" {
140+
clusterK8sVersion = strings.TrimPrefix(cd.cluster.Spec.Topology.Version, "v")
141+
}
142+
143+
if cd.nutanixClusterConfigSpec != nil && cd.nutanixClusterConfigSpec.ControlPlane != nil &&
144+
cd.nutanixClusterConfigSpec.ControlPlane.Nutanix != nil {
145+
checks = append(checks,
146+
&imageKubernetesVersionCheck{
147+
machineDetails: &cd.nutanixClusterConfigSpec.ControlPlane.Nutanix.MachineDetails,
148+
field: "cluster.spec.topology.variables[.name=clusterConfig]" +
149+
".value.nutanix.controlPlane.machineDetails",
150+
nclient: cd.nclient,
151+
clusterK8sVersion: clusterK8sVersion,
152+
},
153+
)
154+
}
155+
156+
for mdName, nutanixWorkerNodeConfigSpec := range cd.nutanixWorkerNodeConfigSpecByMachineDeploymentName {
157+
if nutanixWorkerNodeConfigSpec.Nutanix != nil {
158+
checks = append(checks,
159+
&imageKubernetesVersionCheck{
160+
machineDetails: &nutanixWorkerNodeConfigSpec.Nutanix.MachineDetails,
161+
field: fmt.Sprintf("cluster.spec.topology.workers.machineDeployments[.name=%s]"+
162+
".variables[.name=workerConfig].value.nutanix.machineDetails", mdName),
163+
nclient: cd.nclient,
164+
clusterK8sVersion: clusterK8sVersion,
165+
},
166+
)
167+
}
168+
}
169+
170+
return checks
171+
}

0 commit comments

Comments
 (0)