Skip to content

Commit 687ef8d

Browse files
authored
Fix K0smotronControlPlane version mismatch for Cluster API compatibility (#1053)
* refactor: Add FormatStatusVersion function for version normalization Extract version formatting logic into a dedicated function to handle the normalization of status.version based on spec.version format. This refactoring: - Introduces FormatStatusVersion function that removes "-k0s." suffix from status.version when spec.version doesn't contain it - Adds comprehensive unit tests covering various suffix scenarios - Prepares the codebase for fixing the Cluster API compatibility issue The function ensures consistent version format handling across the K0smotronControlPlane controller. Signed-off-by: kahirokunn <[email protected]> * feat: Add K0sVersion field to preserve full version information Extend K0smotronControlPlaneStatus with K0sVersion field to store the complete k0s version string while maintaining Cluster API compatibility through the existing Version field. This change: - Adds K0sVersion field to K0smotronControlPlaneStatus - Updates computeStatus to populate both status.version (formatted) and status.k0sVersion (full version string) - Regenerates CRDs to include the new field This ensures that: - status.version remains compatible with Cluster API expectations - Full k0s version information is preserved in status.k0sVersion - No version information is lost during the formatting process Signed-off-by: kahirokunn <[email protected]> * test: Add e2e test for K0smotronControlPlane version format with MachineDeployment Extend the existing CAPI Docker MachineDeployment integration test to verify the version format handling. The test verifies: - K0smotronControlPlane status.version format matches spec.version format - When spec.version has -k0s. suffix, status.version keeps it - When spec.version has no suffix, status.version has no suffix - status.k0sVersion always contains the full k0s version with suffix - MachineDeployment works correctly with both version formats Test implementation: - Split test into two subtests: WithK0sSuffix and WithoutK0sSuffix - Add getK0smotronControlPlaneStatus() to retrieve status via REST API - Add verifyK0smotronControlPlaneVersionFormat() to validate version consistency - Create cluster-with-machinedeployment-no-suffix.yaml for testing without suffix - Maintain backward compatibility by keeping original file names This ensures Cluster API compatibility while preserving complete version information for k0s operations in MachineDeployment scenarios. Signed-off-by: kahirokunn <[email protected]> * test: refactor verifyK0smotronControlPlaneVersionFormat for simplicity Split waiting and verification logic into separate functions. Removed verbose logging and reduced code while maintaining test coverage. Signed-off-by: kahirokunn <[email protected]> * test: Split MachineDeployment version tests into separate files Follow the "one file - one test" principle for integration tests by splitting the version format tests into dedicated test directories. Signed-off-by: kahirokunn <[email protected]> * test: Fix index out of bounds panic in getLBPort function Add bounds checking to prevent runtime panic when Docker container port mappings are empty or missing. This resolves the error: "runtime error: index out of range [0] with length 0" Signed-off-by: kahirokunn <[email protected]> * test: move TestFormatStatusVersion to k0smotron_controlplane_controller_test.go Signed-off-by: kahirokunn <[email protected]> * feat(e2e): add K0smotronControlPlane helper functions for e2e tests - Add WaitForK0smotronControlPlaneToBeReady to wait for K0smotronControlPlane readiness - Add DiscoveryAndWaitForK0smotronControlPlaneInitialized for control plane discovery - Add k0smotronControlPlaneExists to check K0smotronControlPlane existence - Import unstructured package for dynamic K0smotronControlPlane handling These functions enable e2e tests to work with K0smotronControlPlane resources. Signed-off-by: kahirokunn <[email protected]> * refactor: migrate MachineDeployment test from inttest to e2e framework Signed-off-by: kahirokunn <[email protected]> * Make latest tag explicit in Docker config Signed-off-by: kahirokunn <[email protected]> * remove .status.k0sVersion Signed-off-by: kahirokunn <[email protected]> * Set version to v1.27.2 in tests Reuse existing tests with version v1.27.2 to ensure e2e functionality without creating new versions. Signed-off-by: kahirokunn <[email protected]> * fix: prevent infinite requeue due to version format mismatch in K0smotronControlPlane Apply consistent version formatting in status comparison to avoid mismatch between desiredVersion (with k0s suffix) and status.version (potentially without suffix), which was causing infinite reconciliation loops. Signed-off-by: kahirokunn <[email protected]> * Fix spelling mistakes Signed-off-by: kahirokunn <[email protected]> * feat: add k0s version suffix warnings to control plane webhooks Warn users when control plane versions lack k0s suffix (e.g., 'v1.32.2' instead of 'v1.32.2-k0s.0'). Adds validation webhooks for both K0sControlPlane and K0smotronControlPlane resources. - K0sControlPlane: warns for missing '+k0s.' suffix - K0smotronControlPlane: warns for missing '-k0s.' suffix Signed-off-by: kahirokunn <[email protected]> * refactor: remove unused K0smotronControlPlane utility functions Signed-off-by: kahirokunn <[email protected]> --------- Signed-off-by: kahirokunn <[email protected]>
1 parent 9b5d031 commit 687ef8d

File tree

16 files changed

+640
-21
lines changed

16 files changed

+640
-21
lines changed

.github/workflows/go.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ jobs:
186186
- admission-webhook-recreate-strategy-in-single-mode
187187
- admission-webhook-k0s-not-compatible
188188
- k0smotron-upgrade
189+
- machinedeployment
189190

190191
steps:
191192
- name: Check out code into the Go module directory
@@ -209,4 +210,4 @@ jobs:
209210
with:
210211
name: e2e-artifacts
211212
path: _artifacts
212-
if-no-files-found: ignore
213+
if-no-files-found: ignore

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ generate-e2e-templates-main: $(KUSTOMIZE)
119119
$(KUSTOMIZE) build $(DOCKER_TEMPLATES)/main/cluster-template --load-restrictor LoadRestrictionsNone > $(DOCKER_TEMPLATES)/main/cluster-template.yaml
120120
$(KUSTOMIZE) build $(DOCKER_TEMPLATES)/main/cluster-template-webhook-recreate-in-single-mode --load-restrictor LoadRestrictionsNone > $(DOCKER_TEMPLATES)/main/cluster-template-webhook-recreate-in-single-mode.yaml
121121
$(KUSTOMIZE) build $(DOCKER_TEMPLATES)/main/cluster-template-webhook-k0s-not-compatible --load-restrictor LoadRestrictionsNone > $(DOCKER_TEMPLATES)/main/cluster-template-webhook-k0s-not-compatible.yaml
122+
$(KUSTOMIZE) build $(DOCKER_TEMPLATES)/main/cluster-template-machinedeployment --load-restrictor LoadRestrictionsNone > $(DOCKER_TEMPLATES)/main/cluster-template-machinedeployment.yaml
122123

123124
e2e: k0smotron-image-bundle.tar install.yaml kustomize generate-e2e-templates-main
124125
set +x;

api/controlplane/v1beta1/k0smotron_types.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ type K0smotronControlPlaneStatus struct {
9191
// They may either be pods that are running but not yet ready.
9292
// +optional
9393
UnavailableReplicas int32 `json:"unavailableReplicas"`
94-
9594
// selector is the label selector for pods that should match the replicas count.
9695
Selector string `json:"selector,omitempty"`
9796
}

cmd/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,11 @@ func main() {
291291
setupLog.Error(err, "unable to create validation webhook", "webhook", "K0sControlPlaneValidator")
292292
os.Exit(1)
293293
}
294+
295+
if err = (&controlplane.K0smotronControlPlaneValidator{}).SetupK0smotronControlPlaneWebhookWithManager(mgr); err != nil {
296+
setupLog.Error(err, "unable to create validation webhook", "webhook", "K0smotronControlPlaneValidator")
297+
os.Exit(1)
298+
}
294299
}
295300
}
296301

