Repository: kubernetes-sigs/cluster-api-provider-gcp (go)

Issue:
Align managed control plane to latest CAPI changes
/kind bug
/area gke

# Description

To align `GCPManagedControlPlane` specification and status with CAPI >=v1.9 we need to introduce new control plane version fields and set existing ones for deprecation in coming releases. The list of changes, as agreed on Slack (thanks @damdo @richardcase @cpanato):
- Introduce new `Version` fields in `GCPManagedControlPlaneSpec` and `GCPManagedControlPlaneStatus` as per the updated CAPI contract for control planes.
- Mark `ControlPlaneVersion` and `CurrentVersion` as deprecated with a message saying to use `Version` instead.
- Have a validation webhook that disallows setting `Version` and `ControlPlaneVersion`.
- Update all templates and e2e tests to use `Version`.

In the future, as the deprecated field is ultimately removed, we can add a conversion from `ControlPlaneVersion` to `Version`.

Pull request:
feat: align GCPManagedControlPlane with CAPI >=v1.9
/kind bug

**What this PR does / why we need it**:

To align `GCPManagedControlPlane` specification and status with CAPI >=v1.9 we need to introduce new control plane version fields and set existing ones for deprecation in coming releases. The list of changes, as agreed on Slack (thanks @damdo @richardcase @cpanato):
- Introduce new `Version` fields in `GCPManagedControlPlaneSpec` and `GCPManagedControlPlaneStatus` as per the updated CAPI contract for control planes.
- Mark `ControlPlaneVersion` and `CurrentVersion` as deprecated with a message saying to use `Version` instead.
- Have a validation webhook that disallows setting `Version` and `ControlPlaneVersion`.
- Update all templates and e2e tests to use `Version`.

**Which issue(s) this PR fixes**:
Fixes #1433 

**Special notes for your reviewer**:

In the future, as the deprecated field is ultimately removed, we can add a conversion from `ControlPlaneVersion` to `Version`.

**TODOs**:

- [x] squashed commits
- [ ] includes documentation
- [ ] adds unit tests

**Release note**:
<!--  Write your release note:
1. Enter your extended release note in the below block. If the PR requires additional action from users switching to the new release, include the string "action required".
2. If no release note is required, just write "NONE".
-->
```release-note
action required
!! ACTION REQUIRED BEFORE UPGRADING !!
If you are using the GCPManagedControlPlane to provision GKE clusters and you do not have a spec.Version specified in such resource (or you are using spec.ControlPlaneVersion), you will need to either:
a) explicitly set such `spec.Version` field before upgrading CAPG (if you are already using spec.ControlPlaneVersion, please, use spec.Version instead)
or b) disable the MachineSetPreflightChecks in your cluster either:
b1) by setting this core CAPI feature gate to `false`
b2) or by disabling it via the relevant annotation on all the machineSets belonging to said cluster (follow this guide on how to do this: https://cluster-api.sigs.k8s.io/tasks/experimental-features/machineset-preflight-checks).
This is necessary as core CAPI 1.9 introduces a feature gate change, setting MachineSetPreflightChecks=true, which in turn relies on the presence of spec.Version and status.Version on the GCPManagedControlPlane object.
These fields will be deprecated in a future release.
```


Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 4bb218a3..faa3cd58 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -196,6 +196,11 @@ issues:
     - linters:
         - gocritic
       text: "appendAssign: append result not assigned to the same slice"
+    # Specific exclude rules for deprecated fields that are still part of the codebase.
+    # These should be removed as the referenced deprecated item is removed from the project.
+    - linters:
+        - staticcheck
+      text: "SA1019: (s.GCPManagedControlPlane.Status.CurrentVersion|s.scope.GCPManagedControlPlane.Status.CurrentVersion|spec.ControlPlaneVersion|s.GCPManagedControlPlane.Spec.ControlPlaneVersion|s.scope.GCPManagedControlPlane.Spec.ControlPlaneVersion) is deprecated: This field will soon be removed and you are expected to use Version instead."
 
 run:
   timeout: 10m
