diff --git a/api/v1alpha1/scalityuicomponent_types.go b/api/v1alpha1/scalityuicomponent_types.go index 3431c0e..06bbf2e 100644 --- a/api/v1alpha1/scalityuicomponent_types.go +++ b/api/v1alpha1/scalityuicomponent_types.go @@ -53,6 +53,8 @@ type ScalityUIComponentStatus struct { Kind string `json:"kind,omitempty"` // Version represents the version of the UI component Version string `json:"version,omitempty"` + // LastFetchedImage tracks which image we last fetched configuration from + LastFetchedImage string `json:"lastFetchedImage,omitempty"` // Conditions represent the latest available observations of a UI component's state Conditions []metav1.Condition `json:"conditions,omitempty"` } diff --git a/config/crd/bases/ui.scality.com_scalityuicomponents.yaml b/config/crd/bases/ui.scality.com_scalityuicomponents.yaml index d38165d..2af8e07 100644 --- a/config/crd/bases/ui.scality.com_scalityuicomponents.yaml +++ b/config/crd/bases/ui.scality.com_scalityuicomponents.yaml @@ -1120,6 +1120,10 @@ spec: kind: description: Kind represents the type of UI component type: string + lastFetchedImage: + description: LastFetchedImage tracks which image we last fetched configuration + from + type: string publicPath: description: Path represents the URL path to the UI component type: string diff --git a/internal/controller/scalityuicomponent/constants.go b/internal/controller/scalityuicomponent/constants.go new file mode 100644 index 0000000..d8b4be7 --- /dev/null +++ b/internal/controller/scalityuicomponent/constants.go @@ -0,0 +1,9 @@ +package scalityuicomponent + +const ( + // DefaultServicePort is the default port used to connect to the UI component service + DefaultServicePort = 80 + + // ForceRefreshAnnotation is the annotation key to trigger a force refresh of the configuration + ForceRefreshAnnotation = "ui.scality.com/force-refresh" +) diff --git a/internal/controller/scalityuicomponent/controller.go b/internal/controller/scalityuicomponent/controller.go index 5fe3e0d..897600b 100644 --- a/internal/controller/scalityuicomponent/controller.go +++ b/internal/controller/scalityuicomponent/controller.go @@ -14,6 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -21,9 +22,6 @@ import ( uiv1alpha1 "github.com/scality/ui-operator/api/v1alpha1" ) -// DefaultServicePort is the default port used to connect to the UI component service -const DefaultServicePort = 80 - // MicroAppConfig represents the structure of the micro-app-configuration file type MicroAppConfig struct { Kind string `json:"kind"` @@ -54,6 +52,20 @@ type K8sServiceProxyFetcher struct { // Config *rest.Config // No longer needed for direct HTTP GET } +// validateMicroAppConfig validates the fetched micro-app configuration +func validateMicroAppConfig(config *MicroAppConfig) error { + if config.Metadata.Kind == "" { + return fmt.Errorf("configuration missing required field: metadata.kind") + } + if config.Spec.PublicPath == "" { + return fmt.Errorf("configuration missing required field: spec.publicPath") + } + if config.Spec.Version == "" { + return fmt.Errorf("configuration missing required field: spec.version") + } + return nil +} + // FetchConfig retrieves the micro-app configuration from the specified service func (f *K8sServiceProxyFetcher) FetchConfig(ctx context.Context, namespace, serviceName string, port int) (string, error) { logger := ctrl.LoggerFrom(ctx) @@ -138,60 +150,46 @@ func (r *ScalityUIComponentReconciler) Reconcile(ctx context.Context, req ctrl.R if err := controllerutil.SetControllerReference(scalityUIComponent, deployment, r.Scheme); err != nil { return err } - // Preserve existing volumes and annotations if they exist - existingVolumes := deployment.Spec.Template.Spec.Volumes - existingAnnotations := deployment.Spec.Template.Annotations - - // Preserve existing volume mounts for each container - var existingVolumeMounts [][]corev1.VolumeMount - if len(deployment.Spec.Template.Spec.Containers) > 0 { - existingVolumeMounts = make([][]corev1.VolumeMount, len(deployment.Spec.Template.Spec.Containers)) - for i, container := range deployment.Spec.Template.Spec.Containers { - existingVolumeMounts[i] = container.VolumeMounts - } - } - // Use imagePullSecrets directly - imagePullSecrets := append([]corev1.LocalObjectReference{}, scalityUIComponent.Spec.ImagePullSecrets...) - - deployment.Spec = appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ + // Only set Selector on creation (it's immutable) + if deployment.Spec.Selector == nil { + deployment.Spec.Selector = &metav1.LabelSelector{ MatchLabels: map[string]string{ "app": scalityUIComponent.Name, }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": scalityUIComponent.Name, - }, - Annotations: existingAnnotations, - }, - Spec: corev1.PodSpec{ - Volumes: existingVolumes, - ImagePullSecrets: imagePullSecrets, - Containers: []corev1.Container{ - { - Name: scalityUIComponent.Name, - Image: scalityUIComponent.Spec.Image, - }, - }, - }, - }, + } } - // Restore volume mounts for all containers if they existed - if len(existingVolumeMounts) > 0 { - for i := 0; i < len(existingVolumeMounts) && i < len(deployment.Spec.Template.Spec.Containers); i++ { - if len(existingVolumeMounts[i]) > 0 { - deployment.Spec.Template.Spec.Containers[i].VolumeMounts = existingVolumeMounts[i] - } + // Update pod template labels + if deployment.Spec.Template.Labels == nil { + deployment.Spec.Template.Labels = make(map[string]string) + } + deployment.Spec.Template.Labels["app"] = scalityUIComponent.Name + + // Update container image (find existing or create) + containerFound := false + for i := range deployment.Spec.Template.Spec.Containers { + if deployment.Spec.Template.Spec.Containers[i].Name == scalityUIComponent.Name { + deployment.Spec.Template.Spec.Containers[i].Image = scalityUIComponent.Spec.Image + containerFound = true + break } } + if !containerFound { + deployment.Spec.Template.Spec.Containers = append(deployment.Spec.Template.Spec.Containers, corev1.Container{ + Name: scalityUIComponent.Name, + Image: scalityUIComponent.Spec.Image, + }) + } + + // Update imagePullSecrets (empty slice clears them) + deployment.Spec.Template.Spec.ImagePullSecrets = scalityUIComponent.Spec.ImagePullSecrets - // Apply tolerations from CR spec - if scalityUIComponent.Spec.Scheduling != nil && len(scalityUIComponent.Spec.Scheduling.Tolerations) > 0 { + // Apply tolerations from CR spec (empty slice clears them) + if scalityUIComponent.Spec.Scheduling != nil { deployment.Spec.Template.Spec.Tolerations = scalityUIComponent.Spec.Scheduling.Tolerations + } else { + deployment.Spec.Template.Spec.Tolerations = nil } return nil @@ -218,18 +216,32 @@ func (r *ScalityUIComponentReconciler) Reconcile(ctx context.Context, req ctrl.R return err } - service.Spec = corev1.ServiceSpec{ - Selector: map[string]string{ - "app": scalityUIComponent.Name, - }, - Ports: []corev1.ServicePort{ - { - Name: "http", - Port: DefaultServicePort, - Protocol: corev1.ProtocolTCP, - TargetPort: intstr.FromInt(DefaultServicePort), - }, - }, + // Update selector + if service.Spec.Selector == nil { + service.Spec.Selector = make(map[string]string) + } + service.Spec.Selector["app"] = scalityUIComponent.Name + + // Update or add the http port (preserve other ports and existing port properties) + desiredPort := corev1.ServicePort{ + Name: "http", + Port: DefaultServicePort, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt(DefaultServicePort), + } + + portFound := false + for i := range service.Spec.Ports { + if service.Spec.Ports[i].Name == "http" || service.Spec.Ports[i].Port == DefaultServicePort { + // Preserve NodePort if it was assigned + desiredPort.NodePort = service.Spec.Ports[i].NodePort + service.Spec.Ports[i] = desiredPort + portFound = true + break + } + } + if !portFound { + service.Spec.Ports = append(service.Spec.Ports, desiredPort) } return nil @@ -268,11 +280,65 @@ func (r *ScalityUIComponentReconciler) processUIComponentConfig(ctx context.Cont // Check if deployment has ready replicas if deployment.Status.ReadyReplicas == 0 { logger.Info("Deployment not ready yet (0 ready replicas), will retry") - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + // Wait for rolling update to complete to avoid fetching from old pods + if deployment.Status.UpdatedReplicas < deployment.Status.Replicas { + logger.Info("Deployment still rolling out, will retry", + "readyReplicas", deployment.Status.ReadyReplicas, + "updatedReplicas", deployment.Status.UpdatedReplicas, + "targetReplicas", deployment.Status.Replicas) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + // Extract current image from deployment by finding the matching container + var currentImage string + for _, container := range deployment.Spec.Template.Spec.Containers { + if container.Name == scalityUIComponent.Name { + currentImage = container.Image + break + } + } + if currentImage == "" { + logger.Info("Cannot find UI component container in deployment") + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } - // Always fetch micro-app configuration to detect changes - // We'll compare with existing status to determine if configuration changed + // Determine if we need to fetch configuration + needsFetch := false + reasons := []string{} + + // Check if this is the first fetch + if scalityUIComponent.Status.LastFetchedImage == "" { + needsFetch = true + reasons = append(reasons, "initial configuration fetch") + } else if scalityUIComponent.Status.LastFetchedImage != currentImage { + // Image has changed, need to fetch new config + needsFetch = true + reasons = append(reasons, fmt.Sprintf("image changed from %s to %s", + scalityUIComponent.Status.LastFetchedImage, currentImage)) + } + + // Check for force-refresh annotation + if scalityUIComponent.Annotations != nil { + if val, exists := scalityUIComponent.Annotations[ForceRefreshAnnotation]; exists && val == "true" { + needsFetch = true + reasons = append(reasons, "force-refresh annotation present") + } + } + + // Skip fetch if not needed + if !needsFetch { + logger.V(1).Info("Skipping configuration fetch - image unchanged", + "currentImage", currentImage, + "lastFetchedImage", scalityUIComponent.Status.LastFetchedImage) + return ctrl.Result{}, nil + } + + logger.Info("Fetching micro-app configuration", "reasons", reasons, "image", currentImage) + + // Fetch configuration from service configContent, err := r.fetchMicroAppConfig(ctx, scalityUIComponent.Namespace, scalityUIComponent.Name) if err != nil { logger.Error(err, "Failed to fetch micro-app configuration") @@ -282,7 +348,7 @@ func (r *ScalityUIComponentReconciler) processUIComponentConfig(ctx context.Cont Type: "ConfigurationRetrieved", Status: metav1.ConditionFalse, Reason: "FetchFailed", - Message: fmt.Sprintf("Failed to fetch configuration: %v", err), + Message: fmt.Sprintf("Failed to fetch configuration from image %s: %v", currentImage, err), }) // Update the status @@ -290,61 +356,95 @@ func (r *ScalityUIComponentReconciler) processUIComponentConfig(ctx context.Cont logger.Error(statusErr, "Failed to update ScalityUIComponent status after fetch failure") } - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } // Parse and apply configuration - return r.parseAndApplyConfig(ctx, scalityUIComponent, configContent) + return r.parseAndApplyConfig(ctx, scalityUIComponent, configContent, currentImage) } func (r *ScalityUIComponentReconciler) parseAndApplyConfig(ctx context.Context, - scalityUIComponent *uiv1alpha1.ScalityUIComponent, configContent string) (ctrl.Result, error) { + scalityUIComponent *uiv1alpha1.ScalityUIComponent, configContent string, currentImage string) (ctrl.Result, error) { logger := ctrl.LoggerFrom(ctx) var config MicroAppConfig if err := json.Unmarshal([]byte(configContent), &config); err != nil { logger.Error(err, "Failed to parse micro-app configuration") - // Set ConfigurationRetrieved=False condition with ParseFailed reason meta.SetStatusCondition(&scalityUIComponent.Status.Conditions, metav1.Condition{ Type: "ConfigurationRetrieved", Status: metav1.ConditionFalse, Reason: "ParseFailed", - Message: fmt.Sprintf("Failed to parse configuration: %v", err), + Message: fmt.Sprintf("Failed to parse configuration from image %s: %v", currentImage, err), }) - // Update the status even on parse failure + // Update LastFetchedImage to prevent repeated fetch attempts for the same broken image + scalityUIComponent.Status.LastFetchedImage = currentImage + if statusErr := r.Status().Update(ctx, scalityUIComponent); statusErr != nil { logger.Error(statusErr, "Failed to update ScalityUIComponent status after parse failure") } - return ctrl.Result{Requeue: true}, nil + // Remove force-refresh annotation even on failure to prevent infinite fetch loops + r.removeForceRefreshAnnotation(ctx, scalityUIComponent) + + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } - // Check if this is the first time we're setting the configuration - oldPublicPath := scalityUIComponent.Status.PublicPath - isFirstConfiguration := oldPublicPath == "" - publicPathChanged := oldPublicPath != config.Spec.PublicPath + // Validate configuration + if err := validateMicroAppConfig(&config); err != nil { + logger.Error(err, "Invalid micro-app configuration") + + meta.SetStatusCondition(&scalityUIComponent.Status.Conditions, metav1.Condition{ + Type: "ConfigurationRetrieved", + Status: metav1.ConditionFalse, + Reason: "ValidationFailed", + Message: fmt.Sprintf("Configuration validation failed for image %s: %v", currentImage, err), + }) + + // Update LastFetchedImage to prevent repeated fetch attempts for the same invalid config + scalityUIComponent.Status.LastFetchedImage = currentImage + + if statusErr := r.Status().Update(ctx, scalityUIComponent); statusErr != nil { + logger.Error(statusErr, "Failed to update ScalityUIComponent status after validation failure") + } + + // Remove force-refresh annotation even on failure to prevent infinite fetch loops + r.removeForceRefreshAnnotation(ctx, scalityUIComponent) + + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + + // Check if status actually changed to avoid unnecessary updates + statusChanged := false + if scalityUIComponent.Status.Kind != config.Metadata.Kind || + scalityUIComponent.Status.PublicPath != config.Spec.PublicPath || + scalityUIComponent.Status.Version != config.Spec.Version || + scalityUIComponent.Status.LastFetchedImage != currentImage { + statusChanged = true + } + + if !statusChanged { + logger.V(1).Info("Configuration unchanged, skipping status update") + return ctrl.Result{}, nil + } // Update status with configuration details + oldImage := scalityUIComponent.Status.LastFetchedImage + scalityUIComponent.Status.Kind = config.Metadata.Kind scalityUIComponent.Status.PublicPath = config.Spec.PublicPath scalityUIComponent.Status.Version = config.Spec.Version + scalityUIComponent.Status.LastFetchedImage = currentImage - // Set appropriate condition message based on the scenario + // Set appropriate condition message var conditionMessage string - if isFirstConfiguration { - conditionMessage = "Successfully fetched and applied UI component configuration" - logger.Info("Successfully fetched initial configuration", - "publicPath", config.Spec.PublicPath, - "version", config.Spec.Version) - } else if publicPathChanged { - conditionMessage = fmt.Sprintf("PublicPath updated: %s -> %s", oldPublicPath, config.Spec.PublicPath) - logger.Info("PublicPath change detected", - "oldPath", oldPublicPath, - "newPath", config.Spec.PublicPath) + if oldImage == "" { + conditionMessage = fmt.Sprintf("Successfully fetched initial configuration from image %s", currentImage) + } else if oldImage != currentImage { + conditionMessage = fmt.Sprintf("Configuration updated for new image: %s -> %s", oldImage, currentImage) } else { - conditionMessage = "Configuration verified - no changes detected" + conditionMessage = fmt.Sprintf("Configuration verified for image %s", currentImage) } meta.SetStatusCondition(&scalityUIComponent.Status.Conditions, metav1.Condition{ @@ -354,7 +454,7 @@ func (r *ScalityUIComponentReconciler) parseAndApplyConfig(ctx context.Context, Message: conditionMessage, }) - // Update the status + // Update the status first if err := r.Status().Update(ctx, scalityUIComponent); err != nil { logger.Error(err, "Failed to update ScalityUIComponent status") return ctrl.Result{}, err @@ -363,10 +463,56 @@ func (r *ScalityUIComponentReconciler) parseAndApplyConfig(ctx context.Context, logger.Info("Successfully updated ScalityUIComponent status", "kind", config.Metadata.Kind, "publicPath", config.Spec.PublicPath, - "version", config.Spec.Version) + "version", config.Spec.Version, + "lastFetchedImage", currentImage) + + // Remove force-refresh annotation if it exists (also done on failure to prevent fetch loops) + r.removeForceRefreshAnnotation(ctx, scalityUIComponent) + + return ctrl.Result{}, nil +} + +func (r *ScalityUIComponentReconciler) removeForceRefreshAnnotation(ctx context.Context, + scalityUIComponent *uiv1alpha1.ScalityUIComponent) { + logger := ctrl.LoggerFrom(ctx) + + if scalityUIComponent.Annotations == nil { + return + } + + if _, exists := scalityUIComponent.Annotations[ForceRefreshAnnotation]; !exists { + return + } + + key := client.ObjectKey{ + Name: scalityUIComponent.Name, + Namespace: scalityUIComponent.Namespace, + } + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + fresh := &uiv1alpha1.ScalityUIComponent{} + if err := r.Get(ctx, key, fresh); err != nil { + return err + } + + if fresh.Annotations == nil { + return nil + } + + if _, exists := fresh.Annotations[ForceRefreshAnnotation]; !exists { + return nil + } + + delete(fresh.Annotations, ForceRefreshAnnotation) + return r.Update(ctx, fresh) + }) + + if err != nil { + logger.Error(err, "Failed to remove force-refresh annotation after retries") + return + } - // Schedule next configuration check after 1 minute to detect any changes - return ctrl.Result{RequeueAfter: time.Minute}, nil + logger.Info("Removed force-refresh annotation") } func (r *ScalityUIComponentReconciler) fetchMicroAppConfig(ctx context.Context, namespace, serviceName string) (string, error) { diff --git a/internal/controller/scalityuicomponent/controller_test.go b/internal/controller/scalityuicomponent/controller_test.go index df03268..6be981f 100644 --- a/internal/controller/scalityuicomponent/controller_test.go +++ b/internal/controller/scalityuicomponent/controller_test.go @@ -288,7 +288,7 @@ var _ = Describe("ScalityUIComponent Controller", func() { NamespacedName: typeNamespacedName, }) Expect(err).NotTo(HaveOccurred()) - Expect(result.Requeue).To(BeTrue()) // Framework returns Requeue: true when deployment not ready + Expect(result.RequeueAfter).To(Equal(10 * time.Second)) // Returns RequeueAfter when deployment not ready // Check that no status condition for ConfigurationRetrieved was added yet updatedScalityUIComponent := &uiv1alpha1.ScalityUIComponent{} @@ -340,7 +340,7 @@ var _ = Describe("ScalityUIComponent Controller", func() { NamespacedName: typeNamespacedName, }) Expect(err).NotTo(HaveOccurred()) // The reconcile itself should not error, but requeue - Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(30 * time.Second)) By("Checking ScalityUIComponent status conditions") updatedScalityUIComponent := &uiv1alpha1.ScalityUIComponent{} @@ -399,7 +399,7 @@ var _ = Describe("ScalityUIComponent Controller", func() { result, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) Expect(err).NotTo(HaveOccurred()) - Expect(result.RequeueAfter).To(Equal(time.Minute)) // Now requeues every minute for periodic checks + Expect(result.RequeueAfter).To(Equal(time.Duration(0))) // No requeue needed, Deployment changes trigger reconcile via Owns() updatedScalityUIComponent := &uiv1alpha1.ScalityUIComponent{} Expect(k8sClient.Get(ctx, typeNamespacedName, updatedScalityUIComponent)).To(Succeed()) @@ -411,11 +411,11 @@ var _ = Describe("ScalityUIComponent Controller", func() { Expect(cond).NotTo(BeNil()) Expect(cond.Status).To(Equal(metav1.ConditionTrue)) Expect(cond.Reason).To(Equal("FetchSucceeded")) - // Second reconcile verifies configuration (first reconcile already set it) - Expect(cond.Message).To(Equal("Configuration verified - no changes detected")) + // Second reconcile skips fetch since image unchanged + Expect(cond.Message).To(Equal("Successfully fetched initial configuration from image scality/ui-component:latest")) - By("Verifying that the mock was called with correct parameters") - Expect(mockFetcher.ReceivedCalls).To(HaveLen(2)) // Configuration fetched on both reconcile calls + By("Verifying that the mock was called only once (second reconcile skips fetch)") + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1)) // Configuration fetched only on first reconcile Expect(mockFetcher.ReceivedCalls[0].Namespace).To(Equal(testNamespace)) Expect(mockFetcher.ReceivedCalls[0].ServiceName).To(Equal(resourceName)) Expect(mockFetcher.ReceivedCalls[0].Port).To(Equal(DefaultServicePort)) @@ -450,9 +450,8 @@ var _ = Describe("ScalityUIComponent Controller", func() { } Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) - result, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) Expect(err).NotTo(HaveOccurred()) - Expect(result.Requeue).To(BeTrue()) updatedScalityUIComponent := &uiv1alpha1.ScalityUIComponent{} Expect(k8sClient.Get(ctx, typeNamespacedName, updatedScalityUIComponent)).To(Succeed()) @@ -463,8 +462,11 @@ var _ = Describe("ScalityUIComponent Controller", func() { Expect(cond.Reason).To(Equal("ParseFailed")) Expect(cond.Message).To(ContainSubstring("Failed to parse configuration")) - By("Verifying that the mock was called with correct parameters") - Expect(mockFetcher.ReceivedCalls).To(HaveLen(2)) // Configuration fetched twice: once per reconcile call + By("Verifying that LastFetchedImage is set to prevent repeated fetch attempts") + Expect(updatedScalityUIComponent.Status.LastFetchedImage).To(Equal("scality/ui-component:latest")) + + By("Verifying that the mock was called only once (parse failures don't trigger refetch)") + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1)) Expect(mockFetcher.ReceivedCalls[0].Namespace).To(Equal(testNamespace)) Expect(mockFetcher.ReceivedCalls[0].ServiceName).To(Equal(resourceName)) Expect(mockFetcher.ReceivedCalls[0].Port).To(Equal(DefaultServicePort)) @@ -512,7 +514,7 @@ var _ = Describe("ScalityUIComponent Controller", func() { By("Reconciling again to process initial configuration") result, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) Expect(err).NotTo(HaveOccurred()) - Expect(result.RequeueAfter).To(Equal(time.Minute)) + Expect(result.RequeueAfter).To(Equal(time.Duration(0))) By("Verifying initial configuration status") updatedScalityUIComponent := &uiv1alpha1.ScalityUIComponent{} @@ -522,45 +524,53 @@ var _ = Describe("ScalityUIComponent Controller", func() { cond := meta.FindStatusCondition(updatedScalityUIComponent.Status.Conditions, "ConfigurationRetrieved") Expect(cond).NotTo(BeNil()) Expect(cond.Status).To(Equal(metav1.ConditionTrue)) - // Second reconcile verifies configuration (first reconcile already set it) - Expect(cond.Message).To(Equal("Configuration verified - no changes detected")) + // Second reconcile skips fetch since image unchanged + Expect(cond.Message).To(Equal("Successfully fetched initial configuration from image scality/ui-component:latest")) - By("Changing the PublicPath in mock config") + By("Changing the image and mock config to simulate new version") mockFetcher.ConfigContent = `{ - "kind": "UIModule", - "apiVersion": "v1alpha1", - "metadata": {"kind": "TestKind"}, + "kind": "UIModule", + "apiVersion": "v1alpha1", + "metadata": {"kind": "TestKind"}, "spec": { - "remoteEntryPath": "/remoteEntry.js", - "publicPath": "/changed-path/", - "version": "1.0.0" + "remoteEntryPath": "/remoteEntry.js", + "publicPath": "/changed-path/", + "version": "2.0.0" } }` - By("Reconciling again to detect the change") + // Update ScalityUIComponent image to trigger refetch + Expect(k8sClient.Get(ctx, typeNamespacedName, updatedScalityUIComponent)).To(Succeed()) + updatedScalityUIComponent.Spec.Image = "scality/ui-component:v2.0.0" + Expect(k8sClient.Update(ctx, updatedScalityUIComponent)).To(Succeed()) + + By("Reconciling again to detect the image change") result, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) Expect(err).NotTo(HaveOccurred()) - Expect(result.RequeueAfter).To(Equal(time.Minute)) + Expect(result.RequeueAfter).To(Equal(time.Duration(0))) - By("Verifying PublicPath change was detected") + By("Verifying configuration was updated for new image") Expect(k8sClient.Get(ctx, typeNamespacedName, updatedScalityUIComponent)).To(Succeed()) Expect(updatedScalityUIComponent.Status.PublicPath).To(Equal("/changed-path/")) + Expect(updatedScalityUIComponent.Status.Version).To(Equal("2.0.0")) + Expect(updatedScalityUIComponent.Status.LastFetchedImage).To(Equal("scality/ui-component:v2.0.0")) cond = meta.FindStatusCondition(updatedScalityUIComponent.Status.Conditions, "ConfigurationRetrieved") Expect(cond).NotTo(BeNil()) Expect(cond.Status).To(Equal(metav1.ConditionTrue)) - Expect(cond.Message).To(Equal("PublicPath updated: /initial-path/ -> /changed-path/")) + Expect(cond.Message).To(ContainSubstring("Configuration updated for new image")) - By("Reconciling again with no changes to verify no-change message") + By("Reconciling again with no changes to verify fetch is skipped") result, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) Expect(err).NotTo(HaveOccurred()) - Expect(result.RequeueAfter).To(Equal(time.Minute)) + Expect(result.RequeueAfter).To(Equal(time.Duration(0))) Expect(k8sClient.Get(ctx, typeNamespacedName, updatedScalityUIComponent)).To(Succeed()) cond = meta.FindStatusCondition(updatedScalityUIComponent.Status.Conditions, "ConfigurationRetrieved") Expect(cond).NotTo(BeNil()) Expect(cond.Status).To(Equal(metav1.ConditionTrue)) - Expect(cond.Message).To(Equal("Configuration verified - no changes detected")) + // Condition message unchanged since fetch was skipped (image unchanged on this reconcile) + Expect(cond.Message).To(ContainSubstring("Configuration updated for new image")) }) It("should preserve existing volumes and volume mounts during deployment update", func() { @@ -624,5 +634,576 @@ var _ = Describe("ScalityUIComponent Controller", func() { Expect(updatedDeployment.Spec.Template.Annotations).To(HaveKey("custom-annotation")) Expect(updatedDeployment.Spec.Template.Annotations["custom-annotation"]).To(Equal("test-value")) }) + + It("should only fetch configuration when image changes - integration test", func() { + By("Setting up mock fetcher that tracks all calls") + mockFetcher := &MockConfigFetcher{ + ShouldFail: false, + ConfigContent: `{ + "kind": "UIModule", + "apiVersion": "v1alpha1", + "metadata": {"kind": "IntegrationTestKind"}, + "spec": { + "remoteEntryPath": "/remoteEntry.js", + "publicPath": "/integration-test/", + "version": "1.0.0" + } + }`, + } + + controllerReconciler := NewScalityUIComponentReconciler(k8sClient, k8sClient.Scheme()) + controllerReconciler.ConfigFetcher = mockFetcher + + By("First reconcile - creating deployment and service") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + By("Making deployment ready") + deployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, deploymentNamespacedName, deployment) + }, time.Second*5, time.Millisecond*250).Should(Succeed()) + + deployment.Status.UpdatedReplicas = 1 + deployment.Status.Replicas = 1 + deployment.Status.ReadyReplicas = 1 + deployment.Status.AvailableReplicas = 1 + deployment.Status.Conditions = []appsv1.DeploymentCondition{ + {Type: appsv1.DeploymentAvailable, Status: corev1.ConditionTrue}, + } + Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) + + By("Second reconcile - should fetch configuration (first time)") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should fetch on first successful reconcile") + + By("Third reconcile - should NOT fetch (image unchanged)") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should still be 1 - no fetch when image unchanged") + + By("Fourth reconcile - should still NOT fetch") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should still be 1 - multiple reconciles don't trigger fetch") + + By("Fifth reconcile - should still NOT fetch") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should still be 1 - proving no reconcile storm") + + By("Updating image to trigger new fetch") + scalityUIComponent := &uiv1alpha1.ScalityUIComponent{} + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + scalityUIComponent.Spec.Image = "scality/ui-component:v2.0.0" + Expect(k8sClient.Update(ctx, scalityUIComponent)).To(Succeed()) + + By("Reconciling after image change - should fetch again") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(2), "Should fetch again after image change") + + By("Reconciling again - should NOT fetch (new image already fetched)") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(2), "Should stay at 2 - new image already processed") + + By("Verifying LastFetchedImage is tracked correctly") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + Expect(scalityUIComponent.Status.LastFetchedImage).To(Equal("scality/ui-component:v2.0.0")) + Expect(scalityUIComponent.Status.PublicPath).To(Equal("/integration-test/")) + }) + }) + + Context("Parse failure handling", func() { + It("should not retry fetch after parse failure for same image", func() { + ctx := context.Background() + + scalityUIComponent := &uiv1alpha1.ScalityUIComponent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-parse-failure", + Namespace: "default", + }, + Spec: uiv1alpha1.ScalityUIComponentSpec{ + Image: "scality/broken-json:v1.0.0", + MountPath: "/usr/share/ui-config", + }, + } + Expect(k8sClient.Create(ctx, scalityUIComponent)).To(Succeed()) + + mockFetcher := &MockConfigFetcher{ + ConfigContent: "{ invalid json", + } + controllerReconciler := &ScalityUIComponentReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConfigFetcher: mockFetcher, + } + + typeNamespacedName := types.NamespacedName{ + Name: "test-parse-failure", + Namespace: "default", + } + + By("First reconcile - creates deployment") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + By("Wait for deployment to be ready") + deployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, typeNamespacedName, deployment) + }, time.Second*5, time.Millisecond*250).Should(Succeed()) + + deployment.Status.ReadyReplicas = 1 + deployment.Status.Replicas = 1 + deployment.Status.UpdatedReplicas = 1 + Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) + + By("Second reconcile - fetches config and fails to parse") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should fetch once") + + By("Verify LastFetchedImage is set despite parse failure") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + Expect(scalityUIComponent.Status.LastFetchedImage).To(Equal("scality/broken-json:v1.0.0")) + + By("Third reconcile - should NOT fetch again (same image)") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should still be 1 - no refetch") + + By("Verify ConfigurationRetrieved condition is False") + condition := meta.FindStatusCondition(scalityUIComponent.Status.Conditions, "ConfigurationRetrieved") + Expect(condition).NotTo(BeNil()) + Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + Expect(condition.Reason).To(Equal("ParseFailed")) + }) + }) + + Context("Validation failure handling", func() { + It("should not retry fetch after validation failure for same image", func() { + ctx := context.Background() + + scalityUIComponent := &uiv1alpha1.ScalityUIComponent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-validation-failure", + Namespace: "default", + }, + Spec: uiv1alpha1.ScalityUIComponentSpec{ + Image: "scality/invalid-config:v1.0.0", + MountPath: "/usr/share/ui-config", + }, + } + Expect(k8sClient.Create(ctx, scalityUIComponent)).To(Succeed()) + + mockFetcher := &MockConfigFetcher{ + ConfigContent: `{ + "metadata": {}, + "spec": { + "publicPath": "/test/" + } + }`, + } + controllerReconciler := &ScalityUIComponentReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConfigFetcher: mockFetcher, + } + + typeNamespacedName := types.NamespacedName{ + Name: "test-validation-failure", + Namespace: "default", + } + + By("First reconcile - creates deployment") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + By("Wait for deployment to be ready") + deployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, typeNamespacedName, deployment) + }, time.Second*5, time.Millisecond*250).Should(Succeed()) + + deployment.Status.ReadyReplicas = 1 + deployment.Status.Replicas = 1 + deployment.Status.UpdatedReplicas = 1 + Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) + + By("Second reconcile - fetches config and fails validation") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should fetch once") + + By("Verify LastFetchedImage is set despite validation failure") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + Expect(scalityUIComponent.Status.LastFetchedImage).To(Equal("scality/invalid-config:v1.0.0")) + + By("Third reconcile - should NOT fetch again (same image)") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should still be 1 - no refetch") + + By("Verify ConfigurationRetrieved condition is False") + condition := meta.FindStatusCondition(scalityUIComponent.Status.Conditions, "ConfigurationRetrieved") + Expect(condition).NotTo(BeNil()) + Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + Expect(condition.Reason).To(Equal("ValidationFailed")) + }) + }) + + Context("Rolling update handling", func() { + It("should wait for rolling update to complete before fetching", func() { + ctx := context.Background() + + scalityUIComponent := &uiv1alpha1.ScalityUIComponent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rolling-update", + Namespace: "default", + }, + Spec: uiv1alpha1.ScalityUIComponentSpec{ + Image: "scality/ui-component:v1.0.0", + MountPath: "/usr/share/ui-config", + }, + } + Expect(k8sClient.Create(ctx, scalityUIComponent)).To(Succeed()) + + mockFetcher := &MockConfigFetcher{ + ConfigContent: `{ + "metadata": { + "kind": "app", + "name": "test-app" + }, + "spec": { + "publicPath": "/test/", + "version": "1.0.0" + } + }`, + } + controllerReconciler := &ScalityUIComponentReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConfigFetcher: mockFetcher, + } + + typeNamespacedName := types.NamespacedName{ + Name: "test-rolling-update", + Namespace: "default", + } + + By("First reconcile - creates deployment") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + By("Simulate rolling update - ReadyReplicas=1 but UpdatedReplicas=0") + deployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, typeNamespacedName, deployment) + }, time.Second*5, time.Millisecond*250).Should(Succeed()) + + deployment.Status.ReadyReplicas = 1 + deployment.Status.Replicas = 1 + deployment.Status.UpdatedReplicas = 0 + Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) + + By("Second reconcile - should NOT fetch (rolling update incomplete)") + result, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(result.RequeueAfter).To(Equal(10 * time.Second)) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(0), "Should not fetch during rolling update") + + By("Complete rolling update") + Expect(k8sClient.Get(ctx, typeNamespacedName, deployment)).To(Succeed()) + deployment.Status.UpdatedReplicas = 1 + Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) + + By("Third reconcile - should fetch now (rolling update complete)") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should fetch after rolling update completes") + }) + }) + + Context("Force-refresh annotation", func() { + It("should trigger fetch when force-refresh annotation is present even if image unchanged", func() { + ctx := context.Background() + + scalityUIComponent := &uiv1alpha1.ScalityUIComponent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-force-refresh", + Namespace: "default", + }, + Spec: uiv1alpha1.ScalityUIComponentSpec{ + Image: "scality/ui-component:v1.0.0", + MountPath: "/usr/share/ui-config", + }, + } + Expect(k8sClient.Create(ctx, scalityUIComponent)).To(Succeed()) + + mockFetcher := &MockConfigFetcher{ + ConfigContent: `{ + "metadata": {"kind": "TestKind"}, + "spec": {"publicPath": "/test/", "version": "1.0.0"} + }`, + } + controllerReconciler := &ScalityUIComponentReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConfigFetcher: mockFetcher, + } + + typeNamespacedName := types.NamespacedName{ + Name: "test-force-refresh", + Namespace: "default", + } + + By("First reconcile - creates deployment") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + By("Make deployment ready") + deployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, typeNamespacedName, deployment) + }, time.Second*5, time.Millisecond*250).Should(Succeed()) + + deployment.Status.ReadyReplicas = 1 + deployment.Status.Replicas = 1 + deployment.Status.UpdatedReplicas = 1 + Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) + + By("Second reconcile - initial fetch") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should fetch on initial reconcile") + + By("Third reconcile - no fetch (image unchanged)") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should not fetch - image unchanged") + + By("Add force-refresh annotation") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + if scalityUIComponent.Annotations == nil { + scalityUIComponent.Annotations = make(map[string]string) + } + scalityUIComponent.Annotations[ForceRefreshAnnotation] = "true" + Expect(k8sClient.Update(ctx, scalityUIComponent)).To(Succeed()) + + By("Update mock to return different config") + mockFetcher.ConfigContent = `{ + "metadata": {"kind": "TestKind"}, + "spec": {"publicPath": "/updated-path/", "version": "1.0.1"} + }` + + By("Fourth reconcile - should fetch due to force-refresh annotation") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(2), "Should fetch due to force-refresh annotation") + + By("Verify configuration was updated") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + Expect(scalityUIComponent.Status.PublicPath).To(Equal("/updated-path/")) + Expect(scalityUIComponent.Status.Version).To(Equal("1.0.1")) + + By("Verify force-refresh annotation was removed") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + _, hasAnnotation := scalityUIComponent.Annotations[ForceRefreshAnnotation] + Expect(hasAnnotation).To(BeFalse(), "force-refresh annotation should be removed after fetch") + + By("Fifth reconcile - no fetch (annotation removed, image unchanged)") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(2), "Should not fetch - annotation removed") + }) + + It("should remove force-refresh annotation even on fetch failure", func() { + ctx := context.Background() + + scalityUIComponent := &uiv1alpha1.ScalityUIComponent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-force-refresh-failure", + Namespace: "default", + Annotations: map[string]string{ + ForceRefreshAnnotation: "true", + }, + }, + Spec: uiv1alpha1.ScalityUIComponentSpec{ + Image: "scality/ui-component:v1.0.0", + MountPath: "/usr/share/ui-config", + }, + } + Expect(k8sClient.Create(ctx, scalityUIComponent)).To(Succeed()) + + mockFetcher := &MockConfigFetcher{ + ShouldFail: true, + ErrorMessage: "simulated fetch failure", + } + controllerReconciler := &ScalityUIComponentReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConfigFetcher: mockFetcher, + } + + typeNamespacedName := types.NamespacedName{ + Name: "test-force-refresh-failure", + Namespace: "default", + } + + By("First reconcile - creates deployment") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + By("Make deployment ready") + deployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, typeNamespacedName, deployment) + }, time.Second*5, time.Millisecond*250).Should(Succeed()) + + deployment.Status.ReadyReplicas = 1 + deployment.Status.Replicas = 1 + deployment.Status.UpdatedReplicas = 1 + Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) + + By("Second reconcile - fetch fails") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should attempt fetch") + + By("Verify force-refresh annotation was NOT removed on fetch failure (only removed on parse/validation failure)") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + _, hasAnnotation := scalityUIComponent.Annotations[ForceRefreshAnnotation] + Expect(hasAnnotation).To(BeTrue(), "force-refresh annotation should remain on fetch failure for retry") + }) + + It("should remove force-refresh annotation on parse failure", func() { + ctx := context.Background() + + scalityUIComponent := &uiv1alpha1.ScalityUIComponent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-force-refresh-parse-fail", + Namespace: "default", + Annotations: map[string]string{ + ForceRefreshAnnotation: "true", + }, + }, + Spec: uiv1alpha1.ScalityUIComponentSpec{ + Image: "scality/ui-component:v1.0.0", + MountPath: "/usr/share/ui-config", + }, + } + Expect(k8sClient.Create(ctx, scalityUIComponent)).To(Succeed()) + + mockFetcher := &MockConfigFetcher{ + ConfigContent: "{ invalid json", + } + controllerReconciler := &ScalityUIComponentReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConfigFetcher: mockFetcher, + } + + typeNamespacedName := types.NamespacedName{ + Name: "test-force-refresh-parse-fail", + Namespace: "default", + } + + By("First reconcile - creates deployment") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + By("Make deployment ready") + deployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, typeNamespacedName, deployment) + }, time.Second*5, time.Millisecond*250).Should(Succeed()) + + deployment.Status.ReadyReplicas = 1 + deployment.Status.Replicas = 1 + deployment.Status.UpdatedReplicas = 1 + Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) + + By("Second reconcile - parse fails") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + By("Verify force-refresh annotation was removed on parse failure") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + _, hasAnnotation := scalityUIComponent.Annotations[ForceRefreshAnnotation] + Expect(hasAnnotation).To(BeFalse(), "force-refresh annotation should be removed on parse failure") + }) + }) + + Context("Nil annotations handling", func() { + It("should handle force-refresh annotation when Annotations map is nil", func() { + ctx := context.Background() + + scalityUIComponent := &uiv1alpha1.ScalityUIComponent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nil-annotations", + Namespace: "default", + }, + Spec: uiv1alpha1.ScalityUIComponentSpec{ + Image: "scality/ui-component:v1.0.0", + MountPath: "/usr/share/ui-config", + }, + } + Expect(k8sClient.Create(ctx, scalityUIComponent)).To(Succeed()) + + mockFetcher := &MockConfigFetcher{ + ConfigContent: `{ + "metadata": { + "kind": "app", + "name": "test-app" + }, + "spec": { + "publicPath": "/test/", + "version": "1.0.0" + } + }`, + } + controllerReconciler := &ScalityUIComponentReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConfigFetcher: mockFetcher, + } + + typeNamespacedName := types.NamespacedName{ + Name: "test-nil-annotations", + Namespace: "default", + } + + By("First reconcile - creates deployment") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + By("Wait for deployment to be ready") + deployment := &appsv1.Deployment{} + Eventually(func() error { + return k8sClient.Get(ctx, typeNamespacedName, deployment) + }, time.Second*5, time.Millisecond*250).Should(Succeed()) + + deployment.Status.ReadyReplicas = 1 + deployment.Status.Replicas = 1 + deployment.Status.UpdatedReplicas = 1 + Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed()) + + By("Verify Annotations is nil") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + Expect(scalityUIComponent.Annotations).To(BeNil()) + + By("Second reconcile with nil Annotations - should not panic") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(mockFetcher.ReceivedCalls).To(HaveLen(1), "Should fetch successfully") + + By("Verify status is updated correctly") + Expect(k8sClient.Get(ctx, typeNamespacedName, scalityUIComponent)).To(Succeed()) + Expect(scalityUIComponent.Status.PublicPath).To(Equal("/test/")) + Expect(scalityUIComponent.Status.LastFetchedImage).To(Equal("scality/ui-component:v1.0.0")) + }) }) })