Skip to content

Commit 34c8543

Browse files
fix(ScalityUIComponent): don't stamp lastFetchedImage on validation failure
During upgrades with pre-existing deployments (e.g., metalk8s-ui adopted from salt), a rolling update race condition can occur: the operator fetches config from an old pod that lacks required fields (e.g., publicPath), validation fails, and lastFetchedImage is stamped to the new image tag. This prevents retries, permanently blocking the component from registering in deployed-ui-apps — causing missing UI tabs. By not stamping lastFetchedImage on validation failure, the controller retries on subsequent reconciles (every 30s) until the rolling update completes and a new pod with valid config responds. Fixes: ARTESCA-17164 Made-with: Cursor
1 parent dd11339 commit 34c8543

File tree

2 files changed

+84
-8
lines changed

2 files changed

+84
-8
lines changed

internal/controller/scalityuicomponent/controller.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,6 @@ func (r *ScalityUIComponentReconciler) parseAndApplyConfig(ctx context.Context,
398398
Message: fmt.Sprintf("Configuration validation failed for image %s: %v", currentImage, err),
399399
})
400400

401-
// Update LastFetchedImage to prevent repeated fetch attempts for the same invalid config
402-
scalityUIComponent.Status.LastFetchedImage = currentImage
403-
404401
if statusErr := r.Status().Update(ctx, scalityUIComponent); statusErr != nil {
405402
logger.Error(statusErr, "Failed to update ScalityUIComponent status after validation failure")
406403
}

internal/controller/scalityuicomponent/controller_test.go

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,7 @@ var _ = Describe("ScalityUIComponent Controller", func() {
784784
})
785785

786786
Context("Validation failure handling", func() {
787-
It("should not retry fetch after validation failure for same image", func() {
787+
It("should retry fetch after validation failure to handle rolling update race conditions", func() {
788788
ctx := context.Background()
789789

790790
scalityUIComponent := &uiv1alpha1.ScalityUIComponent{
@@ -838,21 +838,100 @@ var _ = Describe("ScalityUIComponent Controller", func() {
838838
Expect(err).NotTo(HaveOccurred())
839839
Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should fetch once")
840840

841-
By("Verify LastFetchedImage is set despite validation failure")
841+
By("Verify LastFetchedImage is NOT set on validation failure")
842842
Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed())
843-
Expect(scalityUIComponent.Status.LastFetchedImage).To(Equal("scality/invalid-config:v1.0.0"))
843+
Expect(scalityUIComponent.Status.LastFetchedImage).To(BeEmpty())
844844

845-
By("Third reconcile - should NOT fetch again (same image)")
845+
By("Third reconcile - should retry fetch (lastFetchedImage not stamped)")
846846
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName})
847847
Expect(err).NotTo(HaveOccurred())
848-
Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should still be 1 - no refetch")
848+
Expect(mockFetcher.ReceivedCalls).To(HaveLen(2), "Should retry fetch after validation failure")
849849

850850
By("Verify ConfigurationRetrieved condition is False")
851+
Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed())
851852
condition := meta.FindStatusCondition(scalityUIComponent.Status.Conditions, "ConfigurationRetrieved")
852853
Expect(condition).NotTo(BeNil())
853854
Expect(condition.Status).To(Equal(metav1.ConditionFalse))
854855
Expect(condition.Reason).To(Equal("ValidationFailed"))
855856
})
857+
858+
It("should eventually succeed after validation failure when config becomes valid", func() {
859+
ctx := context.Background()
860+
861+
scalityUIComponent := &uiv1alpha1.ScalityUIComponent{
862+
ObjectMeta: metav1.ObjectMeta{
863+
Name: "test-validation-recovery",
864+
Namespace: "default",
865+
},
866+
Spec: uiv1alpha1.ScalityUIComponentSpec{
867+
Image: "scality/ui-component:v2.0.0",
868+
MountPath: "/usr/share/ui-config",
869+
},
870+
}
871+
Expect(k8sClient.Create(ctx, scalityUIComponent)).To(Succeed())
872+
873+
mockFetcher := &MockConfigFetcher{
874+
ConfigContent: `{
875+
"metadata": {},
876+
"spec": {
877+
"publicPath": "/test/"
878+
}
879+
}`,
880+
}
881+
controllerReconciler := &ScalityUIComponentReconciler{
882+
Client: k8sClient,
883+
Scheme: k8sClient.Scheme(),
884+
ConfigFetcher: mockFetcher,
885+
}
886+
887+
typeNamespacedName := types.NamespacedName{
888+
Name: "test-validation-recovery",
889+
Namespace: "default",
890+
}
891+
892+
By("First reconcile - creates deployment")
893+
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName})
894+
Expect(err).NotTo(HaveOccurred())
895+
896+
By("Wait for deployment to be ready")
897+
deployment := &appsv1.Deployment{}
898+
Eventually(func() error {
899+
return k8sClient.Get(ctx, typeNamespacedName, deployment)
900+
}, time.Second*5, time.Millisecond*250).Should(Succeed())
901+
902+
deployment.Status.ReadyReplicas = 1
903+
deployment.Status.Replicas = 1
904+
deployment.Status.UpdatedReplicas = 1
905+
Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed())
906+
907+
By("Second reconcile - fetches config missing metadata.kind, fails validation")
908+
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName})
909+
Expect(err).NotTo(HaveOccurred())
910+
Expect(mockFetcher.ReceivedCalls).To(HaveLen(1))
911+
912+
By("Simulate rolling update completing - new pod now returns valid config")
913+
mockFetcher.ConfigContent = `{
914+
"metadata": {"kind": "TestKind"},
915+
"spec": {
916+
"publicPath": "/test/",
917+
"remoteEntryPath": "/remoteEntry.js",
918+
"version": "2.0.0"
919+
}
920+
}`
921+
922+
By("Third reconcile - should retry and succeed with valid config")
923+
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName})
924+
Expect(err).NotTo(HaveOccurred())
925+
Expect(mockFetcher.ReceivedCalls).To(HaveLen(2))
926+
927+
By("Verify ConfigurationRetrieved is now True")
928+
Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed())
929+
condition := meta.FindStatusCondition(scalityUIComponent.Status.Conditions, "ConfigurationRetrieved")
930+
Expect(condition).NotTo(BeNil())
931+
Expect(condition.Status).To(Equal(metav1.ConditionTrue))
932+
Expect(scalityUIComponent.Status.PublicPath).To(Equal("/test/"))
933+
Expect(scalityUIComponent.Status.LastFetchedImage).To(Equal("scality/ui-component:v2.0.0"))
934+
})
856935
})
857936

858937
Context("Rolling update handling", func() {

0 commit comments

Comments
 (0)