diff --git a/api/v1beta1/gcpmachine_types.go b/api/v1beta1/gcpmachine_types.go
index acff1ab3..0675c6fd 100644
--- a/api/v1beta1/gcpmachine_types.go
+++ b/api/v1beta1/gcpmachine_types.go
@@ -135,9 +135,9 @@ const (
 	// ConfidentialComputePolicyDisabled disables confidential compute for the GCP machine.
 	ConfidentialComputePolicyDisabled ConfidentialComputePolicy = "Disabled"
 	// ConfidentialComputePolicySEV sets AMD SEV as the VM instance's confidential computing technology of choice.
-	ConfidentialComputePolicySEV ConfidentialComputePolicy = "AMDEncryptedVirtualization"
+	ConfidentialComputePolicySEV ConfidentialComputePolicy = "AMDEncrytedVirtualization"
 	// ConfidentialComputePolicySEVSNP sets AMD SEV-SNP as the VM instance's confidential computing technology of choice.
-	ConfidentialComputePolicySEVSNP ConfidentialComputePolicy = "AMDEncryptedVirtualizationNestedPaging"
+	ConfidentialComputePolicySEVSNP ConfidentialComputePolicy = "AMDEncrytedVirtualizationNestedPaging"
 	// ConfidentialComputePolicyTDX sets Intel TDX as the VM instance's confidential computing technology of choice.
 	ConfidentialComputePolicyTDX ConfidentialComputePolicy = "IntelTrustedDomainExtensions"
 )
