Skip to content

Commit dfee5fa

Browse files
feat(preflight): Add VM Image kubernetes version check (#1172)
This check ensures images that have the standard NKP naming scheme have the image with the same kubernetes version as the cluster version. **How has this been tested?** create a cluster using `nkp create cluster nutanix --dry-run` and tweak the image kubernetes version to be different from cluster kubernetes version. ``` $ cat cluster.yaml apiVersion: cluster.x-k8s.io/v1beta1 kind: Cluster metadata: ... name: nkp-sid namespace: default spec: ... topology: ... variables: - name: clusterConfig value: ... controlPlane: nutanix: machineDetails: ... image: name: nkp-rocky-9.5-release-1.31.4-20250214003015.qcow2 type: name version: v1.32.3 workers: machineDeployments: - class: default-worker ... name: md-0 variables: overrides: - name: workerConfig value: nutanix: machineDetails: ... image: name: nkp-rocky-9.5-release-1.31.4-20250214003015.qcow2 type: name ``` create the cluster ``` $ k apply -f cluster.yaml -v4 The request is invalid: * cluster.spec.topology.variables[.name=clusterConfig].value.nutanix.controlPlane.machineDetails: kubernetes version mismatch: cluster kubernetes version '1.32.3' does not match image kubernetes version '1.31.4' (from image name 'nkp-rocky-9.5-release-1.31.4-20250214003015.qcow2') * cluster.spec.topology.workers.machineDeployments[.name=md-0].variables[.name=workerConfig].value.nutanix.machineDetails: kubernetes version mismatch: cluster kubernetes version '1.32.3' does not match image kubernetes version '1.31.4' (from image name 'nkp-rocky-9.5-release-1.31.4-20250214003015.qcow2') ``` --------- Co-authored-by: Daniel Lipovetsky <daniel.lipovetsky@nutanix.com>
1 parent 4c7bec6 commit dfee5fa

File tree

6 files changed

+555
-37
lines changed

6 files changed

+555
-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{

pkg/webhook/preflight/nutanix/image.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ func getVMImages(
115115
if err != nil {
116116
return nil, err
117117
}
118+
if resp == nil {
119+
// No images were returned.
120+
return []vmmv4.Image{}, nil
121+
}
118122
image, ok := resp.GetData().(vmmv4.Image)
119123
if !ok {
120124
return nil, fmt.Errorf("failed to get data returned by GetImageById")

pkg/webhook/preflight/nutanix/image_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,20 @@ func TestGetVMImages(t *testing.T) {
416416
wantErr: true,
417417
errorMsg: "image identifier is missing both name and uuid",
418418
},
419+
{
420+
name: "no image found by uuid",
421+
client: &mocknclient{
422+
getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) {
423+
return nil, nil
424+
},
425+
},
426+
id: &capxv1.NutanixResourceIdentifier{
427+
Type: capxv1.NutanixIdentifierUUID,
428+
UUID: ptr.To("test-uuid"),
429+
},
430+
wantErr: false,
431+
want: []vmmv4.Image{}, // No images found
432+
},
419433
{
420434
name: "invalid data from GetImageById",
421435
client: &mocknclient{
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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+
"strings"
10+
11+
vmmv4 "github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4/models/vmm/v4/content"
12+
13+
carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1"
14+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight"
15+
)
16+
17+
type imageKubernetesVersionCheck struct {
18+
machineDetails *carenv1.NutanixMachineDetails
19+
field string
20+
nclient client
21+
clusterK8sVersion string
22+
}
23+
24+
func (c *imageKubernetesVersionCheck) Name() string {
25+
return "NutanixVMImageKubernetesVersion"
26+
}
27+
28+
func (c *imageKubernetesVersionCheck) Run(ctx context.Context) preflight.CheckResult {
29+
if c.machineDetails.ImageLookup != nil {
30+
return preflight.CheckResult{
31+
Allowed: true,
32+
Warnings: []string{fmt.Sprintf("%s uses imageLookup, which is not yet supported by checks", c.field)},
33+
}
34+
}
35+
36+
if c.machineDetails.Image != nil {
37+
images, err := getVMImages(c.nclient, c.machineDetails.Image)
38+
if err != nil {
39+
return preflight.CheckResult{
40+
Allowed: false,
41+
Error: true,
42+
Causes: []preflight.Cause{
43+
{
44+
Message: fmt.Sprintf("failed to get VM Image: %s", err),
45+
Field: c.field,
46+
},
47+
},
48+
}
49+
}
50+
51+
if len(images) == 0 {
52+
return preflight.CheckResult{
53+
Allowed: true,
54+
}
55+
}
56+
57+
if err := c.checkKubernetesVersion(&images[0]); err != nil {
58+
return preflight.CheckResult{
59+
Allowed: false,
60+
Error: false,
61+
Causes: []preflight.Cause{
62+
{
63+
Message: err.Error(),
64+
Field: c.field,
65+
},
66+
},
67+
}
68+
}
69+
}
70+
71+
return preflight.CheckResult{Allowed: true}
72+
}
73+
74+
func (c *imageKubernetesVersionCheck) checkKubernetesVersion(image *vmmv4.Image) error {
75+
imageName := ""
76+
if image.Name != nil {
77+
imageName = *image.Name
78+
}
79+
80+
if imageName == "" {
81+
return fmt.Errorf("VM image name is empty")
82+
}
83+
84+
if !strings.Contains(imageName, c.clusterK8sVersion) {
85+
return fmt.Errorf(
86+
"cluster kubernetes version '%s' is not part of image name '%s'",
87+
c.clusterK8sVersion,
88+
imageName,
89+
)
90+
}
91+
92+
return nil
93+
}
94+
95+
func newVMImageKubernetesVersionChecks(
96+
cd *checkDependencies,
97+
) []preflight.Check {
98+
checks := make([]preflight.Check, 0)
99+
100+
if cd.nclient == nil {
101+
return checks
102+
}
103+
104+
// Get cluster Kubernetes version for version matching
105+
clusterK8sVersion := ""
106+
if cd.cluster != nil && cd.cluster.Spec.Topology != nil && cd.cluster.Spec.Topology.Version != "" {
107+
clusterK8sVersion = strings.TrimPrefix(cd.cluster.Spec.Topology.Version, "v")
108+
}
109+
110+
// If cluster Kubernetes version is not specified, skip the check.
111+
if clusterK8sVersion == "" {
112+
return checks
113+
}
114+
115+
if cd.nutanixClusterConfigSpec != nil && cd.nutanixClusterConfigSpec.ControlPlane != nil &&
116+
cd.nutanixClusterConfigSpec.ControlPlane.Nutanix != nil {
117+
checks = append(checks,
118+
&imageKubernetesVersionCheck{
119+
machineDetails: &cd.nutanixClusterConfigSpec.ControlPlane.Nutanix.MachineDetails,
120+
field: "cluster.spec.topology.variables[.name=clusterConfig]" +
121+
".value.nutanix.controlPlane.machineDetails.image",
122+
nclient: cd.nclient,
123+
clusterK8sVersion: clusterK8sVersion,
124+
},
125+
)
126+
}
127+
128+
for mdName, nutanixWorkerNodeConfigSpec := range cd.nutanixWorkerNodeConfigSpecByMachineDeploymentName {
129+
if nutanixWorkerNodeConfigSpec.Nutanix != nil {
130+
checks = append(checks,
131+
&imageKubernetesVersionCheck{
132+
machineDetails: &nutanixWorkerNodeConfigSpec.Nutanix.MachineDetails,
133+
field: fmt.Sprintf("cluster.spec.topology.workers.machineDeployments[.name=%s]"+
134+
".variables[.name=workerConfig].value.nutanix.machineDetails.image", mdName),
135+
nclient: cd.nclient,
136+
clusterK8sVersion: clusterK8sVersion,
137+
},
138+
)
139+
}
140+
}
141+
142+
return checks
143+
}

0 commit comments

Comments
 (0)