config/webhook/manifests.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,23 @@ webhooks:
5050
resources:
5151
- k0scontrolplanes
5252
sideEffects: None
53+
- admissionReviewVersions:
54+
- v1
55+
clientConfig:
56+
service:
57+
name: webhook-service
58+
namespace: system
59+
path: /validate-controlplane-cluster-x-k8s-io-v1beta1-k0smotroncontrolplane
60+
failurePolicy: Fail
61+
name: validate-k0smotroncontrolplane-v1beta1.k0smotron.io
62+
rules:
63+
- apiGroups:
64+
- controlplane.cluster.x-k8s.io
65+
apiVersions:
66+
- v1beta1
67+
operations:
68+
- CREATE
69+
- UPDATE
70+
resources:
71+
- k0smotroncontrolplanes
72+
sideEffects: None

e2e/config/docker.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# - control-plane k0smotron
66
# - infrastructure docker
77
images:
8-
- name: quay.io/k0sproject/k0smotron
8+
- name: quay.io/k0sproject/k0smotron:latest
99
loadBehavior: mustLoad
1010

1111
providers:
@@ -38,6 +38,7 @@ providers:
3838
- sourcePath: "../data/infrastructure-docker/main/cluster-template-kcp-remediation.yaml"
3939
- sourcePath: "../data/infrastructure-docker/main/cluster-template-webhook-recreate-in-single-mode.yaml"
4040
- sourcePath: "../data/infrastructure-docker/main/cluster-template-webhook-k0s-not-compatible.yaml"
41+
- sourcePath: "../data/infrastructure-docker/main/cluster-template-machinedeployment.yaml"
4142
- name: k0sproject-k0smotron
4243
type: ControlPlaneProvider
4344
versions:
@@ -155,3 +156,6 @@ intervals:
155156
workload-inplace-upgrade/wait-cluster: ["10m", "10s"]
156157
workload-inplace-upgrade/wait-control-plane: ["20m", "10s"]
157158
workload-inplace-upgrade/wait-worker-nodes: ["20m", "10s"]
159+
machinedeployment/wait-cluster: ["20m", "10s"]
160+
machinedeployment/wait-control-plane: ["20m", "10s"]
161+
machinedeployment/wait-delete-cluster: ["20m", "10s"]
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
apiVersion: cluster.x-k8s.io/v1beta1
2+
kind: Cluster
3+
metadata:
4+
name: ${CLUSTER_NAME}
5+
namespace: ${NAMESPACE}
6+
spec:
7+
clusterNetwork:
8+
pods:
9+
cidrBlocks:
10+
- 192.168.0.0/16
11+
serviceDomain: cluster.local
12+
services:
13+
cidrBlocks:
14+
- 10.128.0.0/12
15+
controlPlaneRef:
16+
apiVersion: controlplane.cluster.x-k8s.io/v1beta1
17+
kind: K0smotronControlPlane
18+
name: ${CLUSTER_NAME}
19+
infrastructureRef:
20+
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
21+
kind: DockerCluster
22+
name: ${CLUSTER_NAME}
23+
---
24+
apiVersion: controlplane.cluster.x-k8s.io/v1beta1
25+
kind: K0smotronControlPlane
26+
metadata:
27+
name: ${CLUSTER_NAME}
28+
namespace: ${NAMESPACE}
29+
spec:
30+
version: ${KUBERNETES_VERSION}
31+
persistence:
32+
type: emptyDir
33+
service:
34+
type: NodePort
35+
---
36+
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
37+
kind: DockerCluster
38+
metadata:
39+
name: ${CLUSTER_NAME}
40+
namespace: ${NAMESPACE}
41+
spec:
42+
---
43+
apiVersion: cluster.x-k8s.io/v1beta1
44+
kind: MachineDeployment
45+
metadata:
46+
name: ${CLUSTER_NAME}
47+
namespace: ${NAMESPACE}
48+
spec:
49+
replicas: 2
50+
clusterName: ${CLUSTER_NAME}
51+
selector:
52+
matchLabels:
53+
cluster.x-k8s.io/cluster-name: ${CLUSTER_NAME}
54+
pool: worker-pool-1
55+
template:
56+
metadata:
57+
labels:
58+
cluster.x-k8s.io/cluster-name: ${CLUSTER_NAME}
59+
pool: worker-pool-1
60+
spec:
61+
version: ${KUBERNETES_VERSION}
62+
clusterName: ${CLUSTER_NAME}
63+
bootstrap:
64+
configRef:
65+
apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
66+
kind: K0sWorkerConfigTemplate
67+
name: ${CLUSTER_NAME}
68+
infrastructureRef:
69+
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
70+
kind: DockerMachineTemplate
71+
name: ${CLUSTER_NAME}
72+
---
73+
apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
74+
kind: K0sWorkerConfigTemplate
75+
metadata:
76+
name: ${CLUSTER_NAME}
77+
namespace: ${NAMESPACE}
78+
spec:
79+
template:
80+
spec:
81+
version: ${KUBERNETES_VERSION}+k0s.0
82+
---
83+
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
84+
kind: DockerMachineTemplate
85+
metadata:
86+
name: ${CLUSTER_NAME}
87+
namespace: ${NAMESPACE}
88+
spec:
89+
template:
90+
spec: {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: kustomize.config.k8s.io/v1beta1
2+
kind: Kustomization
3+
4+
resources:
5+
- ../bases/machinedeployment.yaml

e2e/machinedeployment_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
//go:build e2e
2+
3+
/*
4+
Copyright 2025.
5+
6+
Licensed under the Apache License, Version 2.0 (the "License");
7+
you may not use this file except in compliance with the License.
8+
You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing, software
13+
distributed under the License is distributed on an "AS IS" BASIS,
14+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
See the License for the specific language governing permissions and
16+
limitations under the License.
17+
*/
18+
19+
package e2e
20+
21+
import (
22+
"context"
23+
"fmt"
24+
"path/filepath"
25+
"testing"
26+
"time"
27+
28+
"github.com/k0sproject/k0smotron/e2e/util"
29+
"github.com/stretchr/testify/require"
30+
corev1 "k8s.io/api/core/v1"
31+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
32+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
33+
"k8s.io/client-go/kubernetes"
34+
"k8s.io/client-go/rest"
35+
"k8s.io/client-go/tools/clientcmd"
36+
"k8s.io/utils/ptr"
37+
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
38+
capiframework "sigs.k8s.io/cluster-api/test/framework"
39+
"sigs.k8s.io/cluster-api/test/framework/clusterctl"
40+
capiutil "sigs.k8s.io/cluster-api/util"
41+
"sigs.k8s.io/controller-runtime/pkg/client"
42+
)
43+
44+
func TestMachinedeployment(t *testing.T) {
45+
setupAndRun(t, func(t *testing.T) {
46+
testName := "machinedeployment"
47+
48+
// Setup a Namespace where to host objects for this spec and create a watcher for the namespace events.
49+
namespace, _ := util.SetupSpecNamespace(ctx, testName, bootstrapClusterProxy, artifactFolder)
50+
51+
clusterName := fmt.Sprintf("%s-%s", testName, capiutil.RandomString(6))
52+
53+
workloadClusterTemplate := clusterctl.ConfigCluster(ctx, clusterctl.ConfigClusterInput{
54+
ClusterctlConfigPath: clusterctlConfigPath,
55+
KubeconfigPath: bootstrapClusterProxy.GetKubeconfigPath(),
56+
// select cluster templates
57+
Flavor: "machinedeployment",
58+
59+
Namespace: namespace.Name,
60+
ClusterName: clusterName,
61+
KubernetesVersion: "v1.32.2",
62+
ControlPlaneMachineCount: ptr.To[int64](1),
63+
// TODO: make infra provider configurable
64+
InfrastructureProvider: "docker",
65+
LogFolder: filepath.Join(artifactFolder, "clusters", bootstrapClusterProxy.GetName()),
66+
ClusterctlVariables: map[string]string{
67+
"CLUSTER_NAME": clusterName,
68+
"NAMESPACE": namespace.Name,
69+
},
70+
})
71+
require.NotNil(t, workloadClusterTemplate)
72+
73+
require.Eventually(t, func() bool {
74+
return bootstrapClusterProxy.CreateOrUpdate(ctx, workloadClusterTemplate) == nil
75+
}, 10*time.Second, 1*time.Second, "Failed to apply the cluster template")
76+
77+
cluster, err := util.DiscoveryAndWaitForCluster(ctx, capiframework.DiscoveryAndWaitForClusterInput{
78+
Getter: bootstrapClusterProxy.GetClient(),
79+
Namespace: namespace.Name,
80+
Name: clusterName,
81+
}, util.GetInterval(e2eConfig, testName, "wait-cluster"))
82+
require.NoError(t, err)
83+
84+
defer func() {
85+
util.DumpSpecResourcesAndCleanup(
86+
ctx,
87+
testName,
88+
bootstrapClusterProxy,
89+
artifactFolder,
90+
namespace,
91+
cancelWatches,
92+
cluster,
93+
util.GetInterval(e2eConfig, testName, "wait-delete-cluster"),
94+
skipCleanup,
95+
)
96+
}()
97+
98+
err = util.DiscoveryAndWaitForK0smotronControlPlaneInitialized(ctx, capiframework.DiscoveryAndWaitForControlPlaneInitializedInput{
99+
Lister: bootstrapClusterProxy.GetClient(),
100+
Cluster: cluster,
101+
}, util.GetInterval(e2eConfig, testName, "wait-control-plane"))
102+
require.NoError(t, err)
103+
104+
err = util.WaitForK0smotronControlPlaneToBeReady(ctx, bootstrapClusterProxy.GetClient(), clusterName, namespace.Name, util.GetInterval(e2eConfig, testName, "wait-control-plane"))
105+
require.NoError(t, err)
106+
107+
fmt.Print("Verifying K0smotronControlPlane version format\n")
108+
verifyK0smotronControlPlaneVersionFormat(ctx, t, bootstrapClusterProxy, clusterName, namespace.Name)
109+
110+
// Get the kubeconfig for the workload cluster
111+
workloadClusterKubeconfig := getWorkloadClusterKubeconfig(ctx, t, bootstrapClusterProxy, clusterName, namespace.Name)
112+
113+
fmt.Print("Waiting for MachineDeployment to be ready\n")
114+
require.Eventually(t, func() bool {
115+
md := &clusterv1.MachineDeployment{}
116+
err := bootstrapClusterProxy.GetClient().Get(ctx, client.ObjectKey{
117+
Namespace: namespace.Name,
118+
Name: clusterName,
119+
}, md)
120+
if err != nil {
121+
return false
122+
}
123+
return md.Status.ReadyReplicas == 2
124+
}, 5*time.Minute, 10*time.Second, "MachineDeployment failed to become ready")
125+
126+
fmt.Print("Verifying worker nodes are ready in the workload cluster\n")
127+
verifyWorkerNodesReady(ctx, t, workloadClusterKubeconfig, 2)
128+
129+
fmt.Print("MachineDeployment test completed successfully\n")
130+
})
131+
}
132+
133+
func verifyK0smotronControlPlaneVersionFormat(ctx context.Context, t *testing.T, clusterProxy capiframework.ClusterProxy, clusterName, namespace string) {
134+
kcp := &unstructured.Unstructured{}
135+
kcp.SetAPIVersion("controlplane.cluster.x-k8s.io/v1beta1")
136+
kcp.SetKind("K0smotronControlPlane")
137+
138+
require.Eventually(t, func() bool {
139+
err := clusterProxy.GetClient().Get(ctx, client.ObjectKey{
140+
Namespace: namespace,
141+
Name: clusterName,
142+
}, kcp)
143+
if err != nil {
144+
return false
145+
}
146+
147+
status, found, err := unstructured.NestedMap(kcp.Object, "status")
148+
if err != nil || !found {
149+
return false
150+
}
151+
152+
version, found, err := unstructured.NestedString(status, "version")
153+
if err != nil || !found {
154+
return false
155+
}
156+
157+
if version != "v1.32.2" {
158+
t.Errorf("Expected version %s, but got: %s", "v1.32.2", version)
159+
return false
160+
}
161+
162+
return true
163+
}, 3*time.Minute, 10*time.Second, "K0smotronControlPlane version format verification failed")
164+
}
165+
166+
func getWorkloadClusterKubeconfig(ctx context.Context, t *testing.T, clusterProxy capiframework.ClusterProxy, clusterName, namespace string) *rest.Config {
167+
kubeconfigSecret := &corev1.Secret{}
168+
require.Eventually(t, func() bool {
169+
err := clusterProxy.GetClient().Get(ctx, client.ObjectKey{
170+
Namespace: namespace,
171+
Name: fmt.Sprintf("%s-kubeconfig", clusterName),
172+
}, kubeconfigSecret)
173+
return err == nil
174+
}, 2*time.Minute, 10*time.Second, "Failed to get kubeconfig secret")
175+
176+
kubeconfig, ok := kubeconfigSecret.Data["value"]
177+
require.True(t, ok, "kubeconfig secret should contain 'value' key")
178+
179+
restConfig, err := clientcmd.RESTConfigFromKubeConfig(kubeconfig)
180+
require.NoError(t, err, "Failed to parse kubeconfig")
181+
182+
return restConfig
183+
}
184+
185+
func verifyWorkerNodesReady(ctx context.Context, t *testing.T, kubeconfig *rest.Config, expectedNodes int) {
186+
clientset, err := kubernetes.NewForConfig(kubeconfig)
187+
require.NoError(t, err, "Failed to create Kubernetes clientset")
188+
189+
require.Eventually(t, func() bool {
190+
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
191+
if err != nil {
192+
return false
193+
}
194+
195+
readyCount := 0
196+
for _, node := range nodes.Items {
197+
for _, condition := range node.Status.Conditions {
198+
if condition.Type == corev1.NodeReady && condition.Status == corev1.ConditionTrue {
199+
readyCount++
200+
break
201+
}
202+
}
203+
}
204+
205+
return readyCount == expectedNodes
206+
}, 5*time.Minute, 10*time.Second, "Expected number of worker nodes not ready")
207+
}

0 commit comments

Comments
 (0)