diff --git a/api/v1alpha1/scalityui_types.go b/api/v1alpha1/scalityui_types.go index 696813c..cc6508d 100644 --- a/api/v1alpha1/scalityui_types.go +++ b/api/v1alpha1/scalityui_types.go @@ -27,6 +27,27 @@ import ( // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. +// DeployedUIApp represents a deployed UI application entry +type DeployedUIApp struct { + // AppHistoryBasePath is the base path for the app's history + // +kubebuilder:validation:Required + AppHistoryBasePath string `json:"appHistoryBasePath"` + // Kind specifies the type of UI app (e.g., "micro-app", "solution") + // +kubebuilder:validation:Required + Kind string `json:"kind"` + // Name is the unique identifier for the app + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + // URL is the path where the app is served + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + URL string `json:"url"` + // Version is the app version + // +kubebuilder:validation:Required + Version string `json:"version"` +} + // ScalityUISpec defines the desired state of ScalityUI type ScalityUISpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster @@ -39,6 +60,7 @@ type ScalityUISpec struct { Auth *AuthConfig `json:"auth,omitempty"` UIConfig *UIConfig `json:"uiConfig,omitempty"` ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` + ExtraUIApps []DeployedUIApp `json:"extraUIApps,omitempty"` // Scheduling defines pod scheduling constraints for the UI deployment Scheduling *PodSchedulingSpec `json:"scheduling,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 1353ca2..6b21f15 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -69,6 +69,21 @@ func (in *CommonStatus) DeepCopy() *CommonStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeployedUIApp) DeepCopyInto(out *DeployedUIApp) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeployedUIApp. +func (in *DeployedUIApp) DeepCopy() *DeployedUIApp { + if in == nil { + return nil + } + out := new(DeployedUIApp) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExternalNavbarItem) DeepCopyInto(out *ExternalNavbarItem) { *out = *in @@ -522,6 +537,11 @@ func (in *ScalityUISpec) DeepCopyInto(out *ScalityUISpec) { *out = make([]corev1.LocalObjectReference, len(*in)) copy(*out, *in) } + if in.ExtraUIApps != nil { + in, out := &in.ExtraUIApps, &out.ExtraUIApps + *out = make([]DeployedUIApp, len(*in)) + copy(*out, *in) + } if in.Scheduling != nil { in, out := &in.Scheduling, &out.Scheduling *out = new(PodSchedulingSpec) diff --git a/config/crd/bases/ui.scality.com_scalityuis.yaml b/config/crd/bases/ui.scality.com_scalityuis.yaml index 1aa5c68..09711ac 100644 --- a/config/crd/bases/ui.scality.com_scalityuis.yaml +++ b/config/crd/bases/ui.scality.com_scalityuis.yaml @@ -73,6 +73,38 @@ spec: description: Scopes specifies the OIDC scopes type: string type: object + extraUIApps: + items: + description: DeployedUIApp represents a deployed UI application + entry + properties: + appHistoryBasePath: + description: AppHistoryBasePath is the base path for the app's + history + type: string + kind: + description: Kind specifies the type of UI app (e.g., "micro-app", + "solution") + type: string + name: + description: Name is the unique identifier for the app + minLength: 1 + type: string + url: + description: URL is the path where the app is served + minLength: 1 + type: string + version: + description: Version is the app version + type: string + required: + - appHistoryBasePath + - kind + - name + - url + - version + type: object + type: array image: description: |- INSERT ADDITIONAL SPEC FIELDS - desired state of cluster diff --git a/internal/controller/scalityui/controller.go b/internal/controller/scalityui/controller.go index 70419c7..bade3a9 100644 --- a/internal/controller/scalityui/controller.go +++ b/internal/controller/scalityui/controller.go @@ -25,15 +25,6 @@ import ( "github.com/scality/ui-operator/internal/services" ) -// DeployedUIApp represents a deployed UI application entry -type DeployedUIApp struct { - AppHistoryBasePath string `json:"appHistoryBasePath"` - Kind string `json:"kind"` - Name string `json:"name"` - URL string `json:"url"` - Version string `json:"version"` -} - // getOperatorNamespace returns the namespace where the operator is deployed. // It reads from the POD_NAMESPACE environment variable, which should be set // using the downward API in the operator's deployment. diff --git a/internal/controller/scalityui/controller_test.go b/internal/controller/scalityui/controller_test.go index b464598..e6c0952 100644 --- a/internal/controller/scalityui/controller_test.go +++ b/internal/controller/scalityui/controller_test.go @@ -580,6 +580,143 @@ var _ = Describe("ScalityUI Shell Features", func() { Expect(deployedAppsConfigMap.Annotations).To(HaveKey("scality.com/deployed-apps-hash")) }) + It("should include ExtraUIApps in deployed-ui-apps ConfigMap", func() { + By("Creating ScalityUI with ExtraUIApps") + extraUIAppName := "extra-ui-apps-test" + extraUI := &uiv1alpha1.ScalityUI{ + ObjectMeta: metav1.ObjectMeta{Name: extraUIAppName}, + Spec: uiv1alpha1.ScalityUISpec{ + Image: "shell-ui:1.0.0", + ProductName: "Extra UI Apps Test", + ExtraUIApps: []uiv1alpha1.DeployedUIApp{ + { + Name: "external-app", + Kind: "solution", + URL: "/external-app", + Version: "2.0.0", + AppHistoryBasePath: "/external-app", + }, + { + Name: "another-app", + Kind: "micro-app", + URL: "/another-app", + Version: "1.5.0", + AppHistoryBasePath: "/another", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, extraUI)).To(Succeed()) + defer k8sClient.Delete(ctx, extraUI) + + By("Reconciling to create deployed-ui-apps ConfigMap") + reconciler := NewScalityUIReconcilerForTest(k8sClient, k8sClient.Scheme()) + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: extraUIAppName}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying deployed-ui-apps ConfigMap contains ExtraUIApps") + verifyDeployedUIAppsContent(ctx, extraUIAppName, []map[string]interface{}{ + { + "appHistoryBasePath": "/external-app", + "kind": "solution", + "name": "external-app", + "url": "/external-app", + "version": "2.0.0", + }, + { + "appHistoryBasePath": "/another", + "kind": "micro-app", + "name": "another-app", + "url": "/another-app", + "version": "1.5.0", + }, + }) + }) + + It("should merge ExtraUIApps with exposer-based apps", func() { + By("Creating ScalityUI with ExtraUIApps") + mergeUIAppName := "merge-ui-apps-test" + mergeUI := &uiv1alpha1.ScalityUI{ + ObjectMeta: metav1.ObjectMeta{Name: mergeUIAppName}, + Spec: uiv1alpha1.ScalityUISpec{ + Image: "shell-ui:1.0.0", + ProductName: "Merge UI Apps Test", + ExtraUIApps: []uiv1alpha1.DeployedUIApp{ + { + Name: "extra-app", + Kind: "solution", + URL: "/extra-app", + Version: "3.0.0", + AppHistoryBasePath: "/extra", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mergeUI)).To(Succeed()) + defer k8sClient.Delete(ctx, mergeUI) + + By("Creating component and exposer") + testNamespace := "test-merge-apps" + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNamespace}} + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + defer k8sClient.Delete(ctx, ns) + + component := &uiv1alpha1.ScalityUIComponent{ + ObjectMeta: metav1.ObjectMeta{Name: "merge-component", Namespace: testNamespace}, + Spec: uiv1alpha1.ScalityUIComponentSpec{Image: "component:1.0.0"}, + } + Expect(k8sClient.Create(ctx, component)).To(Succeed()) + component.Status = uiv1alpha1.ScalityUIComponentStatus{ + Kind: "micro-app", PublicPath: "/apps/merge-component", Version: "1.0.0", + } + component.Status.Conditions = []metav1.Condition{ + { + Type: "ConfigurationRetrieved", + Status: metav1.ConditionTrue, + Reason: "FetchSucceeded", + LastTransitionTime: metav1.Now(), + }, + } + Expect(k8sClient.Status().Update(ctx, component)).To(Succeed()) + defer k8sClient.Delete(ctx, component) + + exposer := &uiv1alpha1.ScalityUIComponentExposer{ + ObjectMeta: metav1.ObjectMeta{Name: "merge-exposer", Namespace: testNamespace}, + Spec: uiv1alpha1.ScalityUIComponentExposerSpec{ + ScalityUI: mergeUIAppName, ScalityUIComponent: "merge-component", AppHistoryBasePath: "/merge-app", + }, + } + Expect(k8sClient.Create(ctx, exposer)).To(Succeed()) + defer k8sClient.Delete(ctx, exposer) + + By("Reconciling to merge apps") + reconciler := NewScalityUIReconcilerForTest(k8sClient, k8sClient.Scheme()) + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: mergeUIAppName}, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying deployed-ui-apps ConfigMap contains both exposer-based and ExtraUIApps") + verifyDeployedUIAppsContent(ctx, mergeUIAppName, []map[string]interface{}{ + { + "appHistoryBasePath": "/merge-app", + "kind": "micro-app", + "name": "merge-component", + "url": "/apps/merge-component", + "version": "1.0.0", + }, + { + "appHistoryBasePath": "/extra", + "kind": "solution", + "name": "extra-app", + "url": "/extra-app", + "version": "3.0.0", + }, + }) + }) + It("should trigger rolling update when exposer is removed", func() { By("Deploying the Shell UI with an exposer") reconciler := NewScalityUIReconcilerForTest(k8sClient, k8sClient.Scheme()) diff --git a/internal/controller/scalityui/deployed_apps_configmap.go b/internal/controller/scalityui/deployed_apps_configmap.go index a2ee568..bb7a1dd 100644 --- a/internal/controller/scalityui/deployed_apps_configmap.go +++ b/internal/controller/scalityui/deployed_apps_configmap.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/go-logr/logr" + uiv1alpha1 "github.com/scality/ui-operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -26,7 +27,7 @@ func newDeployedAppsConfigMapReducer(r *ScalityUIReconciler, cr ScalityUI, curre } // Build the deployed apps list with component deduplication - deployedApps := make([]DeployedUIApp, 0) + deployedApps := make([]uiv1alpha1.DeployedUIApp, 0) processedComponents := make(map[string]bool) // Track which components we've already processed for _, exposer := range exposers { @@ -48,7 +49,7 @@ func newDeployedAppsConfigMapReducer(r *ScalityUIReconciler, cr ScalityUI, curre } if meta.IsStatusConditionTrue(component.Status.Conditions, "ConfigurationRetrieved") { - deployedApp := DeployedUIApp{ + deployedApp := uiv1alpha1.DeployedUIApp{ AppHistoryBasePath: exposer.Spec.AppHistoryBasePath, Kind: component.Status.Kind, Name: component.Name, @@ -60,6 +61,9 @@ func newDeployedAppsConfigMapReducer(r *ScalityUIReconciler, cr ScalityUI, curre } } + // Append ExtraUIApps directly from ScalityUI spec + deployedApps = append(deployedApps, cr.Spec.ExtraUIApps...) + // Create or update the deployed-ui-apps ConfigMap configMapName := cr.Name + "-deployed-ui-apps" configMap := &corev1.ConfigMap{ @@ -102,7 +106,10 @@ func newDeployedAppsConfigMapReducer(r *ScalityUIReconciler, cr ScalityUI, curre logOperationResult(log, result, "ConfigMap deployed-ui-apps", configMapName) log.Info("Successfully reconciled deployed UI apps", - "appsCount", len(deployedApps), "configMap", configMapName) + "totalAppsCount", len(deployedApps), + "exposerAppsCount", len(deployedApps)-len(cr.Spec.ExtraUIApps), + "extraUIAppsCount", len(cr.Spec.ExtraUIApps), + "configMap", configMapName) // Store hash in memory for deployment to use (avoids cache sync issues) currentState.SetSubresourceHash(deployedAppsConfigMapHashKey, deployedAppsHash)