@@ -353,7 +353,7 @@ type GCPMachineSpec struct {
 	// If IntelTrustedDomainExtensions, it will configure Intel TDX as the confidential computing technology.
 	// If enabled (any value other than Disabled) OnHostMaintenance is required to be set to "Terminate".
 	// If omitted, the platform chooses a default, which is subject to change over time, currently that default is false.
-	// +kubebuilder:validation:Enum=Enabled;Disabled;AMDEncryptedVirtualization;AMDEncryptedVirtualizationNestedPaging;IntelTrustedDomainExtensions
+	// +kubebuilder:validation:Enum=Enabled;Disabled;AMDEncrytedVirtualization;AMDEncrytedVirtualizationNestedPaging;IntelTrustedDomainExtensions
 	// +optional
 	ConfidentialCompute *ConfidentialComputePolicy `json:"confidentialCompute,omitempty"`
 
diff --git a/cloud/scope/managedcontrolplane.go b/cloud/scope/managedcontrolplane.go
index a9ac5421..1587a6ae 100644
--- a/cloud/scope/managedcontrolplane.go
+++ b/cloud/scope/managedcontrolplane.go
@@ -237,3 +237,14 @@ func (s *ManagedControlPlaneScope) SetEndpoint(host string) {
 func (s *ManagedControlPlaneScope) IsAutopilotCluster() bool {
 	return s.GCPManagedControlPlane.Spec.EnableAutopilot
 }
+
+// GetControlPlaneVersion returns the control plane version from the specification.
+func (s *ManagedControlPlaneScope) GetControlPlaneVersion() *string {
+	if s.GCPManagedControlPlane.Spec.Version != nil {
+		return s.GCPManagedControlPlane.Spec.Version
+	}
+	if s.GCPManagedControlPlane.Spec.ControlPlaneVersion != nil {
+		return s.GCPManagedControlPlane.Spec.ControlPlaneVersion
+	}
+	return nil
+}
diff --git a/cloud/services/container/clusters/reconcile.go b/cloud/services/container/clusters/reconcile.go
index 64ceec39..e3e729c3 100644
--- a/cloud/services/container/clusters/reconcile.go
+++ b/cloud/services/container/clusters/reconcile.go
@@ -96,7 +96,9 @@ func (s *Service) Reconcile(ctx context.Context) (ctrl.Result, error) {
 	}
 
 	log.V(2).Info("gke cluster found", "status", cluster.GetStatus())
-	s.scope.GCPManagedControlPlane.Status.CurrentVersion = convertToSdkMasterVersion(cluster.GetCurrentMasterVersion())
+	controlPlaneVersion := convertToSdkMasterVersion(cluster.GetCurrentMasterVersion())
+	s.scope.GCPManagedControlPlane.Status.CurrentVersion = controlPlaneVersion
+	s.scope.GCPManagedControlPlane.Status.Version = &controlPlaneVersion
 
 	switch cluster.GetStatus() {
 	case containerpb.Cluster_PROVISIONING:
@@ -271,8 +273,8 @@ func (s *Service) createCluster(ctx context.Context, log *logr.Logger) error {
 			},
 		},
 	}
-	if s.scope.GCPManagedControlPlane.Spec.ControlPlaneVersion != nil {
-		cluster.InitialClusterVersion = convertToSdkMasterVersion(*s.scope.GCPManagedControlPlane.Spec.ControlPlaneVersion)
+	if initialClusterVersionFromSpec := s.scope.GetControlPlaneVersion(); initialClusterVersionFromSpec != nil {
+		cluster.InitialClusterVersion = convertToSdkMasterVersion(*initialClusterVersionFromSpec)
 	}
 	if s.scope.GCPManagedControlPlane.Spec.ClusterNetwork != nil {
 		cn := s.scope.GCPManagedControlPlane.Spec.ClusterNetwork
@@ -434,8 +436,8 @@ func (s *Service) checkDiffAndPrepareUpdate(existingCluster *containerpb.Cluster
 		}
 	}
 	// Master version
-	if s.scope.GCPManagedControlPlane.Spec.ControlPlaneVersion != nil {
-		desiredMasterVersion := convertToSdkMasterVersion(*s.scope.GCPManagedControlPlane.Spec.ControlPlaneVersion)
+	if desiredMasterVersionFromSpec := s.scope.GetControlPlaneVersion(); desiredMasterVersionFromSpec != nil {
+		desiredMasterVersion := convertToSdkMasterVersion(*desiredMasterVersionFromSpec)
 		existingClusterMasterVersion := convertToSdkMasterVersion(existingCluster.GetCurrentMasterVersion())
 		if desiredMasterVersion != existingClusterMasterVersion {
 			needUpdate = true
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmachines.yaml
index 855a210d..597e723a 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmachines.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmachines.yaml
@@ -204,8 +204,8 @@ spec:
                 enum:
                 - Enabled
                 - Disabled
-                - AMDEncryptedVirtualization
-                - AMDEncryptedVirtualizationNestedPaging
+                - AMDEncrytedVirtualization
+                - AMDEncrytedVirtualizationNestedPaging
                 - IntelTrustedDomainExtensions
                 type: string
               image:
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmachinetemplates.yaml
index b3e800c2..9aa65d88 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmachinetemplates.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmachinetemplates.yaml
@@ -219,8 +219,8 @@ spec:
                         enum:
                         - Enabled
                         - Disabled
-                        - AMDEncryptedVirtualization
-                        - AMDEncryptedVirtualizationNestedPaging
+                        - AMDEncrytedVirtualization
+                        - AMDEncrytedVirtualizationNestedPaging
                         - IntelTrustedDomainExtensions
                         type: string
                       image:
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmanagedcontrolplanes.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmanagedcontrolplanes.yaml
index c1de7a59..2eb9c294 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmanagedcontrolplanes.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_gcpmanagedcontrolplanes.yaml
@@ -31,6 +31,10 @@ spec:
       jsonPath: .status.currentVersion
       name: CurrentVersion
       type: string
+    - description: The Kubernetes version of the GKE control plane
+      jsonPath: .status.version
+      name: Version
+      type: string
     - description: API Endpoint
       jsonPath: .spec.endpoint
       name: Endpoint
@@ -133,6 +137,8 @@ spec:
                   ControlPlaneVersion represents the control plane version of the GKE cluster.
                   If not specified, the default version currently supported by GKE will be
                   used.
+
+                  Deprecated: This field will soon be removed and you are expected to use Version instead.
                 type: string
               description:
                 description: Description describe the cluster.
@@ -217,6 +223,12 @@ spec:
                 - regular
                 - stable
                 type: string
+              version:
+                description: |-
+                  Version represents the control plane version of the GKE cluster.
+                  If not specified, the default version currently supported by GKE will be
+                  used.
+                type: string
             required:
             - location
             - project
@@ -272,8 +284,10 @@ spec:
                   type: object
                 type: array
               currentVersion:
-                description: CurrentVersion shows the current version of the GKE control
-                  plane.
+                description: |-
+                  CurrentVersion shows the current version of the GKE control plane.
+
+                  Deprecated: This field will soon be removed and you are expected to use Version instead.
                 type: string
               initialized:
                 description: |-
@@ -286,6 +300,9 @@ spec:
                   Ready denotes that the GCPManagedControlPlane API Server is ready to
                   receive requests.
                 type: boolean
+              version:
+                description: Version represents the version of the GKE control plane.
+                type: string
             required:
             - ready
             type: object
diff --git a/exp/api/v1beta1/gcpmanagedcontrolplane_types.go b/exp/api/v1beta1/gcpmanagedcontrolplane_types.go
index d4c65b4c..53ecffcd 100644
--- a/exp/api/v1beta1/gcpmanagedcontrolplane_types.go
+++ b/exp/api/v1beta1/gcpmanagedcontrolplane_types.go
@@ -146,8 +146,16 @@ type GCPManagedControlPlaneSpec struct {
 	// ControlPlaneVersion represents the control plane version of the GKE cluster.
 	// If not specified, the default version currently supported by GKE will be
 	// used.
+	//
+	// Deprecated: This field will soon be removed and you are expected to use Version instead.
+	//
 	// +optional
 	ControlPlaneVersion *string `json:"controlPlaneVersion,omitempty"`
+	// Version represents the control plane version of the GKE cluster.
+	// If not specified, the default version currently supported by GKE will be
+	// used.
+	// +optional
+	Version *string `json:"version,omitempty"`
 	// Endpoint represents the endpoint used to communicate with the control plane.
 	// +optional
 	Endpoint clusterv1.APIEndpoint `json:"endpoint"`
@@ -183,8 +191,15 @@ type GCPManagedControlPlaneStatus struct {
 	Conditions clusterv1.Conditions `json:"conditions,omitempty"`
 
 	// CurrentVersion shows the current version of the GKE control plane.
+	//
+	// Deprecated: This field will soon be removed and you are expected to use Version instead.
+	//
 	// +optional
 	CurrentVersion string `json:"currentVersion,omitempty"`
+
+	// Version represents the version of the GKE control plane.
+	// +optional
+	Version *string `json:"version,omitempty"`
 }
 
 // +kubebuilder:object:root=true
@@ -194,6 +209,7 @@ type GCPManagedControlPlaneStatus struct {
 // +kubebuilder:printcolumn:name="Cluster",type="string",JSONPath=".metadata.labels.cluster\\.x-k8s\\.io/cluster-name",description="Cluster to which this GCPManagedControlPlane belongs"
 // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.ready",description="Control plane is ready"
 // +kubebuilder:printcolumn:name="CurrentVersion",type="string",JSONPath=".status.currentVersion",description="The current Kubernetes version"
+// +kubebuilder:printcolumn:name="Version",type="string",JSONPath=".status.version",description="The Kubernetes version of the GKE control plane"
 // +kubebuilder:printcolumn:name="Endpoint",type="string",JSONPath=".spec.endpoint",description="API Endpoint",priority=1
 
 // GCPManagedControlPlane is the Schema for the gcpmanagedcontrolplanes API.
diff --git a/exp/api/v1beta1/gcpmanagedcontrolplane_webhook.go b/exp/api/v1beta1/gcpmanagedcontrolplane_webhook.go
index 7835b414..d9a1ff32 100644
--- a/exp/api/v1beta1/gcpmanagedcontrolplane_webhook.go
+++ b/exp/api/v1beta1/gcpmanagedcontrolplane_webhook.go
@@ -76,6 +76,7 @@ var _ webhook.Validator = &GCPManagedControlPlane{}
 func (r *GCPManagedControlPlane) ValidateCreate() (admission.Warnings, error) {
 	gcpmanagedcontrolplanelog.Info("validate create", "name", r.Name)
 	var allErrs field.ErrorList
+	var allWarns admission.Warnings
 
 	if len(r.Spec.ClusterName) > maxClusterNameLength {
 		allErrs = append(allErrs,
@@ -98,11 +99,20 @@ func (r *GCPManagedControlPlane) ValidateCreate() (admission.Warnings, error) {
 			r.Spec.LoggingService, "can't be set when autopilot is enabled"))
 	}
 
+	if r.Spec.ControlPlaneVersion != nil {
+		if r.Spec.Version != nil {
+			allErrs = append(allErrs, field.Invalid(field.NewPath("spec", "ControlPlaneVersion"),
+				r.Spec.LoggingService, "spec.ControlPlaneVersion and spec.Version cannot be set at the same time: please use spec.Version"))
+		} else {
+			allWarns = append(allWarns, "spec.ControlPlaneVersion is deprecated and will soon be removed: please use spec.Version")
+		}
+	}
+
 	if len(allErrs) == 0 {
-		return nil, nil
+		return allWarns, nil
 	}
 
-	return nil, apierrors.NewInvalid(GroupVersion.WithKind("GCPManagedControlPlane").GroupKind(), r.Name, allErrs)
+	return allWarns, apierrors.NewInvalid(GroupVersion.WithKind("GCPManagedControlPlane").GroupKind(), r.Name, allErrs)
 }
 
 // ValidateUpdate implements webhook.Validator so a webhook will be registered for the type.
@@ -149,6 +159,11 @@ func (r *GCPManagedControlPlane) ValidateUpdate(oldRaw runtime.Object) (admissio
 			r.Spec.LoggingService, "can't be set when autopilot is enabled"))
 	}
 
+	if old.Spec.Version != nil && r.Spec.ControlPlaneVersion != nil {
+		allErrs = append(allErrs, field.Invalid(field.NewPath("spec", "ControlPlaneVersion"),
+			r.Spec.LoggingService, "spec.ControlPlaneVersion and spec.Version cannot be set at the same time: please use spec.Version"))
+	}
+
 	if r.Spec.LoggingService != nil {
 		err := r.Spec.LoggingService.Validate()
 		if err != nil {
diff --git a/exp/api/v1beta1/zz_generated.deepcopy.go b/exp/api/v1beta1/zz_generated.deepcopy.go
index f14563ca..73a813a1 100644
--- a/exp/api/v1beta1/zz_generated.deepcopy.go
+++ b/exp/api/v1beta1/zz_generated.deepcopy.go
@@ -307,6 +307,11 @@ func (in *GCPManagedControlPlaneSpec) DeepCopyInto(out *GCPManagedControlPlaneSp
 		*out = new(string)
 		**out = **in
 	}
+	if in.Version != nil {
+		in, out := &in.Version, &out.Version
+		*out = new(string)
+		**out = **in
+	}
 	out.Endpoint = in.Endpoint
 	if in.MasterAuthorizedNetworksConfig != nil {
 		in, out := &in.MasterAuthorizedNetworksConfig, &out.MasterAuthorizedNetworksConfig
@@ -345,6 +350,11 @@ func (in *GCPManagedControlPlaneStatus) DeepCopyInto(out *GCPManagedControlPlane
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.Version != nil {
+		in, out := &in.Version, &out.Version
+		*out = new(string)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GCPManagedControlPlaneStatus.
diff --git a/go.mod b/go.mod
index a1c67299..e4ced8de 100644
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,16 @@ module sigs.k8s.io/cluster-api-provider-gcp
 
 go 1.22.0
 
+toolchain go1.22.8
+
+replace (
+	// cel-go should match the version imported by CAPI.
+	github.com/google/cel-go => github.com/google/cel-go v0.20.1
+	// kube-openapi should match the version imported by CAPI.
+	k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340
+	sigs.k8s.io/cluster-api => sigs.k8s.io/cluster-api v1.9.4
+)
+
 require (
 	cloud.google.com/go/compute v1.31.1
 	cloud.google.com/go/container v1.42.0


Test patch:
diff --git a/exp/api/v1beta1/gcpmanagedcontrolplane_webhook_test.go b/exp/api/v1beta1/gcpmanagedcontrolplane_webhook_test.go
index 1d5ea120..7a89871c 100644
--- a/exp/api/v1beta1/gcpmanagedcontrolplane_webhook_test.go
+++ b/exp/api/v1beta1/gcpmanagedcontrolplane_webhook_test.go
@@ -72,21 +72,21 @@ func TestGCPManagedControlPlaneDefaultingWebhook(t *testing.T) {
 			resourceName: "cluster1",
 			resourceNS:   "default",
 			spec: GCPManagedControlPlaneSpec{
-				ClusterName:         "cluster1_27_1",
-				ControlPlaneVersion: &vV1_27_1,
+				ClusterName: "cluster1_27_1",
+				Version:     &vV1_27_1,
 			},
-			expectSpec: GCPManagedControlPlaneSpec{ClusterName: "cluster1_27_1", ControlPlaneVersion: &vV1_27_1},
+			expectSpec: GCPManagedControlPlaneSpec{ClusterName: "cluster1_27_1", Version: &vV1_27_1},
 		},
 		{
 			name:         "with autopilot enabled",
 			resourceName: "cluster1",
 			resourceNS:   "default",
 			spec: GCPManagedControlPlaneSpec{
-				ClusterName:         "cluster1_autopilot",
-				ControlPlaneVersion: &vV1_27_1,
-				EnableAutopilot:     true,
+				ClusterName:     "cluster1_autopilot",
+				Version:         &vV1_27_1,
+				EnableAutopilot: true,
 			},
-			expectSpec: GCPManagedControlPlaneSpec{ClusterName: "cluster1_autopilot", ControlPlaneVersion: &vV1_27_1, EnableAutopilot: true},
+			expectSpec: GCPManagedControlPlaneSpec{ClusterName: "cluster1_autopilot", Version: &vV1_27_1, EnableAutopilot: true},
 		},
 	}
 
@@ -120,11 +120,13 @@ func TestGCPManagedControlPlaneValidatingWebhookCreate(t *testing.T) {
 	tests := []struct {
 		name        string
 		expectError bool
+		expectWarn  bool
 		spec        GCPManagedControlPlaneSpec
 	}{
 		{
 			name:        "cluster name too long should cause an error",
 			expectError: true,
+			expectWarn:  false,
 			spec: GCPManagedControlPlaneSpec{
 				ClusterName: strings.Repeat("A", maxClusterNameLength+1),
 			},
@@ -132,6 +134,7 @@ func TestGCPManagedControlPlaneValidatingWebhookCreate(t *testing.T) {
 		{
 			name:        "autopilot enabled without release channel should cause an error",
 			expectError: true,
+			expectWarn:  false,
 			spec: GCPManagedControlPlaneSpec{
 				ClusterName:     "",
 				EnableAutopilot: true,
@@ -141,12 +144,32 @@ func TestGCPManagedControlPlaneValidatingWebhookCreate(t *testing.T) {
 		{
 			name:        "autopilot enabled with release channel",
 			expectError: false,
+			expectWarn:  false,
 			spec: GCPManagedControlPlaneSpec{
 				ClusterName:     "",
 				EnableAutopilot: true,
 				ReleaseChannel:  &releaseChannel,
 			},
 		},
+		{
+			name:        "using deprecated ControlPlaneVersion should cause a warning",
+			expectError: false,
+			expectWarn:  true,
+			spec: GCPManagedControlPlaneSpec{
+				ClusterName:         "",
+				ControlPlaneVersion: &vV1_27_1,
+			},
+		},
+		{
+			name:        "using both ControlPlaneVersion and Version should cause a warning and an error",
+			expectError: true,
+			expectWarn:  false,
+			spec: GCPManagedControlPlaneSpec{
+				ClusterName:         "",
+				ControlPlaneVersion: &vV1_27_1,
+				Version:             &vV1_27_1,
+			},
+		},
 	}
 
 	for _, tc := range tests {
@@ -163,8 +186,11 @@ func TestGCPManagedControlPlaneValidatingWebhookCreate(t *testing.T) {
 			} else {
 				g.Expect(err).ToNot(HaveOccurred())
 			}
-			// Nothing emits warnings yet
-			g.Expect(warn).To(BeEmpty())
+			if tc.expectWarn {
+				g.Expect(warn).ToNot(BeEmpty())
+			} else {
+				g.Expect(warn).To(BeEmpty())
+			}
 		})
 	}
 }
diff --git a/test/e2e/data/infrastructure-gcp/cluster-template-ci-gke.yaml b/test/e2e/data/infrastructure-gcp/cluster-template-ci-gke.yaml
index 228acdb5..72c9d410 100644
--- a/test/e2e/data/infrastructure-gcp/cluster-template-ci-gke.yaml
+++ b/test/e2e/data/infrastructure-gcp/cluster-template-ci-gke.yaml
@@ -33,6 +33,7 @@ metadata:
 spec:
   project: "${GCP_PROJECT}"
   location: "${GCP_REGION}"
+  version: "${KUBERNETES_VERSION}"
   releaseChannel: "regular"
 ---
 apiVersion: cluster.x-k8s.io/v1beta1