diff --git a/e2e/test_core/sync/test_k8sdefaultendpoint.go b/e2e/test_core/sync/test_k8sdefaultendpoint.go index b072ef8a99..148692616a 100644 --- a/e2e/test_core/sync/test_k8sdefaultendpoint.go +++ b/e2e/test_core/sync/test_k8sdefaultendpoint.go @@ -78,7 +78,7 @@ func K8sDefaultEndpointSpec() { } g.Expect(hostClusterIps).To(Equal(vClusterIps), "IPs should match between host and vcluster endpoints") g.Expect(hostClusterPorts).To(Equal(vClusterPorts), "Ports should match between host and vcluster endpoints") - }). + }).WithContext(ctx). WithPolling(constants.PollingInterval). WithTimeout(constants.PollingTimeoutVeryLong). Should(Succeed(), "Endpoints should match after waiting") diff --git a/e2e/test_core/sync/test_networkpolicy.go b/e2e/test_core/sync/test_networkpolicy.go index 6ed0b5d223..f0c9375688 100644 --- a/e2e/test_core/sync/test_networkpolicy.go +++ b/e2e/test_core/sync/test_networkpolicy.go @@ -77,7 +77,7 @@ func NetworkPolicySyncSpec() { networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress, ), "synced policy should preserve policy types") - }). + }).WithContext(ctx). WithPolling(constants.PollingInterval). WithTimeout(constants.PollingTimeout). Should(Succeed()) diff --git a/e2e/test_core/sync/test_node.go b/e2e/test_core/sync/test_node.go index 96041a1670..25ece54603 100644 --- a/e2e/test_core/sync/test_node.go +++ b/e2e/test_core/sync/test_node.go @@ -50,7 +50,7 @@ func NodeSyncLabelSelectorSpec() { g.Expect(hostSyncedNodeName).ToNot(BeEmpty(), "Should find host node with matching hostname") g.Expect(virtualNodes.Items).To(HaveLen(1), "Expected exactly one synced node") g.Expect(virtualNodes.Items[0].Name).To(Equal(hostSyncedNodeName), "Synced node name should match") - }). + }).WithContext(ctx). WithPolling(constants.PollingInterval). WithTimeout(constants.PollingTimeout). Should(Succeed(), "Node sync should work correctly") diff --git a/e2e/test_core/sync/test_pods.go b/e2e/test_core/sync/test_pods.go index 4fbeed4ccf..a1fae074c9 100644 --- a/e2e/test_core/sync/test_pods.go +++ b/e2e/test_core/sync/test_pods.go @@ -79,7 +79,7 @@ func PodSyncSpec() { g.Expect(err).NotTo(HaveOccurred(), "failed to get pod %s/%s", ns, podName) g.Expect(pod.Status.Phase).To(Equal(corev1.PodRunning), "pod %s/%s phase is %s, not yet Running", ns, podName, pod.Status.Phase) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) } defaultSecurityContext := func() *corev1.SecurityContext { @@ -159,7 +159,7 @@ func PodSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(p.Status.EphemeralContainerStatuses).NotTo(BeEmpty(), "expected ephemeral container statuses to be present") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) } } }) @@ -297,7 +297,7 @@ func PodSyncSpec() { g.Expect(len(pPod.Status.Conditions)).To(BeNumerically(">=", 5), "expected >= 5 conditions on host pod (4 standard + custom gate), got %d: %v", len(pPod.Status.Conditions), pPod.Status.Conditions) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) }) @@ -320,7 +320,7 @@ func PodSyncSpec() { Eventually(func(g Gomega) { _, err := vClusterClient.CoreV1().ServiceAccounts(ns).Get(ctx, saName, metav1.GetOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) By("Creating a pod using the non-default service account", func() { @@ -544,7 +544,7 @@ func PodSyncSpec() { pSvc, err := hostClient.CoreV1().Services(hostNSForSvc).Get(ctx, pSvcName, metav1.GetOptions{}) g.Expect(err).NotTo(HaveOccurred(), "host service not yet synced") g.Expect(pSvc.Spec.ClusterIP).NotTo(BeEmpty(), "host service ClusterIP not yet assigned") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) By("Creating a pod with dependent env vars", func() { _, err := vClusterClient.CoreV1().Pods(ns).Create(ctx, &corev1.Pod{ @@ -672,7 +672,7 @@ func PodSyncSpec() { g.Expect(pKey).NotTo(BeEmpty(), "namespace label with value %q not yet propagated to host pod, labels: %v", additionalLabelValue, pPod.GetLabels()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) }) @@ -797,7 +797,7 @@ func PodSyncSpec() { "annotation not synced from host to vCluster") g.Expect(vPod.Labels).To(HaveKeyWithValue(additionalLabelKey, additionalLabelValue), "label not synced from host to vCluster") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) additionalLabelValueFromVCluster := "good-syncer-from-vcluster" @@ -831,7 +831,7 @@ func PodSyncSpec() { "annotation not synced from vCluster to host") g.Expect(pPod.Labels).To(HaveKeyWithValue(additionalLabelKey, additionalLabelValueFromVCluster), "label not synced from vCluster to host") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) }) @@ -866,7 +866,7 @@ func PodSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(vpod.Status.ObservedGeneration).To(BeNumerically("==", 1), "status.observedGeneration is %d, expected 1", vpod.Status.ObservedGeneration) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) By("Updating pod tolerations to trigger a generation bump", func() { @@ -892,7 +892,97 @@ func PodSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(vpod.Status.ObservedGeneration).To(BeNumerically(">=", 2), "status.observedGeneration is %d, expected >= 2", vpod.Status.ObservedGeneration) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }) + }) + + It("should preserve virtual pod QOS class", func(ctx context.Context) { + // Regression test for https://github.com/loft-sh/vcluster/issues/3578, where pods + // got stuck at Ready=False. The syncer used to copy the virtual QOS class onto the + // host pod, so a later status update tried to change the host's QOS class. K8s 1.32+ + // treats that field as immutable and rejects the update. + suffix := random.String(6) + ns := "pod-qos-test-" + suffix + createTestNamespace(ctx, ns) + + podName := "qos-" + suffix + By("Creating a pod with no resource requests (virtual QOS class: BestEffort)", func() { + _, err := vClusterClient.CoreV1().Pods(ns).Create(ctx, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: podName}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: testingContainerName, + Image: testingContainerImage, + ImagePullPolicy: corev1.PullIfNotPresent, + SecurityContext: defaultSecurityContext(), + }}, + }, + }, metav1.CreateOptions{}) + Expect(err).To(Succeed()) + }) + + By("Waiting for the pod to be Running", func() { + waitPodRunning(ctx, podName, ns) + }) + + By("Verifying the virtual pod QOS class is BestEffort (not overwritten from host)", func() { + vpod, err := vClusterClient.CoreV1().Pods(ns).Get(ctx, podName, metav1.GetOptions{}) + Expect(err).To(Succeed()) + Expect(vpod.Status.QOSClass).To(Equal(corev1.PodQOSBestEffort), + "virtual pod QOS class should reflect the virtual apiserver's computation (BestEffort for no resource requests), not the host pod's QOS class") + }) + }) + + It("should keep pod Ready condition stable after reaching Ready=True", func(ctx context.Context) { + // Regression test for https://github.com/loft-sh/vcluster/issues/3578, where pods + // kept switching back to Ready=False because the syncer wrongly thought their + // conditions had changed on every reconcile. + // + // This only happens when the host runs K8s >= 1.34 and the virtual cluster runs + // < 1.34. On a same-version cluster the bug can't reproduce, so the real check + // lives in the unit test TestDiffPodStatusObservedGeneration. + suffix := random.String(6) + ns := "pod-cond-stable-test-" + suffix + createTestNamespace(ctx, ns) + + podName := "cond-stable-" + suffix + By("Creating a pod", func() { + _, err := vClusterClient.CoreV1().Pods(ns).Create(ctx, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: podName}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: testingContainerName, + Image: testingContainerImage, + ImagePullPolicy: corev1.PullIfNotPresent, + SecurityContext: defaultSecurityContext(), + }}, + }, + }, metav1.CreateOptions{}) + Expect(err).To(Succeed()) + }) + + readyCondition := And( + HaveField("Type", corev1.PodReady), + HaveField("Status", corev1.ConditionTrue), + ) + + By("Waiting for the pod to reach Running phase with Ready=True", func() { + waitPodRunning(ctx, podName, ns) + Eventually(func(g Gomega) { + pod, err := vClusterClient.CoreV1().Pods(ns).Get(ctx, podName, metav1.GetOptions{}) + g.Expect(err).To(Succeed()) + g.Expect(pod.Status.Conditions).To(ContainElement(readyCondition), + "pod Ready condition is not yet True: %v", pod.Status.Conditions) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }) + + By("Verifying Ready condition stays True and does not flap", func() { + Consistently(func(g Gomega) { + pod, err := vClusterClient.CoreV1().Pods(ns).Get(ctx, podName, metav1.GetOptions{}) + g.Expect(err).To(Succeed()) + g.Expect(pod.Status.Conditions).To(ContainElement(readyCondition), + "pod Ready condition flapped away from True: %v", pod.Status.Conditions) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutShort).Should(Succeed()) }) }) }, diff --git a/e2e/test_core/sync/test_pvc.go b/e2e/test_core/sync/test_pvc.go index 87932e99ac..a3d793c449 100644 --- a/e2e/test_core/sync/test_pvc.go +++ b/e2e/test_core/sync/test_pvc.go @@ -82,7 +82,7 @@ func PVCSyncSpec() { Eventually(func(g Gomega) { _, err := vClusterClient.CoreV1().ServiceAccounts(nsName).Get(ctx, "default", metav1.GetOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) _, err = vClusterClient.CoreV1().Pods(nsName).Create(ctx, &corev1.Pod{ @@ -117,7 +117,7 @@ func PVCSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(vpvc.Status.Phase).To(Equal(corev1.ClaimBound), "PVC phase is %s, not yet Bound", vpvc.Status.Phase) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) }) By("Verifying PVC status matches between vCluster and host", func() { @@ -151,7 +151,7 @@ func PVCSyncSpec() { _, err := vClusterClient.CoreV1().PersistentVolumeClaims(nsName).Get(ctx, pvcName, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "PVC %s/%s not yet deleted", nsName, pvcName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("Waiting for the PV to be deleted from vCluster", func() { @@ -159,7 +159,7 @@ func PVCSyncSpec() { _, err := vClusterClient.CoreV1().PersistentVolumes().Get(ctx, pvName, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "PV %s not yet deleted", pvName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("Verifying the host PVC is also deleted", func() { @@ -168,7 +168,7 @@ func PVCSyncSpec() { _, err := hostClient.CoreV1().PersistentVolumeClaims(hostNS).Get(ctx, hostPvcName, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "host PVC %s/%s not yet deleted", hostNS, hostPvcName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) }) }, diff --git a/e2e/test_core/sync/test_services.go b/e2e/test_core/sync/test_services.go index 82f0f25a45..7914d9a077 100644 --- a/e2e/test_core/sync/test_services.go +++ b/e2e/test_core/sync/test_services.go @@ -97,7 +97,7 @@ func ServiceBasicSyncSpec() { var err error pService, err = hostClient.CoreV1().Services(hostNS).Get(ctx, pServiceName, metav1.GetOptions{}) g.Expect(err).NotTo(HaveOccurred(), "host service %s/%s not yet available", hostNS, pServiceName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("refreshing the vCluster service to get the assigned ClusterIP and node ports", func() { @@ -165,14 +165,14 @@ func ServiceBasicSyncSpec() { Eventually(func(g Gomega) { _, err := vClusterClient.CoreV1().Services(nsName).Get(ctx, svcName, metav1.GetOptions{}) g.Expect(err).NotTo(HaveOccurred(), "vCluster service %s/%s not yet available", nsName, svcName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("waiting for the service to be synced to the host cluster", func() { Eventually(func(g Gomega) { _, err := hostClient.CoreV1().Services(hostNS).Get(ctx, pServiceName, metav1.GetOptions{}) g.Expect(err).NotTo(HaveOccurred(), "host service %s/%s not yet available", hostNS, pServiceName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) }) @@ -222,7 +222,7 @@ func ServiceBasicSyncSpec() { Eventually(func(g Gomega) { _, err := vClusterClient.CoreV1().Services(nsName).Get(ctx, svcName, metav1.GetOptions{}) g.Expect(err).NotTo(HaveOccurred(), "service %s/%s not yet available", nsName, svcName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("getting /status via dynamic client", func() { @@ -252,7 +252,7 @@ func ServiceBasicSyncSpec() { g.Expect(err).NotTo(HaveOccurred(), "service %s/%s not yet available", nsName, svcName) g.Expect(svc.Annotations).To(HaveKeyWithValue("patchedstatus", "true"), "expected patchedstatus annotation on service %s/%s, got annotations: %v", nsName, svcName, svc.Annotations) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) // The vCluster syncer overwrites virtual service status with host status on @@ -311,7 +311,7 @@ func ServiceBasicSyncSpec() { g.Expect(err).NotTo(HaveOccurred(), "service %s/%s not yet available", nsName, svcName) g.Expect(svc.Labels).To(HaveKeyWithValue("test-service-"+suffix, "patched"), "expected patched label on service %s/%s, got labels: %v", nsName, svcName, svc.Labels) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("deleting the service", func() { @@ -324,7 +324,7 @@ func ServiceBasicSyncSpec() { _, err := vClusterClient.CoreV1().Services(nsName).Get(ctx, svcName, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "service %s/%s should be deleted, got err: %v", nsName, svcName, err) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) }) @@ -371,7 +371,7 @@ func ServiceBasicSyncSpec() { Eventually(func(g Gomega) { _, err := hostClient.CoreV1().Services(hostNS).Get(ctx, pServiceName, metav1.GetOptions{}) g.Expect(err).NotTo(HaveOccurred(), "host service %s/%s not yet available", hostNS, pServiceName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("updating the host service to add a new annotation and label", func() { @@ -392,7 +392,7 @@ func ServiceBasicSyncSpec() { _, err = hostClient.CoreV1().Services(hostNS).Update(ctx, pService, metav1.UpdateOptions{}) // Any error (including conflict) causes Eventually to retry. g.Expect(err).NotTo(HaveOccurred(), "failed to update host service %s/%s", hostNS, pServiceName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("waiting for the host-cluster annotation and label to be synced back into the vCluster service", func() { @@ -406,7 +406,7 @@ func ServiceBasicSyncSpec() { "original vCluster annotation should be preserved, annotations: %v", updatedVService.Annotations) g.Expect(updatedVService.Labels).To(HaveKeyWithValue("host-cluster-label", "some_host_label_value"), "expected vService to have host-cluster-label synced back, labels: %v", updatedVService.Labels) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("updating the vCluster service to add a new annotation and label", func() { @@ -427,7 +427,7 @@ func ServiceBasicSyncSpec() { _, err = vClusterClient.CoreV1().Services(nsName).Update(ctx, updatedVService, metav1.UpdateOptions{}) // Any error (including conflict) causes Eventually to retry. g.Expect(err).NotTo(HaveOccurred(), "failed to update vCluster service %s/%s", nsName, svcName) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("waiting for the vCluster annotation and label to be synced to the host service", func() { @@ -441,7 +441,7 @@ func ServiceBasicSyncSpec() { "host-annotation should be preserved after vCluster update, annotations: %v", updatedPService.Annotations) g.Expect(updatedPService.Labels).To(HaveKeyWithValue("vcluster-label", "some_vcluster_value"), "expected pService to have vcluster-label synced, labels: %v", updatedPService.Labels) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) }) }) diff --git a/e2e/test_core/sync/test_servicesync.go b/e2e/test_core/sync/test_servicesync.go index d70ddd9230..c5290f4a44 100644 --- a/e2e/test_core/sync/test_servicesync.go +++ b/e2e/test_core/sync/test_servicesync.go @@ -67,7 +67,7 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := hostClient.CoreV1().Namespaces().Get(ctx, fromNS, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) var fromService *corev1.Service @@ -91,7 +91,7 @@ func ServiceSyncSpec() { var err error toService, err = vClusterClient.CoreV1().Services(toNS).Get(ctx, toName, metav1.GetOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("asserting the replicated service has correct ports", func() { @@ -110,7 +110,7 @@ func ServiceSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(toEndpoints.Subsets).To(HaveLen(1)) g.Expect(toEndpoints.Subsets[0].Addresses).To(HaveLen(1)) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("asserting endpoint IP equals source service ClusterIP", func() { @@ -125,7 +125,7 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := vClusterClient.CoreV1().Services(toNS).Get(ctx, toName, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "replicated service should be deleted after source is gone") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("waiting for the replicated endpoint to be removed from vcluster", func() { @@ -133,7 +133,7 @@ func ServiceSyncSpec() { //nolint:staticcheck _, err := vClusterClient.CoreV1().Endpoints(toNS).Get(ctx, toName, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "replicated endpoint should be deleted after source is gone") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) }) @@ -161,7 +161,7 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := vClusterClient.CoreV1().Namespaces().Get(ctx, fromNS, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("creating source service in vcluster", func() { @@ -183,7 +183,7 @@ func ServiceSyncSpec() { var err error toService, err = hostClient.CoreV1().Services(vClusterNamespace).Get(ctx, toName, metav1.GetOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("asserting the mapped service has correct ports", func() { @@ -209,7 +209,7 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := hostClient.CoreV1().Services(vClusterNamespace).Get(ctx, toName, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "mapped service should be deleted after source is gone") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) }) @@ -240,7 +240,7 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := hostClient.CoreV1().Namespaces().Get(ctx, fromNS, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("creating an nginx deployment on host", func() { @@ -275,7 +275,7 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := vClusterClient.CoreV1().Services(toNS).Get(ctx, toName, metav1.GetOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) //nolint:staticcheck @@ -288,7 +288,7 @@ func ServiceSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(fromEPs.Subsets).To(HaveLen(1)) g.Expect(fromEPs.Subsets[0].Addresses).To(HaveLen(int(two))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) Eventually(func(g Gomega) { var err error @@ -297,7 +297,7 @@ func ServiceSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(toEPs.Subsets).To(HaveLen(1)) g.Expect(toEPs.Subsets[0].Addresses).To(HaveLen(int(two))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) }) By("asserting endpoint IPs match between host and vcluster (order-independent)", func() { @@ -311,7 +311,7 @@ func ServiceSyncSpec() { dep.Spec.Replicas = &zero _, err = hostClient.AppsV1().Deployments(fromNS).Update(ctx, dep, metav1.UpdateOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) By("waiting for host endpoints to be cleared", func() { @@ -320,7 +320,7 @@ func ServiceSyncSpec() { ep, err := hostClient.CoreV1().Endpoints(fromNS).Get(ctx, fromName, metav1.GetOptions{}) g.Expect(err).To(Succeed()) g.Expect(ep.Subsets).To(BeNil()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("waiting for vcluster endpoints to also be cleared", func() { @@ -329,7 +329,7 @@ func ServiceSyncSpec() { ep, err := vClusterClient.CoreV1().Endpoints(toNS).Get(ctx, toName, metav1.GetOptions{}) g.Expect(err).To(Succeed()) g.Expect(ep.Subsets).To(BeNil()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By(fmt.Sprintf("scaling the deployment back to %d", two), func() { @@ -339,7 +339,7 @@ func ServiceSyncSpec() { dep.Spec.Replicas = &two _, err = hostClient.AppsV1().Deployments(fromNS).Update(ctx, dep, metav1.UpdateOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) By(fmt.Sprintf("waiting for %d endpoints to be repopulated on both sides", two), func() { @@ -350,7 +350,7 @@ func ServiceSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(fromEPs.Subsets).To(HaveLen(1)) g.Expect(fromEPs.Subsets[0].Addresses).To(HaveLen(int(two))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) Eventually(func(g Gomega) { var err error @@ -359,7 +359,7 @@ func ServiceSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(toEPs.Subsets).To(HaveLen(1)) g.Expect(toEPs.Subsets[0].Addresses).To(HaveLen(int(two))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) }) By("asserting endpoint IPs still match after scale-up", func() { @@ -393,7 +393,7 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := vClusterClient.CoreV1().Namespaces().Get(ctx, fromNS, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("creating an nginx deployment in vcluster", func() { @@ -428,7 +428,7 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := hostClient.CoreV1().Services(vClusterNamespace).Get(ctx, toName, metav1.GetOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) //nolint:staticcheck @@ -441,7 +441,7 @@ func ServiceSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(fromEPs.Subsets).To(HaveLen(1)) g.Expect(fromEPs.Subsets[0].Addresses).To(HaveLen(int(two))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) Eventually(func(g Gomega) { var err error @@ -450,7 +450,7 @@ func ServiceSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(toEPs.Subsets).To(HaveLen(1)) g.Expect(toEPs.Subsets[0].Addresses).To(HaveLen(int(two))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) }) By("asserting endpoint IPs match between vcluster and host (order-independent)", func() { @@ -464,7 +464,7 @@ func ServiceSyncSpec() { dep.Spec.Replicas = &zero _, err = vClusterClient.AppsV1().Deployments(fromNS).Update(ctx, dep, metav1.UpdateOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) By("waiting for vcluster endpoints to be cleared", func() { @@ -473,7 +473,7 @@ func ServiceSyncSpec() { ep, err := vClusterClient.CoreV1().Endpoints(fromNS).Get(ctx, fromName, metav1.GetOptions{}) g.Expect(err).To(Succeed()) g.Expect(ep.Subsets).To(BeNil()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("waiting for host endpoints to also be cleared", func() { @@ -482,7 +482,7 @@ func ServiceSyncSpec() { ep, err := hostClient.CoreV1().Endpoints(vClusterNamespace).Get(ctx, toName, metav1.GetOptions{}) g.Expect(err).To(Succeed()) g.Expect(ep.Subsets).To(BeNil()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By(fmt.Sprintf("scaling the deployment back to %d", two), func() { @@ -492,7 +492,7 @@ func ServiceSyncSpec() { dep.Spec.Replicas = &two _, err = vClusterClient.AppsV1().Deployments(fromNS).Update(ctx, dep, metav1.UpdateOptions{}) g.Expect(err).To(Succeed()) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed()) }) By(fmt.Sprintf("waiting for %d endpoints to be repopulated on both sides", two), func() { @@ -503,7 +503,7 @@ func ServiceSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(fromEPs.Subsets).To(HaveLen(1)) g.Expect(fromEPs.Subsets[0].Addresses).To(HaveLen(int(two))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) Eventually(func(g Gomega) { var err error @@ -512,7 +512,7 @@ func ServiceSyncSpec() { g.Expect(err).To(Succeed()) g.Expect(toEPs.Subsets).To(HaveLen(1)) g.Expect(toEPs.Subsets[0].Addresses).To(HaveLen(int(two))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutVeryLong).Should(Succeed()) }) By("asserting endpoint IPs still match after scale-up", func() { @@ -575,7 +575,7 @@ func ServiceSyncSpec() { g.Expect(hostSvc.Spec.Ports).To(HaveLen(1)) g.Expect(hostSvc.Spec.Ports[0].Name).To(Equal("custom-port")) g.Expect(hostSvc.Spec.Ports[0].Port).To(Equal(int32(8080))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("waiting for Endpoint to appear on host with correct address and port", func() { @@ -588,7 +588,7 @@ func ServiceSyncSpec() { g.Expect(hostEP.Subsets[0].Addresses[0].IP).To(Equal("1.1.1.1")) g.Expect(hostEP.Subsets[0].Ports).To(HaveLen(1)) g.Expect(hostEP.Subsets[0].Ports[0].Port).To(Equal(int32(5000))) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("waiting for EndpointSlice to appear on host with correct content", func() { @@ -600,7 +600,7 @@ func ServiceSyncSpec() { g.Expect(slices.Items).To(HaveLen(1)) g.Expect(slices.Items[0].Endpoints).NotTo(BeEmpty()) g.Expect(slices.Items[0].Endpoints[0].Addresses).To(ConsistOf("1.1.1.1")) - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) By("deleting Service in vcluster and verifying host resources are cleaned up", func() { @@ -609,13 +609,13 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := hostClient.CoreV1().Services(vClusterNamespace).Get(ctx, translatedName, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "host service should be deleted") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) Eventually(func(g Gomega) { //nolint:staticcheck _, err := hostClient.CoreV1().Endpoints(vClusterNamespace).Get(ctx, translatedName, metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "host endpoint should be deleted") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) Eventually(func(g Gomega) { slices, err := hostClient.DiscoveryV1().EndpointSlices(vClusterNamespace).List(ctx, metav1.ListOptions{ @@ -623,7 +623,7 @@ func ServiceSyncSpec() { }) g.Expect(err).To(Succeed()) g.Expect(slices.Items).To(BeEmpty(), "host EndpointSlice should be deleted") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) }) @@ -640,7 +640,7 @@ func ServiceSyncSpec() { Eventually(func(g Gomega) { _, err := vClusterClient.CoreV1().Services("default").Get(ctx, "test-replicated-service-cleanup", metav1.GetOptions{}) g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "stale replicated service should have been cleaned up") - }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) + }).WithContext(ctx).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed()) }) }) }) diff --git a/pkg/controllers/resources/pods/syncer.go b/pkg/controllers/resources/pods/syncer.go index b933d847b5..81b51249ce 100644 --- a/pkg/controllers/resources/pods/syncer.go +++ b/pkg/controllers/resources/pods/syncer.go @@ -93,15 +93,14 @@ func New(ctx *synccontext.RegisterContext) (syncertypes.Object, error) { return nil, fmt.Errorf("failed to create scheduling config: %w", err) } - hostClusterVersionInfo, err := ctx.Config.HostClient.Discovery().ServerVersion() - if err != nil { - return nil, fmt.Errorf("failed to get virtual cluster version : %w", err) - } - - hostClusterVersion, err := utilversion.ParseSemantic(hostClusterVersionInfo.String()) - if err != nil { - // This should never happen - return nil, fmt.Errorf("failed to parse host cluster version : %w", err) + // The host cluster version is discovered once at startup and carried on the context. + // It is nil in unit tests, which callers treat as "version unknown". + var hostClusterVersion *utilversion.Version + if ctx.HostClusterVersion != nil { + hostClusterVersion, err = utilversion.ParseSemantic(ctx.HostClusterVersion.String()) + if err != nil { + return nil, fmt.Errorf("failed to parse host cluster version: %w", err) + } } return &podSyncer{ @@ -306,18 +305,20 @@ func (s *podSyncer) Sync(ctx *synccontext.SyncContext, event *synccontext.SyncEv } } - // propagate pod status changes from host cluster to vcluster when the host pod - // is being deleted. We need this because there is a possibility that pod is owned - // by a controller which wants the pod status to be either succeeded or failed before - // deleting it. But because these status changes are not propagated - // to vcluster pod when the host pod is being deleted, vcluster pod's status still - // shows as running, hence it cannot be deleted until it has failed or succeeded. This - // results in dangling pods on vcluster - if !equality.Semantic.DeepEqual(event.Virtual.Status, event.Host.Status) { + // Keep propagating the host status to the virtual pod while the host pod is deleting. + // A controller may wait for the pod to reach Succeeded or Failed before removing it, so + // without this the virtual pod stays Running and can never be deleted (dangling pods). + // + // Compare against the status we actually want the virtual pod to have, not the raw host + // status: QOSClass is immutable and ObservedGeneration is dropped by virtual apiservers + // on K8s < 1.34, so those fields legitimately differ and must not trigger an update. + desiredStatus := *event.Host.Status.DeepCopy() + desiredStatus.QOSClass = event.Virtual.Status.QOSClass + s.podTranslator.StripUnpersistedObservedGeneration(&desiredStatus) + + if !equality.Semantic.DeepEqual(event.Virtual.Status, desiredStatus) { updated := event.Virtual.DeepCopy() - updated.Status = *event.Host.Status.DeepCopy() - // QOSClass is immutable in newer Kubernetes versions; preserve the existing value. - updated.Status.QOSClass = event.Virtual.Status.QOSClass + updated.Status = desiredStatus ctx.Log.Infof("update virtual pod %s, because status has changed", event.Virtual.Name) err := ctx.VirtualClient.Status().Update(ctx, updated) if err != nil && !kerrors.IsNotFound(err) { @@ -370,16 +371,6 @@ func (s *podSyncer) Sync(ctx *synccontext.SyncContext, event *synccontext.SyncEv return ctrl.Result{}, err } - // ignore the QOSClass field while updating pod status when there is a - // mismatch in this field value on vcluster and host. This field - // has become immutable from k8s 1.32 version and patch fails if - // syncer tries to update this field. - // This needs to be done before patch object is created when - // NewSyncerPatcher() is called so that there are no - // differences found in host QOSClass and virtual QOSClass and - // a patch event for this field is not created - event.Host.Status.QOSClass = event.Virtual.Status.QOSClass - // patch objects patch, err := patcher.NewSyncerPatcher(ctx, event.Host, event.Virtual, patcher.TranslatePatches(ctx.Config.Sync.ToHost.Pods.Patches, false)) if err != nil { @@ -431,7 +422,9 @@ func (s *podSyncer) Sync(ctx *synccontext.SyncContext, event *synccontext.SyncEv } func (s *podSyncer) resizeHostPodContainerResourcesInPlace(ctx *synccontext.SyncContext, event *synccontext.SyncEvent[*corev1.Pod]) error { - if s.hostClusterVersion.LessThan(utilversion.MustParseSemantic("1.35.0")) { + // in-place pod resize is only available on the host from K8s 1.35; skip it when the host + // version is older or not known. + if s.hostClusterVersion == nil || s.hostClusterVersion.LessThan(utilversion.MustParseSemantic("1.35.0")) { return nil } diff --git a/pkg/controllers/resources/pods/translate/diff.go b/pkg/controllers/resources/pods/translate/diff.go index b22434e111..56c0ccc596 100644 --- a/pkg/controllers/resources/pods/translate/diff.go +++ b/pkg/controllers/resources/pods/translate/diff.go @@ -3,6 +3,7 @@ package translate import ( "encoding/json" "fmt" + "slices" "strings" "github.com/loft-sh/vcluster/pkg/mappings" @@ -20,8 +21,9 @@ import ( ) func (t *translator) Diff(ctx *synccontext.SyncContext, event *synccontext.SyncEvent[*corev1.Pod]) error { - // sync conditions - event.Virtual.Status.Conditions, event.Host.Status.Conditions = patcher.CopyBidirectional( + // Sync conditions. We only keep the reconciled host side; the virtual conditions are + // recomputed below when vPod.Status is overwritten with the host status. + _, event.Host.Status.Conditions = t.conditionsCopyBidirectional( event.VirtualOld.Status.Conditions, event.Virtual.Status.Conditions, event.HostOld.Status.Conditions, @@ -32,11 +34,19 @@ func (t *translator) Diff(ctx *synccontext.SyncContext, event *synccontext.SyncE vPod := event.Virtual pPod := event.Host - // We have to reset the QOSClass to the original value, otherwise we might get an error - // that the field is immutable + // Copy the host status onto the virtual pod, but keep a few virtual values that the host + // must not overwrite: + // - QOSClass: the host treats it as immutable (since K8s 1.32), so it can't be changed. + // - ObservedGeneration: a virtual cluster on K8s < 1.34 doesn't store it, so copying the + // host value would keep looking like a change and patch the status on every reconcile. originalQOSClass := vPod.Status.QOSClass + originalObservedGeneration := vPod.Status.ObservedGeneration vPod.Status = *pPod.Status.DeepCopy() vPod.Status.QOSClass = originalQOSClass + if t.virtualClusterStripsObservedGeneration() { + vPod.Status.ObservedGeneration = originalObservedGeneration + vPod.Status.Conditions = stripConditionObservedGenerations(vPod.Status.Conditions) + } err := t.convertResourceClaimStatuses(ctx, vPod, pPod.GetNamespace()) if err != nil { return err @@ -281,3 +291,51 @@ func hasToleration(tolerations []corev1.Toleration, tol corev1.Toleration) bool } return false } + +// virtualClusterStripsObservedGeneration reports whether the virtual apiserver throws away +// ObservedGeneration when a pod status is written. This happens on K8s < 1.34, where the +// PodObservedGenerationTracking feature gate is off by default. +func (t *translator) virtualClusterStripsObservedGeneration() bool { + return t.virtualClusterVersion != nil && + t.virtualClusterVersion.LessThan(k8sPodObservedGenerationMinVersion) +} + +// conditionsCopyBidirectional works like patcher.CopyBidirectional for pod conditions. On +// K8s < 1.34 the virtual cluster does not store ObservedGeneration, so this function ignores that field +// when checking for changes to avoid reacting to a value that was never saved. It does not +// change the conditions, so a real ObservedGeneration from the host is still synced. +func (t *translator) conditionsCopyBidirectional( + virtualOld, virtual, hostOld, host []corev1.PodCondition, +) (newVirtual, newHost []corev1.PodCondition) { + if !t.virtualClusterStripsObservedGeneration() { + return patcher.CopyBidirectional(virtualOld, virtual, hostOld, host) + } + + return patcher.CopyBidirectionalWithEq(virtualOld, virtual, hostOld, host, func(a, b []corev1.PodCondition) bool { + return apiequality.Semantic.DeepEqual( + stripConditionObservedGenerations(a), + stripConditionObservedGenerations(b), + ) + }) +} + +// StripUnpersistedObservedGeneration zeroes ObservedGeneration on the pod status and its +// conditions when the virtual cluster drops that field on write (K8s < 1.34), so callers can +// compare against what the virtual apiserver will actually store. +func (t *translator) StripUnpersistedObservedGeneration(status *corev1.PodStatus) { + if !t.virtualClusterStripsObservedGeneration() { + return + } + status.ObservedGeneration = 0 + status.Conditions = stripConditionObservedGenerations(status.Conditions) +} + +// stripConditionObservedGenerations returns a copy of the conditions with ObservedGeneration +// set to zero on each one. It is only used to compare conditions while ignoring that field. +func stripConditionObservedGenerations(conditions []corev1.PodCondition) []corev1.PodCondition { + out := slices.Clone(conditions) + for i := range out { + out[i].ObservedGeneration = 0 + } + return out +} diff --git a/pkg/controllers/resources/pods/translate/diff_test.go b/pkg/controllers/resources/pods/translate/diff_test.go index 2d9bf378df..ede481d7f4 100644 --- a/pkg/controllers/resources/pods/translate/diff_test.go +++ b/pkg/controllers/resources/pods/translate/diff_test.go @@ -12,6 +12,7 @@ import ( "gotest.tools/v3/assert/cmp" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilversion "k8s.io/apimachinery/pkg/util/version" ) // TestDiffTolerationSync verifies toleration reconciliation in calcSpecDiff, which runs @@ -145,3 +146,229 @@ func TestDiffTolerationSync(t *testing.T) { }) } } + +func TestConditionsCopyBidirectionalObservedGeneration(t *testing.T) { + v131 := utilversion.MustParseSemantic("1.31.0") + v133 := utilversion.MustParseSemantic("1.33.0") + v134 := utilversion.MustParseSemantic("1.34.0") + + ready := corev1.PodCondition{Type: corev1.PodReady, Status: corev1.ConditionTrue} + readyGen1 := corev1.PodCondition{Type: corev1.PodReady, Status: corev1.ConditionTrue, ObservedGeneration: 1} + notReady := corev1.PodCondition{Type: corev1.PodReady, Status: corev1.ConditionFalse} + + tests := []struct { + name string + version *utilversion.Version + virtualOld []corev1.PodCondition + virtual []corev1.PodCondition + hostOld []corev1.PodCondition + host []corev1.PodCondition + wantNewVirtual []corev1.PodCondition + wantNewHost []corev1.PodCondition + }{ + { + // Object cache stored {ObservedGeneration:1} (what was sent); informer returned + // {ObservedGeneration:0} (what the v1.31 apiserver stored). The delta must not be + // treated as a virtual change. + name: "v1.31: cache/informer divergence (only observedGeneration), no false trigger", + version: v131, + virtualOld: []corev1.PodCondition{readyGen1}, + virtual: []corev1.PodCondition{ready}, + hostOld: []corev1.PodCondition{readyGen1}, + host: []corev1.PodCondition{readyGen1}, + wantNewVirtual: []corev1.PodCondition{ready}, + wantNewHost: []corev1.PodCondition{readyGen1}, + }, + { + // A real condition status change must still propagate to the host even on v1.31. + name: "v1.31: actual condition status change, propagated to host", + version: v131, + virtualOld: []corev1.PodCondition{ready}, + virtual: []corev1.PodCondition{notReady}, + hostOld: []corev1.PodCondition{ready}, + host: []corev1.PodCondition{ready}, + wantNewVirtual: []corev1.PodCondition{notReady}, + wantNewHost: []corev1.PodCondition{notReady}, + }, + { + // Host kubelet sets ObservedGeneration=1 on the host condition. After stripping, + // hostOld and host are identical, so propagation to virtual is skipped. + name: "v1.31: host gets observedGeneration from kubelet, not propagated to virtual", + version: v131, + virtualOld: []corev1.PodCondition{ready}, + virtual: []corev1.PodCondition{ready}, + hostOld: []corev1.PodCondition{ready}, + host: []corev1.PodCondition{readyGen1}, + wantNewVirtual: []corev1.PodCondition{ready}, + wantNewHost: []corev1.PodCondition{readyGen1}, + }, + { + // v1.33 (one of the versions reported in issue 3578): the gate is alpha and off, so + // the apiserver still strips the field. The divergence must not be treated as a change. + name: "v1.33: gate alpha/off, divergence still suppressed", + version: v133, + virtualOld: []corev1.PodCondition{readyGen1}, + virtual: []corev1.PodCondition{ready}, + hostOld: []corev1.PodCondition{readyGen1}, + host: []corev1.PodCondition{readyGen1}, + wantNewVirtual: []corev1.PodCondition{ready}, + wantNewHost: []corev1.PodCondition{readyGen1}, + }, + { + // From v1.34 the gate is beta/on, so the apiserver preserves the field. A change to + // ObservedGeneration is then a genuine mutation and must be treated as a real change. + name: "v1.34: gate beta/on, observedGeneration change treated as real change", + version: v134, + virtualOld: []corev1.PodCondition{readyGen1}, + virtual: []corev1.PodCondition{ready}, + hostOld: []corev1.PodCondition{readyGen1}, + host: []corev1.PodCondition{readyGen1}, + wantNewVirtual: []corev1.PodCondition{ready}, + wantNewHost: []corev1.PodCondition{ready}, + }, + { + // Version not yet discovered (startup): the strip path is not taken, so with the same + // inputs as the v1.33 case the observedGeneration delta is treated as a real change + // (wantNewHost is ready, not readyGen1). + name: "nil version: defaults to preserve, not strip-path", + version: nil, + virtualOld: []corev1.PodCondition{readyGen1}, + virtual: []corev1.PodCondition{ready}, + hostOld: []corev1.PodCondition{readyGen1}, + host: []corev1.PodCondition{readyGen1}, + wantNewVirtual: []corev1.PodCondition{ready}, + wantNewHost: []corev1.PodCondition{ready}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tr := &translator{virtualClusterVersion: tc.version} + newVirtual, newHost := tr.conditionsCopyBidirectional(tc.virtualOld, tc.virtual, tc.hostOld, tc.host) + assert.Assert(t, cmp.DeepEqual(newVirtual, tc.wantNewVirtual), "virtual conditions mismatch") + assert.Assert(t, cmp.DeepEqual(newHost, tc.wantNewHost), "host conditions mismatch") + }) + } +} + +func TestDiffPodStatusObservedGeneration(t *testing.T) { + pClient := testingutil.NewFakeClient(scheme.Scheme) + vClient := testingutil.NewFakeClient(scheme.Scheme) + + imageTranslator, err := NewImageTranslator(map[string]string{}) + assert.NilError(t, err) + + registerCtx := generictesting.NewFakeRegisterContext(testingutil.NewFakeConfig(), pClient, vClient) + syncCtx := registerCtx.ToSyncContext("test") + + assert.NilError(t, vClient.Create(syncCtx.Context, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "testns"}, + })) + + tests := []struct { + name string + version *utilversion.Version + hostObservedGeneration int64 + wantVirtualObservedGen int64 + }{ + { + name: "v1.33: host ObservedGeneration NOT copied to virtual (apiserver strips it, reported version)", + version: utilversion.MustParseSemantic("1.33.0"), + hostObservedGeneration: 3, + wantVirtualObservedGen: 0, + }, + { + name: "v1.34: host ObservedGeneration copied to virtual (apiserver preserves it)", + version: utilversion.MustParseSemantic("1.34.0"), + hostObservedGeneration: 3, + wantVirtualObservedGen: 3, + }, + { + // Version not yet discovered (startup): the guard returns false, so the host value + // is copied (same as >= 1.34). + name: "nil version: host ObservedGeneration copied (unknown version defaults to preserve)", + version: nil, + hostObservedGeneration: 3, + wantVirtualObservedGen: 3, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tr := &translator{ + vClient: vClient, + imageTranslator: imageTranslator, + log: loghelper.New("diff-test"), + virtualClusterVersion: tc.version, + } + + vOld := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "testpod", Namespace: "testns"}} + vNew := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "testpod", Namespace: "testns"}} + vNew.Status.ObservedGeneration = 0 + + // The host kubelet sets ObservedGeneration on both the pod status and the condition. + pOld := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "testpod-x-testns", Namespace: "test"}} + pNew := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "testpod-x-testns", Namespace: "test"}} + pNew.Status.ObservedGeneration = tc.hostObservedGeneration + pNew.Status.Conditions = []corev1.PodCondition{{ + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + ObservedGeneration: tc.hostObservedGeneration, + }} + + event := synccontext.NewSyncEventWithOld(pOld, pNew, vOld, vNew) + assert.NilError(t, tr.Diff(syncCtx, event)) + + assert.Equal(t, tc.wantVirtualObservedGen, event.Virtual.Status.ObservedGeneration, + "test case %q: unexpected virtual pod ObservedGeneration after Diff", tc.name) + + assert.Assert(t, len(event.Virtual.Status.Conditions) == 1, + "test case %q: expected one virtual condition", tc.name) + assert.Equal(t, tc.wantVirtualObservedGen, event.Virtual.Status.Conditions[0].ObservedGeneration, + "test case %q: unexpected virtual condition ObservedGeneration after Diff", tc.name) + }) + } +} + +func TestDiffQOSClassPreservation(t *testing.T) { + pClient := testingutil.NewFakeClient(scheme.Scheme) + vClient := testingutil.NewFakeClient(scheme.Scheme) + + imageTranslator, err := NewImageTranslator(map[string]string{}) + assert.NilError(t, err) + + tr := &translator{ + vClient: vClient, + imageTranslator: imageTranslator, + log: loghelper.New("diff-test"), + } + + registerCtx := generictesting.NewFakeRegisterContext(testingutil.NewFakeConfig(), pClient, vClient) + syncCtx := registerCtx.ToSyncContext("test") + + assert.NilError(t, vClient.Create(syncCtx.Context, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "testns"}, + })) + + vOld := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "testpod", Namespace: "testns"}} + vNew := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "testpod", Namespace: "testns"}, + Status: corev1.PodStatus{QOSClass: corev1.PodQOSBestEffort}, + } + pOld := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "testpod-x-testns", Namespace: "test"}, + Status: corev1.PodStatus{QOSClass: corev1.PodQOSBurstable}, + } + pNew := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "testpod-x-testns", Namespace: "test"}, + Status: corev1.PodStatus{QOSClass: corev1.PodQOSBurstable}, + } + + event := synccontext.NewSyncEventWithOld(pOld, pNew, vOld, vNew) + assert.NilError(t, tr.Diff(syncCtx, event)) + + assert.Equal(t, corev1.PodQOSBestEffort, event.Virtual.Status.QOSClass, + "virtual pod QOS class must be preserved after Diff (not overwritten from host)") + assert.Equal(t, corev1.PodQOSBurstable, event.Host.Status.QOSClass, + "host pod QOS class must not be overwritten by virtual QOS class") +} diff --git a/pkg/controllers/resources/pods/translate/translator.go b/pkg/controllers/resources/pods/translate/translator.go index 3933adc1ea..d6ee65cf38 100644 --- a/pkg/controllers/resources/pods/translate/translator.go +++ b/pkg/controllers/resources/pods/translate/translator.go @@ -26,7 +26,6 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" utilversion "k8s.io/apimachinery/pkg/util/version" - "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/events" @@ -53,12 +52,22 @@ var ( FieldPathAnnotationRegEx = regexp.MustCompile(`^metadata\.annotations\['(.+)'\]$`) False = false maxPriority = int32(1000000000) + + // k8sPodObservedGenerationMinVersion is the first Kubernetes version where the + // PodObservedGenerationTracking feature gate is on by default (beta in 1.34; alpha and + // off in 1.33, absent before that). Below it, the apiserver strips ObservedGeneration on + // write (see virtualClusterStripsObservedGeneration). + k8sPodObservedGenerationMinVersion = utilversion.MustParseSemantic("1.34.0") + // k8sPodResourcesMinVersion is the first Kubernetes version where pod spec.resources + // is supported (in beta). + k8sPodResourcesMinVersion = utilversion.MustParseSemantic("1.34.0") ) type Translator interface { Translate(ctx *synccontext.SyncContext, vPod *corev1.Pod, services []*corev1.Service, dnsIP string, kubeIP string) (*corev1.Pod, error) Diff(ctx *synccontext.SyncContext, event *synccontext.SyncEvent[*corev1.Pod]) error TranslateContainerEnv(ctx *synccontext.SyncContext, envVar []corev1.EnvVar, envFrom []corev1.EnvFromSource, vPod *corev1.Pod, serviceEnvMap map[string]string) ([]corev1.EnvVar, []corev1.EnvFromSource, error) + StripUnpersistedObservedGeneration(status *corev1.PodStatus) } func NewTranslator(ctx *synccontext.RegisterContext, eventRecorder events.EventRecorder) (Translator, error) { @@ -101,12 +110,25 @@ func NewTranslator(ctx *synccontext.RegisterContext, eventRecorder events.EventR "docker.io", ) - hostClusterVersion, err := ctx.Config.HostClient.Discovery().ServerVersion() - if err != nil { - return nil, fmt.Errorf("failed to get virtual cluster version : %w", err) + // The host and virtual cluster versions are discovered once at startup and carried on the + // context. They are nil in unit tests, which callers treat as "version unknown". + var hostClusterVersion *utilversion.Version + if ctx.HostClusterVersion != nil { + hostClusterVersion, err = utilversion.ParseSemantic(ctx.HostClusterVersion.String()) + if err != nil { + return nil, fmt.Errorf("failed to parse host cluster version: %w", err) + } + } + + var virtualClusterVersion *utilversion.Version + if ctx.VirtualClusterVersion != nil { + virtualClusterVersion, err = utilversion.ParseSemantic(ctx.VirtualClusterVersion.String()) + if err != nil { + return nil, fmt.Errorf("failed to parse virtual cluster version: %w", err) + } } - return &translator{ + t := &translator{ vClientConfig: ctx.VirtualManager.GetConfig(), vClient: ctx.VirtualManager.GetClient(), @@ -134,13 +156,22 @@ func NewTranslator(ctx *synccontext.RegisterContext, eventRecorder events.EventR virtualPodLogsPath: filepath.Join(virtualLogsPath, "pods"), virtualKubeletPodPath: filepath.Join(virtualKubeletPath, "pods"), - hostClusterVersion: hostClusterVersion, + hostClusterVersion: hostClusterVersion, + virtualClusterVersion: virtualClusterVersion, resourceClaimEnabled: ctx.Config.Sync.ToHost.ResourceClaims.Enabled, resourceClaimTemplateEnabled: ctx.Config.Sync.ToHost.ResourceClaimTemplates.Enabled, enforcedTolerations: parseEnforcedTolerations(ctx.Config.Sync.ToHost.Pods.EnforceTolerations), - }, nil + } + + // Log when ObservedGeneration suppression is active, to aid debugging pod conditions on a + // mixed-version cluster (virtual < 1.34, host >= 1.34). + if t.virtualClusterStripsObservedGeneration() { + t.log.Infof("virtual cluster version %s strips ObservedGeneration on write; suppressing phantom pod condition diffs", virtualClusterVersion) + } + + return t, nil } type translator struct { @@ -169,7 +200,8 @@ type translator struct { virtualPodLogsPath string virtualKubeletPodPath string - hostClusterVersion *version.Info + hostClusterVersion *utilversion.Version + virtualClusterVersion *utilversion.Version resourceClaimEnabled bool resourceClaimTemplateEnabled bool @@ -339,15 +371,9 @@ func (t *translator) Translate(ctx *synccontext.SyncContext, vPod *corev1.Pod, s } // translate pod resources - if t.hostClusterVersion != nil { - parsedVersion, err := utilversion.ParseSemantic(t.hostClusterVersion.String()) - if err != nil { - return nil, fmt.Errorf("failed to parse host cluster version : %w", err) - } - // spec.resources is only supported in beta from Kubernetes 1.34.0 - if parsedVersion.LessThan(utilversion.MustParseSemantic("1.34.0")) { - pPod.Spec.Resources = nil - } + // spec.resources is only supported in beta from Kubernetes 1.34.0 + if t.hostClusterVersion != nil && t.hostClusterVersion.LessThan(k8sPodResourcesMinVersion) { + pPod.Spec.Resources = nil } // translate containers diff --git a/pkg/controllers/resources/pods/translate/translator_test.go b/pkg/controllers/resources/pods/translate/translator_test.go index f19b178981..7bdfa9a2a2 100644 --- a/pkg/controllers/resources/pods/translate/translator_test.go +++ b/pkg/controllers/resources/pods/translate/translator_test.go @@ -21,7 +21,7 @@ import ( resourcev1 "k8s.io/api/resource/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/version" + utilversion "k8s.io/apimachinery/pkg/util/version" "k8s.io/client-go/tools/events" "k8s.io/utils/ptr" ) @@ -551,11 +551,7 @@ func TestPodResourcesTranslation(t *testing.T) { } if tc.hostVersion != "" { - tr.hostClusterVersion = &version.Info{ - GitVersion: tc.hostVersion, - Major: "1", - Minor: "34", - } + tr.hostClusterVersion = utilversion.MustParseSemantic(tc.hostVersion) } // We need a context diff --git a/pkg/patcher/sync.go b/pkg/patcher/sync.go index fe0d1d4ad4..3cfc44ca0c 100644 --- a/pkg/patcher/sync.go +++ b/pkg/patcher/sync.go @@ -12,11 +12,20 @@ import ( // CopyBidirectional determines whether the change is in the virtual or host object by seeing what changed between "old" and "new" for each. // It then mutates the changed object to match the unchanged object. func CopyBidirectional[T any](virtualOld, virtual, hostOld, host T) (T, T) { + return CopyBidirectionalWithEq(virtualOld, virtual, hostOld, host, func(a, b T) bool { + return apiequality.Semantic.DeepEqual(a, b) + }) +} + +// CopyBidirectionalWithEq is like CopyBidirectional but uses eq to compare the "old" and "new" +// values. Pass a custom eq to ignore fields that should not count as a change (for example a +// field the target apiserver strips on write). +func CopyBidirectionalWithEq[T any](virtualOld, virtual, hostOld, host T, eq func(a, b T) bool) (T, T) { newVirtual := virtual newHost := host - if !apiequality.Semantic.DeepEqual(virtualOld, virtual) { + if !eq(virtualOld, virtual) { newHost = virtual - } else if !apiequality.Semantic.DeepEqual(hostOld, host) { + } else if !eq(hostOld, host) { newVirtual = host } diff --git a/pkg/setup/controller_context.go b/pkg/setup/controller_context.go index 327e75c515..80f6e89400 100644 --- a/pkg/setup/controller_context.go +++ b/pkg/setup/controller_context.go @@ -378,6 +378,11 @@ func initControllerContext( nodes.FakeNodesVersion = virtualClusterVersion.GitVersion klog.FromContext(ctx).Info("Can connect to virtual cluster", "version", virtualClusterVersion.GitVersion) + hostClusterVersion, err := vClusterOptions.HostClient.Discovery().ServerVersion() + if err != nil { + return nil, errors.Wrap(err, "get host cluster version") + } + // create a new current namespace client var currentNamespaceClient client.Client if !vClusterOptions.ControlPlane.Standalone.Enabled { @@ -400,6 +405,7 @@ func initControllerContext( VirtualManager: virtualManager, VirtualRawConfig: virtualRawConfig, VirtualClusterVersion: virtualClusterVersion, + HostClusterVersion: hostClusterVersion, HostNamespaceClient: currentNamespaceClient, diff --git a/pkg/syncer/synccontext/context.go b/pkg/syncer/synccontext/context.go index 5c8346bf0e..7a27f7b00d 100644 --- a/pkg/syncer/synccontext/context.go +++ b/pkg/syncer/synccontext/context.go @@ -21,6 +21,7 @@ type ControllerContext struct { VirtualManager ctrl.Manager VirtualRawConfig *clientcmdapi.Config VirtualClusterVersion *version.Info + HostClusterVersion *version.Info EtcdClient etcd.Client @@ -52,6 +53,9 @@ type RegisterContext struct { VirtualManager ctrl.Manager HostManager ctrl.Manager + + VirtualClusterVersion *version.Info + HostClusterVersion *version.Info } type Filter func(http.Handler, *ControllerContext) http.Handler @@ -70,6 +74,9 @@ func (c *ControllerContext) ToRegisterContext() *RegisterContext { VirtualManager: c.VirtualManager, HostManager: c.HostManager, + VirtualClusterVersion: c.VirtualClusterVersion, + HostClusterVersion: c.HostClusterVersion, + Mappings: c.Mappings, } } diff --git a/pkg/syncer/testing/context.go b/pkg/syncer/testing/context.go index 447fad740a..a8fb5d51e2 100644 --- a/pkg/syncer/testing/context.go +++ b/pkg/syncer/testing/context.go @@ -14,6 +14,7 @@ import ( "github.com/loft-sh/vcluster/pkg/util" "github.com/loft-sh/vcluster/pkg/util/translate" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/rest" "github.com/loft-sh/vcluster/pkg/util/log" @@ -62,6 +63,12 @@ func FakeStartSyncer(t *testing.T, ctx *synccontext.RegisterContext, create func func NewFakeRegisterContext(vConfig *config.VirtualClusterConfig, pClient *testingutil.FakeIndexClient, vClient *testingutil.FakeIndexClient) *synccontext.RegisterContext { ctx := context.Background() + // mirror production, where the host version is fetched once and carried on the context + var hostClusterVersion *version.Info + if vConfig.HostClient != nil { + hostClusterVersion, _ = vConfig.HostClient.Discovery().ServerVersion() + } + // create register context translate.Default = translate.NewSingleNamespaceTranslator(testingutil.DefaultTestTargetNamespace) registerCtx := &synccontext.RegisterContext{ @@ -71,6 +78,7 @@ func NewFakeRegisterContext(vConfig *config.VirtualClusterConfig, pClient *testi CurrentNamespaceClient: pClient, VirtualManager: testingutil.NewFakeManager(vClient), HostManager: testingutil.NewFakeManager(pClient), + HostClusterVersion: hostClusterVersion, } // create new store