diff --git a/.golangci.yml b/.golangci.yml index a105fa75c..37d9c4f98 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -119,6 +119,7 @@ linters: - mnd - revive - staticcheck + - dupl path: _test\.go - path: pkg/golinters/errcheck.go text: 'SA1019: errCfg.Exclude is deprecated: use ExcludeFunctions instead' @@ -144,6 +145,7 @@ linters: - third_party$ - builtin$ - examples$ + - console/node_modules formatters: enable: - gofmt @@ -166,3 +168,4 @@ formatters: - third_party$ - builtin$ - examples$ + - console/node_modules diff --git a/internal/controller/acm_test.go b/internal/controller/acm_test.go index 50cf1b06f..3856a4743 100644 --- a/internal/controller/acm_test.go +++ b/internal/controller/acm_test.go @@ -108,3 +108,208 @@ var _ = Describe("HaveACMHub", func() { }) }) }) + +var _ = Describe("ListManagedClusters", func() { + var ( + patternReconciler *PatternReconciler + dynamicClient *dynamicfake.FakeDynamicClient + gvrMC schema.GroupVersionResource + ) + + BeforeEach(func() { + gvrMC = schema.GroupVersionResource{Group: "cluster.open-cluster-management.io", Version: "v1", Resource: "managedclusters"} + + dynamicClient = dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + gvrMC: "ManagedClusterList", + }) + + patternReconciler = &PatternReconciler{ + dynamicClient: dynamicClient, + } + }) + + Context("when there are managed clusters", func() { + BeforeEach(func() { + for _, name := range []string{"local-cluster", "spoke-1", "spoke-2"} { + mc := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "cluster.open-cluster-management.io/v1", + "kind": "ManagedCluster", + "metadata": map[string]any{ + "name": name, + }, + }, + } + _, err := dynamicClient.Resource(gvrMC).Create(context.Background(), mc, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + } + }) + + It("should return all clusters except local-cluster", func() { + clusters, err := patternReconciler.listManagedClusters(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(clusters).To(HaveLen(2)) + Expect(clusters).To(ContainElement("spoke-1")) + Expect(clusters).To(ContainElement("spoke-2")) + Expect(clusters).ToNot(ContainElement("local-cluster")) + }) + }) + + Context("when there are no managed clusters", func() { + It("should return empty list", func() { + clusters, err := patternReconciler.listManagedClusters(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(clusters).To(BeEmpty()) + }) + }) + + Context("when only local-cluster exists", func() { + BeforeEach(func() { + mc := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "cluster.open-cluster-management.io/v1", + "kind": "ManagedCluster", + "metadata": map[string]any{ + "name": "local-cluster", + }, + }, + } + _, err := dynamicClient.Resource(gvrMC).Create(context.Background(), mc, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return empty list", func() { + clusters, err := patternReconciler.listManagedClusters(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(clusters).To(BeEmpty()) + }) + }) + + Context("when there is an error listing", func() { + BeforeEach(func() { + dynamicClient.PrependReactor("list", "managedclusters", func(testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("list error") + }) + }) + + It("should return an error", func() { + _, err := patternReconciler.listManagedClusters(context.Background()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to list ManagedClusters")) + }) + }) +}) + +var _ = Describe("DeleteManagedClusters", func() { + var ( + patternReconciler *PatternReconciler + dynamicClient *dynamicfake.FakeDynamicClient + gvrMC schema.GroupVersionResource + ) + + BeforeEach(func() { + gvrMC = schema.GroupVersionResource{Group: "cluster.open-cluster-management.io", Version: "v1", Resource: "managedclusters"} + + dynamicClient = dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + gvrMC: "ManagedClusterList", + }) + + patternReconciler = &PatternReconciler{ + dynamicClient: dynamicClient, + } + }) + + Context("when there are managed clusters to delete", func() { + BeforeEach(func() { + for _, name := range []string{"local-cluster", "spoke-1", "spoke-2"} { + mc := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "cluster.open-cluster-management.io/v1", + "kind": "ManagedCluster", + "metadata": map[string]any{ + "name": name, + }, + }, + } + _, err := dynamicClient.Resource(gvrMC).Create(context.Background(), mc, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + } + }) + + It("should delete all clusters except local-cluster", func() { + count, err := patternReconciler.deleteManagedClusters(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(2)) + }) + }) + + Context("when there are no managed clusters", func() { + It("should return 0", func() { + count, err := patternReconciler.deleteManagedClusters(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(0)) + }) + }) + + Context("when only local-cluster exists", func() { + BeforeEach(func() { + mc := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "cluster.open-cluster-management.io/v1", + "kind": "ManagedCluster", + "metadata": map[string]any{ + "name": "local-cluster", + }, + }, + } + _, err := dynamicClient.Resource(gvrMC).Create(context.Background(), mc, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return 0", func() { + count, err := patternReconciler.deleteManagedClusters(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(0)) + }) + }) + + Context("when listing fails", func() { + BeforeEach(func() { + dynamicClient.PrependReactor("list", "managedclusters", func(testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("list error") + }) + }) + + It("should return an error", func() { + _, err := patternReconciler.deleteManagedClusters(context.Background()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to list ManagedClusters")) + }) + }) + + Context("when delete fails", func() { + BeforeEach(func() { + mc := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "cluster.open-cluster-management.io/v1", + "kind": "ManagedCluster", + "metadata": map[string]any{ + "name": "spoke-1", + }, + }, + } + _, err := dynamicClient.Resource(gvrMC).Create(context.Background(), mc, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + dynamicClient.PrependReactor("delete", "managedclusters", func(testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("delete error") + }) + }) + + It("should return an error", func() { + _, err := patternReconciler.deleteManagedClusters(context.Background()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to delete ManagedCluster")) + }) + }) +}) diff --git a/internal/controller/analytics_test.go b/internal/controller/analytics_test.go index f1672dd78..623f7978f 100644 --- a/internal/controller/analytics_test.go +++ b/internal/controller/analytics_test.go @@ -1,11 +1,13 @@ package controllers import ( + "fmt" "time" "github.com/go-logr/logr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/segmentio/analytics-go/v3" api "github.com/hybrid-cloud-patterns/patterns-operator/api/v1alpha1" ) @@ -183,6 +185,147 @@ var _ = Describe("VpAnalytics", func() { }) }) +var _ = Describe("AnalyticsInit", func() { + Context("when disabled", func() { + It("should return VpAnalytics with empty apiKey", func() { + v := AnalyticsInit(true, logr.Discard()) + Expect(v).ToNot(BeNil()) + Expect(v.apiKey).To(BeEmpty()) + }) + }) + + Context("when enabled with invalid api key", func() { + It("should return VpAnalytics with empty apiKey when base64 decoding fails", func() { + // The embedded api_key.txt is expected to have either invalid or test content + v := AnalyticsInit(false, logr.Discard()) + Expect(v).ToNot(BeNil()) + // apiKey will be set based on whether the embedded key can be decoded + }) + }) +}) + +var _ = Describe("retryAnalytics", func() { + Context("when function succeeds on first try", func() { + It("should return nil", func() { + successFunc := func(m analytics.Message) error { + return nil + } + track := analytics.Track{Event: "test"} + err := retryAnalytics(logr.Discard(), 3, 0, track, successFunc) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("when function always fails", func() { + It("should return the error after all retries", func() { + failFunc := func(m analytics.Message) error { + return fmt.Errorf("always fails") + } + track := analytics.Track{Event: "test"} + err := retryAnalytics(logr.Discard(), 2, 0, track, failFunc) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("always fails")) + }) + }) + + Context("when function succeeds on second try", func() { + It("should return nil", func() { + callCount := 0 + retryFunc := func(m analytics.Message) error { + callCount++ + if callCount < 2 { + return fmt.Errorf("temporary error") + } + return nil + } + track := analytics.Track{Event: "test"} + err := retryAnalytics(logr.Discard(), 3, 0, track, retryFunc) + Expect(err).ToNot(HaveOccurred()) + Expect(callCount).To(Equal(2)) + }) + }) +}) + +var _ = Describe("setBit and hasBit", func() { + Context("setBit", func() { + It("should set the bit at the given position", func() { + n := setBit(0, 0) + Expect(n).To(Equal(1)) + }) + + It("should set multiple bits", func() { + n := setBit(0, 0) + n = setBit(n, 2) + Expect(n).To(Equal(5)) // 101 in binary + }) + }) + + Context("hasBit", func() { + It("should return true when bit is set", func() { + Expect(hasBit(5, 0)).To(BeTrue()) + Expect(hasBit(5, 2)).To(BeTrue()) + }) + + It("should return false when bit is not set", func() { + Expect(hasBit(5, 1)).To(BeFalse()) + }) + + It("should return false for zero", func() { + Expect(hasBit(0, 0)).To(BeFalse()) + }) + }) +}) + +var _ = Describe("getAnalyticsProperties", func() { + It("should return properties with correct fields", func() { + pattern := &api.Pattern{ + Status: api.PatternStatus{ + ClusterPlatform: "AWS", + ClusterVersion: "4.12", + ClusterDomain: "example.com", + }, + } + pattern.Name = "test-pattern" + pattern.Spec.GitConfig.TargetRepo = "https://github.com/validatedpatterns/test" + + props := getAnalyticsProperties(pattern) + Expect(props).ToNot(BeNil()) + }) +}) + +var _ = Describe("getAnalyticsContext", func() { + It("should return a valid analytics context", func() { + pattern := &api.Pattern{ + Status: api.PatternStatus{ + ClusterPlatform: "AWS", + ClusterVersion: "4.12", + ClusterDomain: "example.com", + }, + } + pattern.Name = "test-pattern" + pattern.Spec.GitConfig.TargetRepo = "https://github.com/validatedpatterns/test" + + ctx := getAnalyticsContext(pattern) + Expect(ctx).ToNot(BeNil()) + Expect(ctx.Extra["Pattern"]).To(Equal("test-pattern")) + Expect(ctx.Extra["Platform"]).To(Equal("AWS")) + }) +}) + +var _ = Describe("getBaseGitRepo", func() { + It("should extract the repo name from target repo URL", func() { + pattern := &api.Pattern{} + pattern.Spec.GitConfig.TargetRepo = "https://github.com/validatedpatterns/multicloud-gitops" + Expect(getBaseGitRepo(pattern)).To(Equal("multicloud-gitops")) + }) + + It("should handle repos with .git suffix", func() { + pattern := &api.Pattern{} + pattern.Spec.GitConfig.TargetRepo = "https://github.com/validatedpatterns/multicloud-gitops.git" + Expect(getBaseGitRepo(pattern)).To(Equal("multicloud-gitops")) + }) +}) + var _ = Describe("getDeviceHash", func() { var pattern *api.Pattern @@ -257,6 +400,30 @@ var _ = Describe("getSimpleDomain", func() { Expect(actualSimpleDomain).To(Equal(expectedSimpleDomain)) }) }) + + Context("with more than 3 parts", func() { + It("should return only last 3 parts", func() { + pattern.Status.ClusterDomain = "hub.cluster.example.com" + actualSimpleDomain := getSimpleDomain(pattern) + Expect(actualSimpleDomain).To(Equal("cluster.example.com")) + }) + }) + + Context("with deep subdomain", func() { + It("should return only last 3 parts", func() { + pattern.Status.ClusterDomain = "deep.sub.domain.example.com" + actualSimpleDomain := getSimpleDomain(pattern) + Expect(actualSimpleDomain).To(Equal("domain.example.com")) + }) + }) + + Context("with exactly 2 parts", func() { + It("should return the full domain", func() { + pattern.Status.ClusterDomain = "example.com" + actualSimpleDomain := getSimpleDomain(pattern) + Expect(actualSimpleDomain).To(Equal("example.com")) + }) + }) }) var _ = Describe("getNewUUID", func() { diff --git a/internal/controller/argo_test.go b/internal/controller/argo_test.go index f7170f2dc..43ff28b9e 100644 --- a/internal/controller/argo_test.go +++ b/internal/controller/argo_test.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "path/filepath" "slices" "strings" @@ -978,6 +979,824 @@ var _ = Describe("CreateOrUpdateArgoCD", func() { }) }) +var _ = Describe("CompareApplication", func() { + var pattern *api.Pattern + BeforeEach(func() { + tmpFalse := false + pattern = &api.Pattern{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pattern", Namespace: defaultNamespace}, + TypeMeta: metav1.TypeMeta{Kind: "Pattern", APIVersion: api.GroupVersion.String()}, + Spec: api.PatternSpec{ + ClusterGroupName: "foogroup", + GitConfig: api.GitConfig{ + TargetRepo: "https://github.com/validatedpatterns/multicloud-gitops", + TargetRevision: "main", + }, + MultiSourceConfig: api.MultiSourceConfig{ + Enabled: &tmpFalse, + HelmRepoUrl: "https://charts.validatedpatterns.io/", + ClusterGroupChartVersion: "0.0.*", + }, + GitOpsConfig: &api.GitOpsConfig{ + ManualSync: false, + }, + }, + Status: api.PatternStatus{ + ClusterPlatform: "AWS", + ClusterVersion: "4.12", + ClusterName: "barcluster", + AppClusterDomain: "apps.hub-cluster.validatedpatterns.io", + ClusterDomain: "hub-cluster.validatedpatterns.io", + }, + } + }) + + Context("when both applications are nil", func() { + It("should return true", func() { + Expect(compareApplication(nil, nil)).To(BeTrue()) + }) + }) + + Context("when one application is nil and the other is not", func() { + It("should return false when goal is nil", func() { + app := newArgoApplication(pattern) + Expect(compareApplication(nil, app)).To(BeFalse()) + }) + It("should return false when actual is nil", func() { + app := newArgoApplication(pattern) + Expect(compareApplication(app, nil)).To(BeFalse()) + }) + }) + + Context("when both applications are identical", func() { + It("should return true", func() { + app := newArgoApplication(pattern) + Expect(compareApplication(app, app)).To(BeTrue()) + }) + }) + + Context("when applications have different sources", func() { + It("should return false", func() { + app1 := newArgoApplication(pattern) + app2 := app1.DeepCopy() + app2.Spec.Source.RepoURL = "https://different.repo/url" + Expect(compareApplication(app1, app2)).To(BeFalse()) + }) + }) + + Context("when applications have different sync policies", func() { + It("should return false", func() { + app1 := newArgoApplication(pattern) + app2 := app1.DeepCopy() + app2.Spec.SyncPolicy = nil + Expect(compareApplication(app1, app2)).To(BeFalse()) + }) + }) +}) + +var _ = Describe("GetApplication", func() { + var ( + argocdclient *argoclient.Clientset + name string + namespace string + ) + + BeforeEach(func() { + argocdclient = argoclient.NewSimpleClientset() + name = "test-application" + namespace = "default" + }) + + Context("when the application exists", func() { + BeforeEach(func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + _, err := argocdclient.ArgoprojV1alpha1().Applications(namespace).Create(context.Background(), app, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return the application", func() { + app, err := getApplication(argocdclient, name, namespace) + Expect(err).ToNot(HaveOccurred()) + Expect(app).ToNot(BeNil()) + Expect(app.Name).To(Equal(name)) + }) + }) + + Context("when the application does not exist", func() { + It("should return an error", func() { + app, err := getApplication(argocdclient, "nonexistent", namespace) + Expect(err).To(HaveOccurred()) + Expect(app).To(BeNil()) + }) + }) +}) + +var _ = Describe("CreateApplication", func() { + var ( + argocdclient *argoclient.Clientset + namespace string + ) + + BeforeEach(func() { + argocdclient = argoclient.NewSimpleClientset() + namespace = "default" + }) + + Context("when creating an application", func() { + It("should create successfully", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-app", + Namespace: namespace, + }, + } + err := createApplication(argocdclient, app, namespace) + Expect(err).ToNot(HaveOccurred()) + + created, err := argocdclient.ArgoprojV1alpha1().Applications(namespace).Get(context.Background(), "new-app", metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(created.Name).To(Equal("new-app")) + }) + }) + + Context("when creation fails", func() { + BeforeEach(func() { + argocdclient.PrependReactor("create", "applications", func(testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("create error") + }) + }) + + It("should return the error", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "new-app", + Namespace: namespace, + }, + } + err := createApplication(argocdclient, app, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("create error")) + }) + }) +}) + +var _ = Describe("UpdateApplication", func() { + var ( + argocdclient *argoclient.Clientset + namespace string + ) + + BeforeEach(func() { + argocdclient = argoclient.NewSimpleClientset() + namespace = "default" + }) + + Context("when current is nil", func() { + It("should return an error", func() { + target := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: namespace}, + } + changed, err := updateApplication(argocdclient, target, nil, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("current application was nil")) + Expect(changed).To(BeFalse()) + }) + }) + + Context("when target is nil", func() { + It("should return an error", func() { + current := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: namespace}, + } + changed, err := updateApplication(argocdclient, nil, current, namespace) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("target application was nil")) + Expect(changed).To(BeFalse()) + }) + }) + + Context("when applications are identical", func() { + It("should not update and return false", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: namespace}, + Spec: argoapi.ApplicationSpec{ + Source: &argoapi.ApplicationSource{ + RepoURL: "https://example.com/repo", + }, + }, + } + _, err := argocdclient.ArgoprojV1alpha1().Applications(namespace).Create(context.Background(), app, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + changed, err := updateApplication(argocdclient, app, app.DeepCopy(), namespace) + Expect(err).ToNot(HaveOccurred()) + Expect(changed).To(BeFalse()) + }) + }) + + Context("when applications differ", func() { + It("should update and return true", func() { + current := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: namespace}, + Spec: argoapi.ApplicationSpec{ + Source: &argoapi.ApplicationSource{ + RepoURL: "https://example.com/repo-old", + }, + }, + } + _, err := argocdclient.ArgoprojV1alpha1().Applications(namespace).Create(context.Background(), current, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + target := current.DeepCopy() + target.Spec.Source.RepoURL = "https://example.com/repo-new" + + changed, err := updateApplication(argocdclient, target, current, namespace) + Expect(err).ToNot(HaveOccurred()) + Expect(changed).To(BeTrue()) + }) + }) +}) + +var _ = Describe("SyncApplication", func() { + var ( + argocdclient *argoclient.Clientset + namespace string + ) + + BeforeEach(func() { + argocdclient = argoclient.NewSimpleClientset() + namespace = "default" + }) + + Context("when sync is already in progress with same options", func() { + It("should return nil", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: namespace}, + Operation: &argoapi.Operation{ + Sync: &argoapi.SyncOperation{ + Prune: true, + SyncOptions: []string{"Force=true"}, + }, + }, + } + err := syncApplication(argocdclient, app, true) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("when no sync is in progress", func() { + It("should set sync operation and update", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: namespace}, + } + _, err := argocdclient.ArgoprojV1alpha1().Applications(namespace).Create(context.Background(), app, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + err = syncApplication(argocdclient, app, true) + Expect(err).ToNot(HaveOccurred()) + Expect(app.Operation).ToNot(BeNil()) + Expect(app.Operation.Sync.Prune).To(BeTrue()) + Expect(app.Operation.Sync.SyncOptions).To(ContainElement("Force=true")) + }) + }) + + Context("when sync without prune", func() { + It("should set prune to false", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: namespace}, + } + _, err := argocdclient.ArgoprojV1alpha1().Applications(namespace).Create(context.Background(), app, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + err = syncApplication(argocdclient, app, false) + Expect(err).ToNot(HaveOccurred()) + Expect(app.Operation.Sync.Prune).To(BeFalse()) + }) + }) + + Context("when update fails", func() { + BeforeEach(func() { + argocdclient.PrependReactor("update", "applications", func(testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, fmt.Errorf("update error") + }) + }) + + It("should return the error", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: namespace}, + } + err := syncApplication(argocdclient, app, true) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to sync application")) + }) + }) +}) + +var _ = Describe("GetChildApplications", func() { + var ( + argocdclient *argoclient.Clientset + namespace string + ) + + BeforeEach(func() { + argocdclient = argoclient.NewSimpleClientset() + namespace = "default" + }) + + Context("when there are child applications", func() { + It("should return them", func() { + parentApp := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "parent-app", Namespace: namespace}, + } + + childApp := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "child-app", + Namespace: namespace, + Labels: map[string]string{"app.kubernetes.io/instance": "parent-app"}, + }, + } + _, err := argocdclient.ArgoprojV1alpha1().Applications(namespace).Create(context.Background(), childApp, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + children, err := getChildApplications(argocdclient, parentApp) + Expect(err).ToNot(HaveOccurred()) + Expect(children).To(HaveLen(1)) + Expect(children[0].Name).To(Equal("child-app")) + }) + }) + + Context("when there are no child applications", func() { + It("should return empty list", func() { + parentApp := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "parent-app", Namespace: namespace}, + } + + children, err := getChildApplications(argocdclient, parentApp) + Expect(err).ToNot(HaveOccurred()) + Expect(children).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("NewArgoGiteaApplication", func() { + var pattern *api.Pattern + BeforeEach(func() { + tmpFalse := false + PatternsOperatorConfig = DefaultPatternOperatorConfig + pattern = &api.Pattern{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pattern", Namespace: defaultNamespace}, + TypeMeta: metav1.TypeMeta{Kind: "Pattern", APIVersion: api.GroupVersion.String()}, + Spec: api.PatternSpec{ + ClusterGroupName: "foogroup", + GitConfig: api.GitConfig{ + TargetRepo: "https://github.com/validatedpatterns/multicloud-gitops", + TargetRevision: "main", + }, + MultiSourceConfig: api.MultiSourceConfig{ + Enabled: &tmpFalse, + }, + GitOpsConfig: &api.GitOpsConfig{ + ManualSync: false, + }, + }, + Status: api.PatternStatus{ + AppClusterDomain: "apps.hub-cluster.validatedpatterns.io", + ClusterDomain: "hub-cluster.validatedpatterns.io", + }, + } + }) + + It("should create a gitea application with correct properties", func() { + app := newArgoGiteaApplication(pattern) + Expect(app).ToNot(BeNil()) + Expect(app.Name).To(Equal(GiteaApplicationName)) + Expect(app.Namespace).To(Equal(getClusterWideArgoNamespace())) + Expect(app.Labels["validatedpatterns.io/pattern"]).To(Equal("test-pattern")) + Expect(app.Spec.Destination.Name).To(Equal("in-cluster")) + Expect(app.Spec.Destination.Namespace).To(Equal(GiteaNamespace)) + Expect(app.Spec.Project).To(Equal("default")) + Expect(app.Spec.Source).ToNot(BeNil()) + Expect(controllerutil.ContainsFinalizer(app, argoapi.ForegroundPropagationPolicyFinalizer)).To(BeTrue()) + + // Check helm parameters + Expect(app.Spec.Source.Helm).ToNot(BeNil()) + Expect(app.Spec.Source.Helm.Parameters).To(HaveLen(3)) + + foundAdminSecret := false + for _, p := range app.Spec.Source.Helm.Parameters { + if p.Name == "gitea.admin.existingSecret" { + foundAdminSecret = true + Expect(p.Value).To(Equal(GiteaAdminSecretName)) + } + } + Expect(foundAdminSecret).To(BeTrue()) + }) +}) + +var _ = Describe("CommonSyncPolicy", func() { + var pattern *api.Pattern + BeforeEach(func() { + tmpFalse := false + pattern = &api.Pattern{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pattern", Namespace: defaultNamespace}, + Spec: api.PatternSpec{ + MultiSourceConfig: api.MultiSourceConfig{ + Enabled: &tmpFalse, + }, + GitOpsConfig: &api.GitOpsConfig{ + ManualSync: false, + }, + }, + } + }) + + Context("when pattern is not being deleted and manualSync is false", func() { + It("should return automated sync policy", func() { + policy := commonSyncPolicy(pattern) + Expect(policy).ToNot(BeNil()) + Expect(policy.Automated).ToNot(BeNil()) + Expect(policy.Automated.Prune).To(BeFalse()) + }) + }) + + Context("when pattern is not being deleted and manualSync is true", func() { + It("should return nil sync policy", func() { + pattern.Spec.GitOpsConfig.ManualSync = true + policy := commonSyncPolicy(pattern) + Expect(policy).To(BeNil()) + }) + }) + + Context("when pattern is being deleted", func() { + It("should return sync policy with prune enabled", func() { + now := metav1.Now() + pattern.DeletionTimestamp = &now + policy := commonSyncPolicy(pattern) + Expect(policy).ToNot(BeNil()) + Expect(policy.Automated).ToNot(BeNil()) + Expect(policy.Automated.Prune).To(BeTrue()) + Expect(policy.SyncOptions).To(ContainElement("Prune=true")) + }) + }) +}) + +var _ = Describe("NewApplicationParameters with deletion", func() { + var pattern *api.Pattern + BeforeEach(func() { + tmpFalse := false + pattern = &api.Pattern{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pattern", Namespace: defaultNamespace}, + Spec: api.PatternSpec{ + ClusterGroupName: "foogroup", + GitConfig: api.GitConfig{ + TargetRepo: "https://github.com/validatedpatterns/multicloud-gitops", + TargetRevision: "main", + }, + MultiSourceConfig: api.MultiSourceConfig{ + Enabled: &tmpFalse, + HelmRepoUrl: "https://charts.validatedpatterns.io/", + ClusterGroupChartVersion: "0.0.*", + }, + }, + Status: api.PatternStatus{ + ClusterPlatform: "AWS", + ClusterVersion: "4.12", + ClusterName: "barcluster", + AppClusterDomain: "apps.hub-cluster.validatedpatterns.io", + ClusterDomain: "hub-cluster.validatedpatterns.io", + }, + } + }) + + Context("when pattern is being deleted with DeleteSpokeChildApps phase", func() { + It("should include deletePattern parameter with phase value", func() { + now := metav1.Now() + pattern.DeletionTimestamp = &now + pattern.Status.DeletionPhase = api.DeleteSpokeChildApps + params := newApplicationParameters(pattern) + foundDelete := false + for _, p := range params { + if p.Name == "global.deletePattern" { + foundDelete = true + Expect(p.Value).To(Equal(string(api.DeleteSpokeChildApps))) + Expect(p.ForceString).To(BeTrue()) + } + } + Expect(foundDelete).To(BeTrue()) + }) + }) + + Context("when pattern is being deleted with DeleteHubChildApps phase", func() { + It("should include deletePattern=DeleteChildApps", func() { + now := metav1.Now() + pattern.DeletionTimestamp = &now + pattern.Status.DeletionPhase = api.DeleteHubChildApps + params := newApplicationParameters(pattern) + foundDelete := false + for _, p := range params { + if p.Name == "global.deletePattern" { + foundDelete = true + Expect(p.Value).To(Equal("DeleteChildApps")) + Expect(p.ForceString).To(BeTrue()) + } + } + Expect(foundDelete).To(BeTrue()) + }) + }) +}) + +var _ = Describe("CompareSource edge cases", func() { + Context("when both sources have nil Helm", func() { + It("should return true for otherwise identical sources", func() { + source := &argoapi.ApplicationSource{ + RepoURL: "https://example.com/repo", + TargetRevision: "main", + Path: "path", + } + Expect(compareSource(source, source)).To(BeTrue()) + }) + }) + + Context("when only one source has Helm set", func() { + It("should return false when goal has Helm but actual does not", func() { + goal := &argoapi.ApplicationSource{ + RepoURL: "https://example.com/repo", + TargetRevision: "main", + Path: "path", + Helm: &argoapi.ApplicationSourceHelm{}, + } + actual := &argoapi.ApplicationSource{ + RepoURL: "https://example.com/repo", + TargetRevision: "main", + Path: "path", + } + Expect(compareSource(goal, actual)).To(BeFalse()) + }) + + It("should return false when actual has Helm but goal does not", func() { + goal := &argoapi.ApplicationSource{ + RepoURL: "https://example.com/repo", + TargetRevision: "main", + Path: "path", + } + actual := &argoapi.ApplicationSource{ + RepoURL: "https://example.com/repo", + TargetRevision: "main", + Path: "path", + Helm: &argoapi.ApplicationSourceHelm{}, + } + Expect(compareSource(goal, actual)).To(BeFalse()) + }) + }) + + Context("when RepoURL differs", func() { + It("should return false", func() { + goal := &argoapi.ApplicationSource{RepoURL: "https://a.com"} + actual := &argoapi.ApplicationSource{RepoURL: "https://b.com"} + Expect(compareSource(goal, actual)).To(BeFalse()) + }) + }) + + Context("when TargetRevision differs", func() { + It("should return false", func() { + goal := &argoapi.ApplicationSource{RepoURL: "https://a.com", TargetRevision: "v1"} + actual := &argoapi.ApplicationSource{RepoURL: "https://a.com", TargetRevision: "v2"} + Expect(compareSource(goal, actual)).To(BeFalse()) + }) + }) +}) + +var _ = Describe("CompareHelmParameters edge cases", func() { + Context("when both are nil", func() { + It("should return true", func() { + Expect(compareHelmParameters(nil, nil)).To(BeTrue()) + }) + }) + + Context("when one is nil", func() { + It("should return false when goal is nil", func() { + params := []argoapi.HelmParameter{{Name: "key", Value: "val"}} + Expect(compareHelmParameters(nil, params)).To(BeFalse()) + }) + It("should return false when actual is nil", func() { + params := []argoapi.HelmParameter{{Name: "key", Value: "val"}} + Expect(compareHelmParameters(params, nil)).To(BeFalse()) + }) + }) + + Context("when ForceString differs", func() { + It("should return false", func() { + goal := []argoapi.HelmParameter{{Name: "key", Value: "val", ForceString: true}} + actual := []argoapi.HelmParameter{{Name: "key", Value: "val", ForceString: false}} + Expect(compareHelmParameters(goal, actual)).To(BeFalse()) + }) + }) + + Context("when values differ", func() { + It("should return false", func() { + goal := []argoapi.HelmParameter{{Name: "key", Value: "val1"}} + actual := []argoapi.HelmParameter{{Name: "key", Value: "val2"}} + Expect(compareHelmParameters(goal, actual)).To(BeFalse()) + }) + }) +}) + +var _ = Describe("GetClusterGroupChartVersion", func() { + var pattern *api.Pattern + BeforeEach(func() { + tmpFalse := false + pattern = &api.Pattern{ + Spec: api.PatternSpec{ + MultiSourceConfig: api.MultiSourceConfig{ + Enabled: &tmpFalse, + }, + }, + } + }) + + Context("when ClusterGroupChartVersion is explicitly set", func() { + It("should return the explicit version", func() { + pattern.Spec.MultiSourceConfig.ClusterGroupChartVersion = "1.2.3" + Expect(getClusterGroupChartVersion(pattern)).To(Equal("1.2.3")) + }) + }) + + Context("when ClusterGroupChartVersion is not set and common is slimmed", func() { + It("should return 0.9.*", func() { + // No operator-install directory means it's slimmed + pattern.Status.LocalCheckoutPath = "/nonexistent/path" + Expect(getClusterGroupChartVersion(pattern)).To(Equal("0.9.*")) + }) + }) + + Context("when ClusterGroupChartVersion is not set and common is not slimmed", func() { + It("should return 0.8.*", func() { + td := createTempDir("vp-version-test") + defer cleanupTempDir(td) + + // Create common/operator-install to indicate non-slimmed + err := os.MkdirAll(filepath.Join(td, "common", "operator-install"), 0755) + Expect(err).ToNot(HaveOccurred()) + + pattern.Status.LocalCheckoutPath = td + Expect(getClusterGroupChartVersion(pattern)).To(Equal("0.8.*")) + }) + }) +}) + +var _ = Describe("GetSharedValueFiles", func() { + var pattern *api.Pattern + var td string + BeforeEach(func() { + td = createTempDir("vp-shared-test") + tmpFalse := false + pattern = &api.Pattern{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pattern", Namespace: defaultNamespace}, + Spec: api.PatternSpec{ + ClusterGroupName: "foogroup", + GitConfig: api.GitConfig{ + TargetRepo: "https://github.com/validatedpatterns/test", + TargetRevision: "main", + }, + MultiSourceConfig: api.MultiSourceConfig{ + Enabled: &tmpFalse, + }, + }, + Status: api.PatternStatus{ + ClusterPlatform: "AWS", + ClusterVersion: "4.12", + ClusterName: "barcluster", + AppClusterDomain: "apps.hub.example.com", + ClusterDomain: "hub.example.com", + LocalCheckoutPath: td, + }, + } + }) + AfterEach(func() { + cleanupTempDir(td) + }) + + Context("when path does not exist", func() { + It("should return an error", func() { + pattern.Status.LocalCheckoutPath = "/nonexistent/path" + _, err := getSharedValueFiles(pattern, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("path does not exist")) + }) + }) + + Context("when there are no sharedValueFiles in the values", func() { + It("should return nil", func() { + // Create empty values file + err := os.WriteFile(filepath.Join(td, "values-global.yaml"), []byte("key: value\n"), 0600) + Expect(err).ToNot(HaveOccurred()) + + result, err := getSharedValueFiles(pattern, "") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) + + Context("when sharedValueFiles has entries", func() { + It("should return the value file paths", func() { + yamlContent := `clusterGroup: + sharedValueFiles: + - /values-shared.yaml +` + err := os.WriteFile(filepath.Join(td, "values-global.yaml"), []byte(yamlContent), 0600) + Expect(err).ToNot(HaveOccurred()) + + result, err := getSharedValueFiles(pattern, "") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0]).To(ContainSubstring("values-shared.yaml")) + }) + }) +}) + +var _ = Describe("CountVPApplications", func() { + var pattern *api.Pattern + var td string + BeforeEach(func() { + td = createTempDir("vp-count-test") + tmpFalse := false + pattern = &api.Pattern{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pattern", Namespace: defaultNamespace}, + Spec: api.PatternSpec{ + ClusterGroupName: "foogroup", + GitConfig: api.GitConfig{ + TargetRepo: "https://github.com/validatedpatterns/test", + TargetRevision: "main", + }, + MultiSourceConfig: api.MultiSourceConfig{ + Enabled: &tmpFalse, + }, + }, + Status: api.PatternStatus{ + ClusterPlatform: "AWS", + ClusterVersion: "4.12", + ClusterName: "barcluster", + AppClusterDomain: "apps.hub.example.com", + ClusterDomain: "hub.example.com", + LocalCheckoutPath: td, + }, + } + }) + AfterEach(func() { + cleanupTempDir(td) + }) + + Context("when path does not exist", func() { + It("should return error", func() { + pattern.Status.LocalCheckoutPath = "/nonexistent/path" + apps, appsets, err := countVPApplications(pattern) + Expect(err).To(HaveOccurred()) + Expect(apps).To(Equal(-1)) + Expect(appsets).To(Equal(-1)) + }) + }) + + Context("when there are no applications defined", func() { + It("should return 0, 0", func() { + err := os.WriteFile(filepath.Join(td, "values-global.yaml"), []byte("key: value\n"), 0600) + Expect(err).ToNot(HaveOccurred()) + + apps, appsets, err := countVPApplications(pattern) + Expect(err).ToNot(HaveOccurred()) + Expect(apps).To(Equal(0)) + Expect(appsets).To(Equal(0)) + }) + }) + + Context("when there are applications defined", func() { + It("should count them", func() { + yamlContent := `clusterGroup: + applications: + vault: + name: vault + golang-external-secrets: + name: golang-external-secrets + acm: + generators: + - generator1 +` + err := os.WriteFile(filepath.Join(td, "values-global.yaml"), []byte(yamlContent), 0600) + Expect(err).ToNot(HaveOccurred()) + + apps, appsets, err := countVPApplications(pattern) + Expect(err).ToNot(HaveOccurred()) + Expect(apps).To(Equal(2)) + Expect(appsets).To(Equal(1)) + }) + }) +}) + var _ = Describe("ConvertArgoHelmParametersToMap", func() { Context("when the parameters list is empty", func() { It("should return an empty map", func() { @@ -1032,3 +1851,377 @@ var _ = Describe("ConvertArgoHelmParametersToMap", func() { }) }) }) + +var _ = Describe("newApplicationValues", func() { + It("should return extraParametersNested YAML with all extra parameters", func() { + pattern := &api.Pattern{ + Spec: api.PatternSpec{ + ExtraParameters: []api.PatternParameter{ + {Name: "global.extraParam1", Value: "extraValue1"}, + {Name: "global.extraParam2", Value: "extraValue2"}, + }, + }, + } + result := newApplicationValues(pattern) + Expect(result).To(ContainSubstring("extraParametersNested:")) + Expect(result).To(ContainSubstring("global.extraParam1: extraValue1")) + Expect(result).To(ContainSubstring("global.extraParam2: extraValue2")) + }) + + It("should return only the header when no extra parameters exist", func() { + pattern := &api.Pattern{ + Spec: api.PatternSpec{ + ExtraParameters: []api.PatternParameter{}, + }, + } + result := newApplicationValues(pattern) + Expect(result).To(Equal("extraParametersNested:\n")) + }) + + It("should handle a single extra parameter", func() { + pattern := &api.Pattern{ + Spec: api.PatternSpec{ + ExtraParameters: []api.PatternParameter{ + {Name: "key", Value: "value"}, + }, + }, + } + result := newApplicationValues(pattern) + Expect(result).To(Equal("extraParametersNested:\n key: value\n")) + }) +}) + +var _ = Describe("removeApplication", func() { + Context("when the application exists", func() { + It("should delete the application without error", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "openshift-gitops", + }, + } + argoClient := argoclient.NewSimpleClientset(app) + + err := removeApplication(argoClient, "test-app", "openshift-gitops") + Expect(err).ToNot(HaveOccurred()) + + // Verify the application is gone + _, err = argoClient.ArgoprojV1alpha1().Applications("openshift-gitops").Get( + context.Background(), "test-app", metav1.GetOptions{}) + Expect(err).To(HaveOccurred()) + Expect(kerrors.IsNotFound(err)).To(BeTrue()) + }) + }) + + Context("when the application does not exist", func() { + It("should return an error", func() { + argoClient := argoclient.NewSimpleClientset() + err := removeApplication(argoClient, "nonexistent", "openshift-gitops") + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("newArgoGiteaApplication", func() { + var pattern *api.Pattern + + BeforeEach(func() { + tmpFalse := false + pattern = &api.Pattern{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pattern", + Namespace: "default", + }, + Spec: api.PatternSpec{ + ClusterGroupName: "hub", + GitConfig: api.GitConfig{ + TargetRepo: "https://github.com/test/repo", + TargetRevision: "main", + }, + GitOpsConfig: &api.GitOpsConfig{ + ManualSync: false, + }, + MultiSourceConfig: api.MultiSourceConfig{ + Enabled: &tmpFalse, + }, + }, + Status: api.PatternStatus{ + AppClusterDomain: "apps.example.com", + ClusterPlatform: "AWS", + ClusterVersion: "4.14.0", + }, + } + PatternsOperatorConfig = GitOpsConfig{} + }) + + It("should create the Gitea application with correct name", func() { + app := newArgoGiteaApplication(pattern) + Expect(app.Name).To(Equal(GiteaApplicationName)) + Expect(app.Namespace).To(Equal(getClusterWideArgoNamespace())) + }) + + It("should set the pattern label", func() { + app := newArgoGiteaApplication(pattern) + Expect(app.Labels).To(HaveKeyWithValue("validatedpatterns.io/pattern", "test-pattern")) + }) + + It("should set the destination namespace to GiteaNamespace", func() { + app := newArgoGiteaApplication(pattern) + Expect(app.Spec.Destination.Namespace).To(Equal(GiteaNamespace)) + }) + + It("should set destination to in-cluster", func() { + app := newArgoGiteaApplication(pattern) + Expect(app.Spec.Destination.Name).To(Equal("in-cluster")) + }) + + It("should set the project to default", func() { + app := newArgoGiteaApplication(pattern) + Expect(app.Spec.Project).To(Equal("default")) + }) + + It("should include helm parameters for gitea admin secret and console href", func() { + app := newArgoGiteaApplication(pattern) + Expect(app.Spec.Source).ToNot(BeNil()) + Expect(app.Spec.Source.Helm).ToNot(BeNil()) + + params := app.Spec.Source.Helm.Parameters + paramMap := make(map[string]string) + for _, p := range params { + paramMap[p.Name] = p.Value + } + Expect(paramMap).To(HaveKeyWithValue("gitea.admin.existingSecret", GiteaAdminSecretName)) + Expect(paramMap).To(HaveKey("gitea.console.href")) + Expect(paramMap["gitea.console.href"]).To(ContainSubstring("apps.example.com")) + Expect(paramMap).To(HaveKey("gitea.config.server.ROOT_URL")) + }) + + It("should have the foreground propagation finalizer", func() { + app := newArgoGiteaApplication(pattern) + Expect(controllerutil.ContainsFinalizer(app, argoapi.ForegroundPropagationPolicyFinalizer)).To(BeTrue()) + }) + + It("should set a sync policy when not manual sync", func() { + app := newArgoGiteaApplication(pattern) + Expect(app.Spec.SyncPolicy).ToNot(BeNil()) + Expect(app.Spec.SyncPolicy.Automated).ToNot(BeNil()) + }) + + It("should have nil sync policy when manual sync is enabled", func() { + pattern.Spec.GitOpsConfig.ManualSync = true + app := newArgoGiteaApplication(pattern) + Expect(app.Spec.SyncPolicy).To(BeNil()) + }) +}) + +var _ = Describe("newArgoCD", func() { + It("should create an ArgoCD with the correct name and namespace", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Name).To(Equal("test-argo")) + Expect(argo.Namespace).To(Equal("test-ns")) + }) + + It("should have the argoproj.io/finalizer", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Finalizers).To(ContainElement("argoproj.io/finalizer")) + }) + + It("should have HA disabled", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Spec.HA.Enabled).To(BeFalse()) + }) + + It("should have monitoring disabled", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Spec.Monitoring.Enabled).To(BeFalse()) + }) + + It("should have notifications disabled", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Spec.Notifications.Enabled).To(BeFalse()) + }) + + It("should have SSO configured with Dex provider", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Spec.SSO).ToNot(BeNil()) + Expect(argo.Spec.SSO.Provider).To(Equal(argooperator.SSOProviderTypeDex)) + Expect(argo.Spec.SSO.Dex).ToNot(BeNil()) + Expect(argo.Spec.SSO.Dex.OpenShiftOAuth).To(BeTrue()) + }) + + It("should have server route enabled with reencrypt TLS", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Spec.Server.Route.Enabled).To(BeTrue()) + Expect(argo.Spec.Server.Route.TLS).ToNot(BeNil()) + Expect(argo.Spec.Server.Route.TLS.Termination).To(Equal(routev1.TLSTerminationReencrypt)) + }) + + It("should have resource exclusions for tekton", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Spec.ResourceExclusions).To(ContainSubstring("tekton.dev")) + Expect(argo.Spec.ResourceExclusions).To(ContainSubstring("TaskRun")) + Expect(argo.Spec.ResourceExclusions).To(ContainSubstring("PipelineRun")) + }) + + It("should have resource health checks for Subscription", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Spec.ResourceHealthChecks).To(HaveLen(1)) + Expect(argo.Spec.ResourceHealthChecks[0].Group).To(Equal("operators.coreos.com")) + Expect(argo.Spec.ResourceHealthChecks[0].Kind).To(Equal("Subscription")) + }) + + It("should have init containers for CA cert fetching", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Spec.Repo.InitContainers).To(HaveLen(1)) + Expect(argo.Spec.Repo.InitContainers[0].Name).To(Equal("fetch-ca")) + }) + + It("should have correct RBAC policy", func() { + argo := newArgoCD("test-argo", "test-ns") + Expect(argo.Spec.RBAC.Policy).ToNot(BeNil()) + Expect(*argo.Spec.RBAC.Policy).To(ContainSubstring("cluster-admins")) + }) +}) + +var _ = Describe("commonSyncPolicy", func() { + It("should return automated sync policy when not deleting and not manual", func() { + pattern := &api.Pattern{ + Spec: api.PatternSpec{ + GitOpsConfig: &api.GitOpsConfig{ManualSync: false}, + }, + } + policy := commonSyncPolicy(pattern) + Expect(policy).ToNot(BeNil()) + Expect(policy.Automated).ToNot(BeNil()) + Expect(policy.Automated.Prune).To(BeFalse()) + }) + + It("should return nil sync policy when manual sync is enabled", func() { + pattern := &api.Pattern{ + Spec: api.PatternSpec{ + GitOpsConfig: &api.GitOpsConfig{ManualSync: true}, + }, + } + policy := commonSyncPolicy(pattern) + Expect(policy).To(BeNil()) + }) +}) + +var _ = Describe("applicationName", func() { + It("should return pattern name combined with cluster group name", func() { + pattern := &api.Pattern{ + ObjectMeta: metav1.ObjectMeta{Name: "my-pattern"}, + Spec: api.PatternSpec{ClusterGroupName: "hub"}, + } + Expect(applicationName(pattern)).To(Equal("my-pattern-hub")) + }) + + It("should handle different cluster group names", func() { + pattern := &api.Pattern{ + ObjectMeta: metav1.ObjectMeta{Name: "industrial-edge"}, + Spec: api.PatternSpec{ClusterGroupName: "factory"}, + } + Expect(applicationName(pattern)).To(Equal("industrial-edge-factory")) + }) +}) + +var _ = Describe("syncApplication", func() { + Context("when no sync is in progress", func() { + It("should set the sync operation with prune", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "openshift-gitops", + }, + } + argoFakeClient := argoclient.NewSimpleClientset(app) + + err := syncApplication(argoFakeClient, app, true) + Expect(err).ToNot(HaveOccurred()) + Expect(app.Operation).ToNot(BeNil()) + Expect(app.Operation.Sync).ToNot(BeNil()) + Expect(app.Operation.Sync.Prune).To(BeTrue()) + Expect(app.Operation.Sync.SyncOptions).To(ContainElement("Force=true")) + }) + + It("should set the sync operation without prune", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "openshift-gitops", + }, + } + argoFakeClient := argoclient.NewSimpleClientset(app) + + err := syncApplication(argoFakeClient, app, false) + Expect(err).ToNot(HaveOccurred()) + Expect(app.Operation).ToNot(BeNil()) + Expect(app.Operation.Sync.Prune).To(BeFalse()) + }) + }) + + Context("when a matching sync is already in progress", func() { + It("should return nil without updating", func() { + app := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "openshift-gitops", + }, + Operation: &argoapi.Operation{ + Sync: &argoapi.SyncOperation{ + Prune: true, + SyncOptions: []string{"Force=true"}, + }, + }, + } + argoFakeClient := argoclient.NewSimpleClientset(app) + + err := syncApplication(argoFakeClient, app, true) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) + +var _ = Describe("getChildApplications", func() { + Context("when child applications exist", func() { + It("should return the child applications", func() { + parentApp := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent-app", + Namespace: "openshift-gitops", + }, + } + childApp := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "child-app", + Namespace: "openshift-gitops", + Labels: map[string]string{ + "app.kubernetes.io/instance": "parent-app", + }, + }, + } + argoFakeClient := argoclient.NewSimpleClientset(parentApp, childApp) + + children, err := getChildApplications(argoFakeClient, parentApp) + Expect(err).ToNot(HaveOccurred()) + Expect(children).To(HaveLen(1)) + Expect(children[0].Name).To(Equal("child-app")) + }) + }) + + Context("when no child applications exist", func() { + It("should return an empty list", func() { + parentApp := &argoapi.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent-app", + Namespace: "openshift-gitops", + }, + } + argoFakeClient := argoclient.NewSimpleClientset(parentApp) + + children, err := getChildApplications(argoFakeClient, parentApp) + Expect(err).ToNot(HaveOccurred()) + Expect(children).To(BeEmpty()) + }) + }) +}) diff --git a/internal/controller/checkout_test.go b/internal/controller/checkout_test.go index 9ff61481b..71df15396 100644 --- a/internal/controller/checkout_test.go +++ b/internal/controller/checkout_test.go @@ -144,3 +144,377 @@ func createTestTag(repo *git.Repository, commitHash plumbing.Hash, tagName strin _, err := repo.CreateTag(tagName, commitHash, nil) return err } + +var _ = Describe("getUserFromURL", func() { + Context("with SSH URL", func() { + It("should return the user from git@github.com:user/repo", func() { + Expect(getUserFromURL("git@github.com:user/repo")).To(Equal("git")) + }) + }) + + Context("with custom user in SSH URL", func() { + It("should return the user from customuser@gitlab.com:user/repo", func() { + Expect(getUserFromURL("customuser@gitlab.com:user/repo")).To(Equal("customuser")) + }) + }) + + Context("with HTTPS URL", func() { + It("should return empty string", func() { + Expect(getUserFromURL("https://github.com/user/repo")).To(Equal("")) + }) + }) + + Context("with plain URL", func() { + It("should return empty string", func() { + Expect(getUserFromURL("github.com/user/repo")).To(Equal("")) + }) + }) +}) + +var _ = Describe("getLocalGitPath", func() { + It("should return a valid path for HTTPS URLs", func() { + result := getLocalGitPath("https://github.com/user/repo") + Expect(result).ToNot(BeEmpty()) + Expect(result).To(ContainSubstring(VPTmpFolder)) + }) + + It("should return a valid path for SSH URLs", func() { + result := getLocalGitPath("git@github.com:user/repo.git") + Expect(result).ToNot(BeEmpty()) + Expect(result).To(ContainSubstring(VPTmpFolder)) + }) + + It("should return different paths for different repos", func() { + path1 := getLocalGitPath("https://github.com/user/repo1") + path2 := getLocalGitPath("https://github.com/user/repo2") + Expect(path1).ToNot(Equal(path2)) + }) +}) + +var _ = Describe("detectGitAuthType", func() { + Context("with SSH secret", func() { + It("should return GitAuthSsh", func() { + secret := map[string][]byte{ + "sshPrivateKey": []byte("private-key-data"), + } + Expect(detectGitAuthType(secret)).To(Equal(GitAuthSsh)) + }) + }) + + Context("with password secret", func() { + It("should return GitAuthPassword", func() { + secret := map[string][]byte{ + "username": []byte("user"), + "password": []byte("pass"), + } + Expect(detectGitAuthType(secret)).To(Equal(GitAuthPassword)) + }) + }) + + Context("with GitHub App secret", func() { + It("should return GitAuthGitHubApp", func() { + secret := map[string][]byte{ + "githubAppID": []byte("12345"), + "githubAppInstallationID": []byte("67890"), + "githubAppPrivateKey": []byte("key-data"), + } + Expect(detectGitAuthType(secret)).To(Equal(GitAuthGitHubApp)) + }) + }) + + Context("with empty secret", func() { + It("should return GitAuthNone", func() { + secret := map[string][]byte{} + Expect(detectGitAuthType(secret)).To(Equal(GitAuthNone)) + }) + }) + + Context("with nil secret", func() { + It("should return GitAuthNone", func() { + var secret map[string][]byte + Expect(detectGitAuthType(secret)).To(Equal(GitAuthNone)) + }) + }) + + Context("with partial password secret (missing password)", func() { + It("should return GitAuthNone", func() { + secret := map[string][]byte{ + "username": []byte("user"), + } + Expect(detectGitAuthType(secret)).To(Equal(GitAuthNone)) + }) + }) +}) + +var _ = Describe("getField", func() { + Context("when the field exists", func() { + It("should return the value", func() { + secret := map[string][]byte{ + "username": []byte("testuser"), + } + Expect(string(getField(secret, "username"))).To(Equal("testuser")) + }) + }) + + Context("when the field does not exist", func() { + It("should return nil", func() { + secret := map[string][]byte{ + "username": []byte("testuser"), + } + Expect(getField(secret, "nonexistent")).To(BeNil()) + }) + }) +}) + +var _ = Describe("getHttpAuth", func() { + It("should return BasicAuth with correct credentials", func() { + secret := map[string][]byte{ + "username": []byte("myuser"), + "password": []byte("mypass"), + } + auth := getHttpAuth(secret) + Expect(auth).ToNot(BeNil()) + Expect(auth.Username).To(Equal("myuser")) + Expect(auth.Password).To(Equal("mypass")) + }) +}) + +var _ = Describe("getFetchOptions", func() { + Context("with no authentication", func() { + It("should return options without auth", func() { + opts, err := getFetchOptions(nil, "https://github.com/user/repo", nil) + Expect(err).ToNot(HaveOccurred()) + Expect(opts).ToNot(BeNil()) + Expect(opts.RemoteName).To(Equal("origin")) + Expect(opts.Force).To(BeTrue()) + Expect(opts.Auth).To(BeNil()) + }) + }) + + Context("with password authentication", func() { + It("should return options with basic auth", func() { + secret := map[string][]byte{ + "username": []byte("user"), + "password": []byte("pass"), + } + opts, err := getFetchOptions(nil, "https://github.com/user/repo", secret) + Expect(err).ToNot(HaveOccurred()) + Expect(opts.Auth).ToNot(BeNil()) + }) + }) +}) + +var _ = Describe("getCloneOptions", func() { + Context("with no authentication", func() { + It("should return options without auth", func() { + opts, err := getCloneOptions(nil, "https://github.com/user/repo", nil) + Expect(err).ToNot(HaveOccurred()) + Expect(opts).ToNot(BeNil()) + Expect(opts.URL).To(Equal("https://github.com/user/repo")) + Expect(opts.RemoteName).To(Equal("origin")) + Expect(opts.Auth).To(BeNil()) + }) + }) + + Context("with password authentication", func() { + It("should return options with basic auth", func() { + secret := map[string][]byte{ + "username": []byte("user"), + "password": []byte("pass"), + } + opts, err := getCloneOptions(nil, "https://github.com/user/repo", secret) + Expect(err).ToNot(HaveOccurred()) + Expect(opts.Auth).ToNot(BeNil()) + }) + }) + + Context("with SSH authentication and invalid key", func() { + It("should return an error", func() { + secret := map[string][]byte{ + "sshPrivateKey": []byte("invalid-key"), + } + _, err := getCloneOptions(nil, "git@github.com:user/repo", secret) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("with SSH authentication and missing key", func() { + It("should return an error for missing sshPrivateKey", func() { + secret := map[string][]byte{ + "sshPrivateKey": nil, + } + _, err := getCloneOptions(nil, "git@github.com:user/repo", secret) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("could not get sshPrivateKey")) + }) + }) + + Context("with GitHub App authentication and missing fields", func() { + It("should return an error for invalid app credentials", func() { + secret := map[string][]byte{ + "githubAppID": []byte("notanumber"), + "githubAppInstallationID": []byte("12345"), + "githubAppPrivateKey": []byte("invalid-key"), + } + _, err := getCloneOptions(nil, "https://github.com/user/repo", secret) + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("getSshPublicKey", func() { + Context("with valid SSH key", func() { + It("should return public keys", func() { + testKey := []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n" + + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\n" + + "NhAAAAAwEAAQAAAQEAoRkE3Prx0r2BD4F1MY8xw1Sv6y1/L1bnt5S7CtrBXtQ4bpVzuuqs\n" + + "CxCzhhW/qf59LoEFJU3qpU934Ss1hKFFwFD4ad20rBSJun6h0dLjPE65h11WlNz9DtbTmk\n" + + "EnC4vGRcVBRDyZ8Nk1IkOs57kGMDd3R+kYSWjVH34MXZGq3LPCTbXRZUk+KXAjUcyjqJYa\n" + + "rld/bAlNdZ1a3QY2Osb38T2BHdl5FjRV3o5u5449v3HlA4ky/yizSkb5f/6ihQXLMIEfq6\n" + + "UI2ycvMjPxEkgcI1acGukljFRtePTXIYqPaMWV8qSFGaDXrHvpYOS32jcfIjDoDrN5yTXr\n" + + "zSZ+OMshlwAAA8gUsDamFLA2pgAAAAdzc2gtcnNhAAABAQChGQTc+vHSvYEPgXUxjzHDVK\n" + + "/rLX8vVue3lLsK2sFe1DhulXO66qwLELOGFb+p/n0ugQUlTeqlT3fhKzWEoUXAUPhp3bSs\n" + + "FIm6fqHR0uM8TrmHXVaU3P0O1tOaQScLi8ZFxUFEPJnw2TUiQ6znuQYwN3dH6RhJaNUffg\n" + + "xdkarcs8JNtdFlST4pcCNRzKOolhquV39sCU11nVrdBjY6xvfxPYEd2XkWNFXejm7njj2/\n" + + "ceUDiTL/KLNKRvl//qKFBcswgR+rpQjbJy8yM/ESSBwjVpwa6SWMVG149Nchio9oxZXypI\n" + + "UZoNese+lg5LfaNx8iMOgOs3nJNevNJn44yyGXAAAAAwEAAQAAAQA7wC9VFQBnZSE+0onY\n" + + "oV9YLwt2o2/Wa5nTNedv/bYWCYmKvoTnsY2xJvcnBt8JWposiu8RKIac3M4+ZkvZzwUzcP\n" + + "TKM1CFOLLiyIAVdm4Q2rQmeGCaIyL7A4QFZR/pwOR/0UtFV2LTeYSjGk3BvpcEgDYOJm77\n" + + "H1ZY8WP9un8Qj0ceRTD36eNYI75NPO3gEgT2BIaZ9t09u7CkHags/forLvubzmYOfIMeXN\n" + + "nadsmOWRsaBqQrtuH3qbtLsNGuVwE/FDxl9SpLbK7sKOVGG6JmpL6OXGEhJxgMuAYOguia\n" + + "V3Xrzt0deiQRGO30THObpS1g+fVkLlRiMzWLGoKeXAaVAAAAgCY6DNY2DahM84M2qhj8Ef\n" + + "5ypRnqJ6HUoFgV4Hf8sgOXiDamhEwAsbeC94WYTBtMWsQgnmrlYgK7jTxusXbEg5Ac+7Ah\n" + + "Zc3g2rkn/S4xNhZKJMxlzPVhYLKQhc9ZpCOXK0TjMs/3V2yFdfMoCp6AUUj382NLjNGlkx\n" + + "gIPf9t6bmYAAAAgQDMp7cLP2bamWqY8SzXlUCsH12nm/txE74G2BCJpR8wBoHkKjGbrdzE\n" + + "LFRoqs4nPsGIoXS2n4GyZZoY3dUEN6lmWlRUrE8lxhq1Ob4KZ+u3+ozQpr3WS8qIvtNmhn\n" + + "2T5jXyDgnTf6oZmNZavJ8f4Tjm5p2PXAkbzMC9yH4+DjHOEwAAAIEAyYPDioLOqmCs4pNa\n" + + "ZauWNlLt74Zmnr/mfxHiXHUnIliOXvgx38SVX5vOD7O3HAEMRDuD+lhdJH7LZhuuhQTj34\n" + + "ZtzOASRqSaCPd9AAf2bZ/aar69YSaDLA4gKvrjyqqeK9VuKUEKsGoiID2NNzTB8kIfbNVG\n" + + "lAzsUT+xOKn2fu0AAAANbWljaGVsZUBvc2hpZQECAwQFBg==\n" + + "-----END OPENSSH PRIVATE KEY-----\n") + + secret := map[string][]byte{ + "sshPrivateKey": testKey, + } + publicKey, err := getSshPublicKey("git@github.com:user/repo", secret) + Expect(err).ToNot(HaveOccurred()) + Expect(publicKey).ToNot(BeNil()) + Expect(publicKey.User).To(Equal("git")) + }) + }) + + Context("with missing SSH key", func() { + It("should return an error", func() { + secret := map[string][]byte{} + _, err := getSshPublicKey("git@github.com:user/repo", secret) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("could not get sshPrivateKey")) + }) + }) + + Context("with invalid SSH key", func() { + It("should return an error", func() { + secret := map[string][]byte{ + "sshPrivateKey": []byte("not-a-valid-key"), + } + _, err := getSshPublicKey("git@github.com:user/repo", secret) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("could not get publicKey")) + }) + }) +}) + +var _ = Describe("getGitRemoteURL", func() { + var tempDir2 string + + BeforeEach(func() { + tempDir2 = createTempDir("vp-remote-test") + }) + AfterEach(func() { + cleanupTempDir(tempDir2) + }) + + Context("when repository exists with a remote", func() { + It("should return the remote URL", func() { + err := cloneRepo(nil, gitOpsImpl, gitRepoURL, tempDir2, nil) + Expect(err).ToNot(HaveOccurred()) + + url, err := getGitRemoteURL(tempDir2, "origin") + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(Equal(gitRepoURL)) + }) + }) + + Context("when repository does not exist", func() { + It("should return an error", func() { + _, err := getGitRemoteURL("/nonexistent/path", "origin") + Expect(err).To(HaveOccurred()) + }) + }) + + Context("when remote does not exist", func() { + It("should return an error", func() { + _, err := git.PlainInit(tempDir2+"_init", false) + Expect(err).ToNot(HaveOccurred()) + defer cleanupTempDir(tempDir2 + "_init") + + _, err = getGitRemoteURL(tempDir2+"_init", "nonexistent") + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("repoHash on non-existent repo", func() { + It("should return error", func() { + _, err := repoHash("/nonexistent/path") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("getCommitFromTarget", func() { + var tempDir2 string + var repo *git.Repository + + BeforeEach(func() { + tempDir2 = createTempDir("vp-commit-test") + var err error + repo, err = git.PlainInit(tempDir2, false) + Expect(err).ToNot(HaveOccurred()) + + _, err = createTestCommit(repo, "main", "Initial commit") + Expect(err).ToNot(HaveOccurred()) + + // Set HEAD to the main branch + ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main")) + err = repo.Storer.SetReference(ref) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + cleanupTempDir(tempDir2) + }) + + It("should return HEAD hash when target is HEAD", func() { + hash, err := getCommitFromTarget(repo, "HEAD") + Expect(err).ToNot(HaveOccurred()) + Expect(hash).ToNot(Equal(plumbing.ZeroHash)) + }) + + It("should return hash for branch name", func() { + hash, err := getCommitFromTarget(repo, "main") + Expect(err).ToNot(HaveOccurred()) + Expect(hash).ToNot(Equal(plumbing.ZeroHash)) + }) + + It("should return main when target is empty", func() { + hash, err := getCommitFromTarget(repo, "") + Expect(err).ToNot(HaveOccurred()) + Expect(hash).ToNot(Equal(plumbing.ZeroHash)) + }) + + It("should return error for unknown target", func() { + _, err := getCommitFromTarget(repo, "nonexistent-ref-xyz") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown target")) + }) +}) diff --git a/internal/controller/defaults_test.go b/internal/controller/defaults_test.go new file mode 100644 index 000000000..5fdbead34 --- /dev/null +++ b/internal/controller/defaults_test.go @@ -0,0 +1,115 @@ +package controllers + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GitOpsConfig getValueWithDefault", func() { + Context("when the key exists in the config", func() { + It("should return the config value", func() { + config := GitOpsConfig{ + "gitops.channel": "custom-channel", + } + Expect(config.getValueWithDefault("gitops.channel")).To(Equal("custom-channel")) + }) + }) + + Context("when the key does not exist in config but exists in defaults", func() { + It("should return the default value for gitops.channel", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("gitops.channel")).To(Equal(GitOpsDefaultChannel)) + }) + + It("should return the default value for gitops.catalogSource", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("gitops.catalogSource")).To(Equal(GitOpsDefaultCatalogSource)) + }) + + It("should return the default value for gitops.name", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("gitops.name")).To(Equal(GitOpsDefaultPackageName)) + }) + + It("should return the default value for gitops.sourceNamespace", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("gitops.sourceNamespace")).To(Equal(GitOpsDefaultCatalogSourceNamespace)) + }) + + It("should return the default value for gitops.installApprovalPlan", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("gitops.installApprovalPlan")).To(Equal(GitOpsDefaultApprovalPlan)) + }) + + It("should return the default value for gitea.chartName", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("gitea.chartName")).To(Equal(GiteaChartName)) + }) + + It("should return the default value for gitea.helmRepoUrl", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("gitea.helmRepoUrl")).To(Equal(GiteaHelmRepoUrl)) + }) + + It("should return the default value for gitea.chartVersion", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("gitea.chartVersion")).To(Equal(GiteaDefaultChartVersion)) + }) + + It("should return the default value for analytics.enabled", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("analytics.enabled")).To(Equal("true")) + }) + }) + + Context("when the key does not exist in config or defaults", func() { + It("should return an empty string", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("nonexistent.key")).To(Equal("")) + }) + }) + + Context("when config overrides a default value", func() { + It("should return the overridden value, not the default", func() { + config := GitOpsConfig{ + "gitops.channel": "gitops-1.99", + } + Expect(config.getValueWithDefault("gitops.channel")).To(Equal("gitops-1.99")) + }) + }) + + Context("when config is nil", func() { + It("should return the default value", func() { + var config GitOpsConfig + Expect(config.getValueWithDefault("gitops.channel")).To(Equal(GitOpsDefaultChannel)) + }) + }) +}) + +var _ = Describe("DefaultPatternOperatorConfig", func() { + It("should contain all expected keys", func() { + expectedKeys := []string{ + "gitops.catalogSource", + "gitops.name", + "gitops.channel", + "gitops.sourceNamespace", + "gitops.installApprovalPlan", + "gitops.ManualSync", + "gitea.chartName", + "gitea.helmRepoUrl", + "gitea.chartVersion", + "analytics.enabled", + } + for _, key := range expectedKeys { + Expect(DefaultPatternOperatorConfig).To(HaveKey(key)) + } + }) + + It("should have correct default values", func() { + Expect(DefaultPatternOperatorConfig["gitops.catalogSource"]).To(Equal("redhat-operators")) + Expect(DefaultPatternOperatorConfig["gitops.sourceNamespace"]).To(Equal("openshift-marketplace")) + Expect(DefaultPatternOperatorConfig["gitops.installApprovalPlan"]).To(Equal("Automatic")) + Expect(DefaultPatternOperatorConfig["gitops.ManualSync"]).To(Equal("false")) + Expect(DefaultPatternOperatorConfig["analytics.enabled"]).To(Equal("true")) + }) +}) diff --git a/internal/controller/kube_test.go b/internal/controller/kube_test.go index 8b693a7f8..fd3c27387 100644 --- a/internal/controller/kube_test.go +++ b/internal/controller/kube_test.go @@ -12,6 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/discovery" discoveryfake "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/kubernetes" kubefake "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/scheme" @@ -701,3 +702,166 @@ var _ = Describe("checkAPIVersion", func() { // Expect(err).To(MatchError("failed to get API groups: discovery error")) // }) }) + +var _ = Describe("ObjectYaml", func() { + Context("with a valid object", func() { + It("should return valid YAML", func() { + obj := map[string]string{"name": "test", "value": "hello"} + result, err := objectYaml(obj) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(ContainSubstring("name: test")) + Expect(result).To(ContainSubstring("value: hello")) + }) + }) + + Context("with a nil object", func() { + It("should return null YAML", func() { + result, err := objectYaml(nil) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(ContainSubstring("null")) + }) + }) +}) + +var _ = Describe("ReferSameObject", func() { + Context("when references point to same object", func() { + It("should return true", func() { + ref1 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-cm", + UID: "uid-123", + } + ref2 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-cm", + UID: "uid-123", + } + Expect(referSameObject(ref1, ref2)).To(BeTrue()) + }) + }) + + Context("when references point to different objects", func() { + It("should return false for different names", func() { + ref1 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-cm-1", + UID: "uid-123", + } + ref2 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-cm-2", + UID: "uid-123", + } + Expect(referSameObject(ref1, ref2)).To(BeFalse()) + }) + + It("should return false for different UIDs", func() { + ref1 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-cm", + UID: "uid-123", + } + ref2 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-cm", + UID: "uid-456", + } + Expect(referSameObject(ref1, ref2)).To(BeFalse()) + }) + + It("should return false for different kinds", func() { + ref1 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test", + UID: "uid-123", + } + ref2 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "Secret", + Name: "test", + UID: "uid-123", + } + Expect(referSameObject(ref1, ref2)).To(BeFalse()) + }) + }) + + Context("with invalid API versions", func() { + It("should return false for invalid goal APIVersion", func() { + ref1 := &metav1.OwnerReference{ + APIVersion: "invalid//version", + Kind: "ConfigMap", + Name: "test", + UID: "uid-123", + } + ref2 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test", + UID: "uid-123", + } + Expect(referSameObject(ref1, ref2)).To(BeFalse()) + }) + + It("should return false for invalid actual APIVersion", func() { + ref1 := &metav1.OwnerReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test", + UID: "uid-123", + } + ref2 := &metav1.OwnerReference{ + APIVersion: "invalid//version", + Kind: "ConfigMap", + Name: "test", + UID: "uid-123", + } + Expect(referSameObject(ref1, ref2)).To(BeFalse()) + }) + }) +}) + +var _ = Describe("GetSecret", func() { + var kubeClient kubernetes.Interface + + BeforeEach(func() { + kubeClient = kubefake.NewSimpleClientset() + }) + + Context("when the secret exists", func() { + BeforeEach(func() { + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "key": []byte("value"), + }, + } + _, err := kubeClient.CoreV1().Secrets("default").Create(context.Background(), secret, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return the secret", func() { + secret, err := getSecret(kubeClient, "test-secret", "default") + Expect(err).ToNot(HaveOccurred()) + Expect(secret).ToNot(BeNil()) + Expect(string(secret.Data["key"])).To(Equal("value")) + }) + }) + + Context("when the secret does not exist", func() { + It("should return an error", func() { + secret, err := getSecret(kubeClient, "nonexistent", "default") + Expect(err).To(HaveOccurred()) + Expect(secret).To(BeNil()) + }) + }) +}) diff --git a/internal/controller/pattern_controller_test.go b/internal/controller/pattern_controller_test.go index 74bfd6f65..8dd0e25f5 100644 --- a/internal/controller/pattern_controller_test.go +++ b/internal/controller/pattern_controller_test.go @@ -164,3 +164,268 @@ func buildTestApplicationInfoArray() []api.PatternApplicationInfo { return applications } + +var _ = Describe("pattern controller - preValidation", func() { + var reconciler *PatternReconciler + + BeforeEach(func() { + nsOperators := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + reconciler = newFakeReconciler(nsOperators, buildPatternManifest()) + }) + + It("should pass with valid https target and origin repos", func() { + p := &api.Pattern{ + Spec: api.PatternSpec{ + GitConfig: api.GitConfig{ + TargetRepo: "https://github.com/test/repo", + OriginRepo: "https://github.com/upstream/repo", + }, + }, + } + err := reconciler.preValidation(p) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should pass with valid ssh target repo", func() { + p := &api.Pattern{ + Spec: api.PatternSpec{ + GitConfig: api.GitConfig{ + TargetRepo: "git@github.com:test/repo.git", + }, + }, + } + err := reconciler.preValidation(p) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should fail when target repo is empty", func() { + p := &api.Pattern{ + Spec: api.PatternSpec{ + GitConfig: api.GitConfig{ + TargetRepo: "", + }, + }, + } + err := reconciler.preValidation(p) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("TargetRepo cannot be empty")) + }) + + It("should fail with invalid origin repo URL", func() { + p := &api.Pattern{ + Spec: api.PatternSpec{ + GitConfig: api.GitConfig{ + OriginRepo: "invalid-url", + TargetRepo: "https://github.com/test/repo", + }, + }, + } + err := reconciler.preValidation(p) + Expect(err).To(HaveOccurred()) + }) + + It("should fail with invalid target repo URL", func() { + p := &api.Pattern{ + Spec: api.PatternSpec{ + GitConfig: api.GitConfig{ + TargetRepo: "invalid-url", + }, + }, + } + err := reconciler.preValidation(p) + Expect(err).To(HaveOccurred()) + }) + + It("should pass when origin repo is empty but target repo is valid", func() { + p := &api.Pattern{ + Spec: api.PatternSpec{ + GitConfig: api.GitConfig{ + OriginRepo: "", + TargetRepo: "https://github.com/test/repo", + }, + }, + } + err := reconciler.preValidation(p) + Expect(err).ToNot(HaveOccurred()) + }) +}) + +var _ = Describe("pattern controller - applyDefaults", func() { + var reconciler *PatternReconciler + + BeforeEach(func() { + nsOperators := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + reconciler = newFakeReconciler(nsOperators, buildPatternManifest()) + }) + + It("should set cluster info from configClient", func() { + p := buildPatternManifest() + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Status.ClusterPlatform).To(Equal("AWS")) + Expect(output.Status.ClusterVersion).To(Equal("4.10")) + }) + + It("should set the cluster domain from ingress", func() { + p := buildPatternManifest() + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Status.AppClusterDomain).To(Equal("hello.world")) + }) + + It("should default TargetRevision to HEAD when empty", func() { + p := buildPatternManifest() + p.Spec.GitConfig.TargetRevision = "" + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.GitConfig.TargetRevision).To(Equal(GitHEAD)) + }) + + It("should preserve TargetRevision when set", func() { + p := buildPatternManifest() + p.Spec.GitConfig.TargetRevision = "v1.0.0" + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.GitConfig.TargetRevision).To(Equal("v1.0.0")) + }) + + It("should default OriginRevision to HEAD when empty", func() { + p := buildPatternManifest() + p.Spec.GitConfig.OriginRevision = "" + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.GitConfig.OriginRevision).To(Equal(GitHEAD)) + }) + + It("should default MultiSourceConfig.Enabled to true when nil", func() { + p := buildPatternManifest() + p.Spec.MultiSourceConfig.Enabled = nil + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.MultiSourceConfig.Enabled).ToNot(BeNil()) + Expect(*output.Spec.MultiSourceConfig.Enabled).To(BeTrue()) + }) + + It("should default ClusterGroupName to 'default' when empty", func() { + p := buildPatternManifest() + p.Spec.ClusterGroupName = "" + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.ClusterGroupName).To(Equal("default")) + }) + + It("should default HelmRepoUrl when empty", func() { + p := buildPatternManifest() + p.Spec.MultiSourceConfig.HelmRepoUrl = "" + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.MultiSourceConfig.HelmRepoUrl).To(Equal("https://charts.validatedpatterns.io/")) + }) + + It("should initialize GitOpsConfig when nil", func() { + p := buildPatternManifest() + p.Spec.GitOpsConfig = nil + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.GitOpsConfig).ToNot(BeNil()) + }) + + It("should extract hostname from TargetRepo when Hostname is empty", func() { + p := buildPatternManifest() + p.Spec.GitConfig.Hostname = "" + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.GitConfig.Hostname).To(Equal("target.url")) + }) + + It("should preserve hostname when already set", func() { + p := buildPatternManifest() + p.Spec.GitConfig.Hostname = "custom.hostname.io" + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Spec.GitConfig.Hostname).To(Equal("custom.hostname.io")) + }) + + It("should set LocalCheckoutPath", func() { + p := buildPatternManifest() + output, err := reconciler.applyDefaults(p) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Status.LocalCheckoutPath).ToNot(BeEmpty()) + }) +}) + +var _ = Describe("pattern controller - postValidation", func() { + It("should always return nil", func() { + nsOperators := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + reconciler := newFakeReconciler(nsOperators, buildPatternManifest()) + p := buildPatternManifest() + err := reconciler.postValidation(p) + Expect(err).ToNot(HaveOccurred()) + }) +}) + +var _ = Describe("pattern controller - buildPatternManifest helpers", func() { + It("should create a pattern with the correct name", func() { + p := buildPatternManifest() + Expect(p.Name).To(Equal(foo)) + }) + + It("should create a pattern with the correct namespace", func() { + p := buildPatternManifest() + Expect(p.Namespace).To(Equal(namespace)) + }) + + It("should include the pattern finalizer", func() { + p := buildPatternManifest() + Expect(p.Finalizers).To(ContainElement(api.PatternFinalizer)) + }) + + It("should set the git config", func() { + p := buildPatternManifest() + Expect(p.Spec.GitConfig.OriginRepo).To(Equal(originURL)) + Expect(p.Spec.GitConfig.TargetRepo).To(Equal(targetURL)) + }) + + It("should set the cluster platform status", func() { + p := buildPatternManifest() + Expect(p.Status.ClusterPlatform).To(Equal("AWS")) + Expect(p.Status.ClusterVersion).To(Equal("1.2.3")) + }) +}) + +var _ = Describe("pattern controller - reconciler creation", func() { + It("should create a reconciler with all required clients", func() { + nsOperators := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + reconciler := newFakeReconciler(nsOperators, buildPatternManifest()) + Expect(reconciler).ToNot(BeNil()) + Expect(reconciler.Client).ToNot(BeNil()) + Expect(reconciler.Scheme).ToNot(BeNil()) + Expect(reconciler.olmClient).ToNot(BeNil()) + Expect(reconciler.fullClient).ToNot(BeNil()) + Expect(reconciler.configClient).ToNot(BeNil()) + Expect(reconciler.operatorClient).ToNot(BeNil()) + Expect(reconciler.AnalyticsClient).ToNot(BeNil()) + Expect(reconciler.gitOperations).ToNot(BeNil()) + }) +}) + +var _ = Describe("pattern controller - fetching pattern", func() { + It("should be able to get the pattern after creation", func() { + nsOperators := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + reconciler := newFakeReconciler(nsOperators, buildPatternManifest()) + p := &api.Pattern{} + err := reconciler.Client.Get(context.Background(), patternNamespaced, p) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Name).To(Equal(foo)) + Expect(p.Namespace).To(Equal(namespace)) + }) + + It("should return error for nonexistent pattern", func() { + nsOperators := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + reconciler := newFakeReconciler(nsOperators, buildPatternManifest()) + p := &api.Pattern{} + err := reconciler.Client.Get(context.Background(), + types.NamespacedName{Name: "nonexistent", Namespace: namespace}, p) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/internal/controller/utils_test.go b/internal/controller/utils_test.go index a18554b68..6d2def7f1 100644 --- a/internal/controller/utils_test.go +++ b/internal/controller/utils_test.go @@ -18,10 +18,12 @@ package controllers import ( "context" + "crypto/tls" "crypto/x509" "encoding/pem" "fmt" "os" + "path/filepath" "github.com/go-errors/errors" api "github.com/hybrid-cloud-patterns/patterns-operator/api/v1alpha1" @@ -39,6 +41,22 @@ import ( //+kubebuilder:scaffold:imports ) +// Self-signed test CA certificate for getHTTPSTransport tests +var testCACert = []byte(`-----BEGIN CERTIFICATE----- +MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zAKBggqhkjOPQQDAzASMRAw +DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw +WjASMRAwDgYDVQQKEwdBY21lIENvMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7Jdx +McVMDPH0GQKXW9z+Xa0+H/GVvOdxDeGR5dEWBq4eFTkJ5x7+h/bfaSeQGVCBm/s +ZBeXnOJtIG01kv6mBcExZ6YGXpeLdpaIuKsFr7TMjjQ4L/HTPgwIvTAUUoYEo4GG +MIGDMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggrBgEFBQcDATAPBgNVHRMB +Af8EBTADAQH/MB0GA1UdDgQWBBRWbLiq20RFEfwvuGBEdFaxdkQiMDAsBgNVHREE +JTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEKHBR0ZXN0MBIG +A1UdEAQLMAmCB3Rlc3RjYTAKBggqhkjOPQQDAwNnADBkAjBK9MEtFB6VYkOngzWd +Ft0LstEoFkHkWJxgSZ8WlKnmPPKQee3ZIB3JkKRfV2Y80cICMANZmsSy1HTRrbXI +Jfc+jIf39GhvPMfxR3BBfrIvdBH2oKC1PNi6N1iFYrPiKaMs6A== +-----END CERTIFICATE----- +`) + var testCases = []struct { inputURL string expectedName string @@ -1272,3 +1290,1223 @@ var _ = Describe("createNamespace", func() { Expect(err.Error()).To(ContainSubstring("internal error")) }) }) + +var _ = Describe("hasExperimentalCapability", func() { + Context("when capability exists in comma-separated list", func() { + It("should return true", func() { + Expect(hasExperimentalCapability("cap1,cap2,cap3", "cap2")).To(BeTrue()) + }) + }) + + Context("when capability does not exist", func() { + It("should return false", func() { + Expect(hasExperimentalCapability("cap1,cap2,cap3", "cap4")).To(BeFalse()) + }) + }) + + Context("with empty capabilities string", func() { + It("should return false", func() { + Expect(hasExperimentalCapability("", "cap1")).To(BeFalse()) + }) + }) + + Context("with single capability", func() { + It("should return true if matches", func() { + Expect(hasExperimentalCapability("cap1", "cap1")).To(BeTrue()) + }) + }) + + Context("with whitespace around capabilities", func() { + It("should handle trimmed comparison", func() { + Expect(hasExperimentalCapability("cap1, cap2, cap3", "cap2")).To(BeTrue()) + }) + }) +}) + +var _ = Describe("getHTTPSTransport", func() { + Context("with nil client", func() { + It("should return transport with system certs", func() { + transport := getHTTPSTransport(nil) + Expect(transport).ToNot(BeNil()) + Expect(transport.TLSClientConfig).ToNot(BeNil()) + }) + }) + + Context("with fake client with no configmaps", func() { + It("should return transport with system certs", func() { + kubeClient := fake.NewSimpleClientset() + transport := getHTTPSTransport(kubeClient) + Expect(transport).ToNot(BeNil()) + Expect(transport.TLSClientConfig).ToNot(BeNil()) + }) + }) + + Context("with fake client with cert configmaps", func() { + It("should return transport with custom certs", func() { + kubeClient := fake.NewSimpleClientset() + // Create kube-root-ca configmap + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-root-ca.crt", + Namespace: "openshift-config-managed", + }, + Data: map[string]string{ + "ca.crt": "-----BEGIN CERTIFICATE-----\n" + + "MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw\n" + + "DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow\n" + + "EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLU3\n" + + "jSayahkJYT5/UqIqViZFMVh16yrQ1mOA8V/k3H8Pk/DL1tJ1yXYEptzhKELNJIjp\n" + + "zUv0jVJHPnLGVaikzlKjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr\n" + + "BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1\n" + + "NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2wpSek3WdNcr\n" + + "jSuvziv6OERWSEZObKHVIJl/Cj9SWWECIGB/W0PCjZjKXBzgoW0OzXRiDP/WRxW6\n" + + "frNHC7GJcIqs\n-----END CERTIFICATE-----\n", + }, + } + _, err := kubeClient.CoreV1().ConfigMaps("openshift-config-managed").Create(context.TODO(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + transport := getHTTPSTransport(kubeClient) + Expect(transport).ToNot(BeNil()) + Expect(transport.TLSClientConfig.RootCAs).ToNot(BeNil()) + }) + }) +}) + +var _ = Describe("compareMaps", func() { + Context("with identical maps", func() { + It("should return true", func() { + m1 := map[string][]byte{"key1": []byte("val1"), "key2": []byte("val2")} + m2 := map[string][]byte{"key1": []byte("val1"), "key2": []byte("val2")} + Expect(compareMaps(m1, m2)).To(BeTrue()) + }) + }) + + Context("with different lengths", func() { + It("should return false", func() { + m1 := map[string][]byte{"key1": []byte("val1")} + m2 := map[string][]byte{"key1": []byte("val1"), "key2": []byte("val2")} + Expect(compareMaps(m1, m2)).To(BeFalse()) + }) + }) + + Context("with different values", func() { + It("should return false", func() { + m1 := map[string][]byte{"key1": []byte("val1")} + m2 := map[string][]byte{"key1": []byte("val2")} + Expect(compareMaps(m1, m2)).To(BeFalse()) + }) + }) + + Context("with different keys", func() { + It("should return false", func() { + m1 := map[string][]byte{"key1": []byte("val1")} + m2 := map[string][]byte{"key2": []byte("val1")} + Expect(compareMaps(m1, m2)).To(BeFalse()) + }) + }) + + Context("with empty maps", func() { + It("should return true", func() { + m1 := map[string][]byte{} + m2 := map[string][]byte{} + Expect(compareMaps(m1, m2)).To(BeTrue()) + }) + }) +}) + +var _ = Describe("GenerateRandomPassword", func() { + Context("with default random reader", func() { + It("should generate a password of expected length", func() { + password, err := GenerateRandomPassword(15, DefaultRandRead) + Expect(err).ToNot(HaveOccurred()) + Expect(password).ToNot(BeEmpty()) + // base64 encoded 15 bytes = 20 chars + Expect(password).To(HaveLen(20)) + }) + }) + + Context("with failing random reader", func() { + It("should return an error", func() { + failReader := func(b []byte) (int, error) { + return 0, fmt.Errorf("random read failed") + } + _, err := GenerateRandomPassword(15, failReader) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("random read failed")) + }) + }) +}) + +var _ = Describe("writeConfigMapKeyToFile", func() { + var kubeClient kubernetes.Interface + var td string + + BeforeEach(func() { + kubeClient = fake.NewSimpleClientset() + td = createTempDir("vp-write-cm-test") + }) + AfterEach(func() { + cleanupTempDir(td) + }) + + Context("when configmap exists and key is found", func() { + It("should write the value to the file", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "test-ns"}, + Data: map[string]string{"ca.crt": "cert-data"}, + } + _, err := kubeClient.CoreV1().ConfigMaps("test-ns").Create(context.TODO(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + filePath := filepath.Join(td, "ca.crt") + err = writeConfigMapKeyToFile(kubeClient, "test-ns", "test-cm", "ca.crt", filePath, false) + Expect(err).ToNot(HaveOccurred()) + + content, err := os.ReadFile(filePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(Equal("cert-data\n")) + }) + }) + + Context("when configmap does not exist", func() { + It("should return an error", func() { + filePath := filepath.Join(td, "ca.crt") + err := writeConfigMapKeyToFile(kubeClient, "test-ns", "nonexistent", "ca.crt", filePath, false) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("when key does not exist in configmap", func() { + It("should return an error", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "test-ns"}, + Data: map[string]string{"other-key": "data"}, + } + _, err := kubeClient.CoreV1().ConfigMaps("test-ns").Create(context.TODO(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + filePath := filepath.Join(td, "ca.crt") + err = writeConfigMapKeyToFile(kubeClient, "test-ns", "test-cm", "ca.crt", filePath, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("key ca.crt not found")) + }) + }) + + Context("when appending to file", func() { + It("should append the content", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "test-ns"}, + Data: map[string]string{"ca.crt": "cert-data"}, + } + _, err := kubeClient.CoreV1().ConfigMaps("test-ns").Create(context.TODO(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + filePath := filepath.Join(td, "ca.crt") + err = os.WriteFile(filePath, []byte("existing-data\n"), 0600) + Expect(err).ToNot(HaveOccurred()) + + err = writeConfigMapKeyToFile(kubeClient, "test-ns", "test-cm", "ca.crt", filePath, true) + Expect(err).ToNot(HaveOccurred()) + + content, err := os.ReadFile(filePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("existing-data")) + Expect(string(content)).To(ContainSubstring("cert-data")) + }) + }) +}) + +var _ = Describe("getConfigMapKey", func() { + var kubeClient kubernetes.Interface + + BeforeEach(func() { + kubeClient = fake.NewSimpleClientset() + }) + + Context("when configmap and key exist", func() { + It("should return the value", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "test-ns"}, + Data: map[string]string{"mykey": "myvalue"}, + } + _, err := kubeClient.CoreV1().ConfigMaps("test-ns").Create(context.TODO(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + val, err := getConfigMapKey(kubeClient, "test-ns", "test-cm", "mykey") + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal("myvalue")) + }) + }) + + Context("when configmap does not exist", func() { + It("should return an error", func() { + _, err := getConfigMapKey(kubeClient, "test-ns", "nonexistent", "mykey") + Expect(err).To(HaveOccurred()) + }) + }) + + Context("when key does not exist", func() { + It("should return an error", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "test-ns"}, + Data: map[string]string{"otherkey": "othervalue"}, + } + _, err := kubeClient.CoreV1().ConfigMaps("test-ns").Create(context.TODO(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + _, err = getConfigMapKey(kubeClient, "test-ns", "test-cm", "mykey") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("key mykey not found")) + }) + }) +}) + +var _ = Describe("createTrustedBundleCM", func() { + var kubeClient kubernetes.Interface + + BeforeEach(func() { + kubeClient = fake.NewSimpleClientset() + }) + + Context("when configmap does not exist", func() { + It("should create it", func() { + err := createTrustedBundleCM(kubeClient, "test-ns") + Expect(err).ToNot(HaveOccurred()) + + cm, err := kubeClient.CoreV1().ConfigMaps("test-ns").Get(context.TODO(), "trusted-ca-bundle", metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(cm.Labels["config.openshift.io/inject-trusted-cabundle"]).To(Equal("true")) + }) + }) + + Context("when configmap already exists", func() { + It("should not error", func() { + err := createTrustedBundleCM(kubeClient, "test-ns") + Expect(err).ToNot(HaveOccurred()) + + // Call again + err = createTrustedBundleCM(kubeClient, "test-ns") + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) + +var _ = Describe("logOnce", func() { + BeforeEach(func() { + // Reset the logKeys map before each test + logKeys = map[string]bool{} + }) + + It("should log a message the first time", func() { + logOnce("test message") + Expect(logKeys).To(HaveKey("test message")) + }) + + It("should not add duplicate entries", func() { + logOnce("duplicate message") + logOnce("duplicate message") + Expect(logKeys).To(HaveLen(1)) + }) + + It("should handle different messages", func() { + logOnce("message 1") + logOnce("message 2") + Expect(logKeys).To(HaveLen(2)) + }) +}) + +var _ = Describe("IsCommonSlimmed", func() { + var td string + + BeforeEach(func() { + td = createTempDir("vp-slimmed-test") + }) + AfterEach(func() { + cleanupTempDir(td) + }) + + Context("when common/operator-install exists", func() { + It("should return false (not slimmed)", func() { + err := os.MkdirAll(filepath.Join(td, "common", "operator-install"), 0755) + Expect(err).ToNot(HaveOccurred()) + Expect(IsCommonSlimmed(td)).To(BeFalse()) + }) + }) + + Context("when common/operator-install does not exist", func() { + It("should return true (slimmed)", func() { + Expect(IsCommonSlimmed(td)).To(BeTrue()) + }) + }) + + Context("when path does not exist", func() { + It("should return true (slimmed)", func() { + Expect(IsCommonSlimmed("/nonexistent/path")).To(BeTrue()) + }) + }) +}) + +var _ = Describe("IntOrZero", func() { + Context("when key exists with valid integer", func() { + It("should return the integer value", func() { + secret := map[string][]byte{"count": []byte("42")} + val, err := IntOrZero(secret, "count") + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(int64(42))) + }) + }) + + Context("when key does not exist", func() { + It("should return 0", func() { + secret := map[string][]byte{} + val, err := IntOrZero(secret, "missing") + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(int64(0))) + }) + }) + + Context("when key exists with invalid integer", func() { + It("should return an error", func() { + secret := map[string][]byte{"count": []byte("not-a-number")} + _, err := IntOrZero(secret, "count") + Expect(err).To(HaveOccurred()) + }) + }) + + Context("when key exists with negative integer", func() { + It("should return the negative value", func() { + secret := map[string][]byte{"count": []byte("-5")} + val, err := IntOrZero(secret, "count") + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(int64(-5))) + }) + }) +}) + +var _ = Describe("getClusterWideArgoNamespace", func() { + It("should return the ApplicationNamespace", func() { + Expect(getClusterWideArgoNamespace()).To(Equal(ApplicationNamespace)) + }) +}) + +var _ = Describe("Pattern condition search functions", func() { + var conditions []api.PatternCondition + + BeforeEach(func() { + conditions = []api.PatternCondition{ + { + Type: api.Synced, + Status: corev1.ConditionTrue, + }, + { + Type: api.Degraded, + Status: corev1.ConditionFalse, + }, + } + }) + + Describe("getPatternConditionByStatus", func() { + Context("when conditions is nil", func() { + It("should return -1 and nil", func() { + idx, cond := getPatternConditionByStatus(nil, corev1.ConditionTrue) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) + + Context("when condition exists", func() { + It("should return the index and condition", func() { + idx, cond := getPatternConditionByStatus(conditions, corev1.ConditionTrue) + Expect(idx).To(Equal(0)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Type).To(Equal(api.Synced)) + }) + }) + + Context("when condition does not exist", func() { + It("should return -1 and nil", func() { + idx, cond := getPatternConditionByStatus(conditions, corev1.ConditionUnknown) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) + }) + + Describe("getPatternConditionByType", func() { + Context("when conditions is nil", func() { + It("should return -1 and nil", func() { + idx, cond := getPatternConditionByType(nil, api.Synced) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) + + Context("when condition type exists", func() { + It("should return the index and condition", func() { + idx, cond := getPatternConditionByType(conditions, api.Degraded) + Expect(idx).To(Equal(1)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(corev1.ConditionFalse)) + }) + }) + + Context("when condition type does not exist", func() { + It("should return -1 and nil", func() { + idx, cond := getPatternConditionByType(conditions, api.Unknown) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) + }) +}) + +var _ = Describe("parseAndReturnVersion", func() { + Context("with a valid version string", func() { + It("should return the parsed version", func() { + v, err := parseAndReturnVersion("4.12.5") + Expect(err).ToNot(HaveOccurred()) + Expect(v).ToNot(BeNil()) + Expect(v.Major()).To(Equal(uint64(4))) + Expect(v.Minor()).To(Equal(uint64(12))) + Expect(v.Patch()).To(Equal(uint64(5))) + }) + }) + + Context("with an invalid version string", func() { + It("should return an error", func() { + _, err := parseAndReturnVersion("not-a-version") + Expect(err).To(HaveOccurred()) + }) + }) + + Context("with empty string", func() { + It("should return an error", func() { + _, err := parseAndReturnVersion("") + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("getCurrentClusterVersion", func() { + Context("with completed history entry", func() { + It("should return the completed version", func() { + cv := &configv1.ClusterVersion{ + Status: configv1.ClusterVersionStatus{ + History: []configv1.UpdateHistory{ + {Version: "4.13.5", State: "Partial"}, + {Version: "4.13.4", State: "Completed"}, + }, + Desired: configv1.Release{Version: "4.13.5"}, + }, + } + v, err := getCurrentClusterVersion(cv) + Expect(err).ToNot(HaveOccurred()) + Expect(v.String()).To(Equal("4.13.4")) + }) + }) + + Context("with no completed history", func() { + It("should fall back to desired version", func() { + cv := &configv1.ClusterVersion{ + Status: configv1.ClusterVersionStatus{ + History: []configv1.UpdateHistory{ + {Version: "4.13.5", State: "Partial"}, + }, + Desired: configv1.Release{Version: "4.13.5"}, + }, + } + v, err := getCurrentClusterVersion(cv) + Expect(err).ToNot(HaveOccurred()) + Expect(v.String()).To(Equal("4.13.5")) + }) + }) + + Context("with empty history", func() { + It("should fall back to desired version", func() { + cv := &configv1.ClusterVersion{ + Status: configv1.ClusterVersionStatus{ + Desired: configv1.Release{Version: "4.12.0"}, + }, + } + v, err := getCurrentClusterVersion(cv) + Expect(err).ToNot(HaveOccurred()) + Expect(v.String()).To(Equal("4.12.0")) + }) + }) +}) + +var _ = Describe("newSecret", func() { + It("should create a secret with correct properties", func() { + data := map[string][]byte{"key": []byte("value")} + labels := map[string]string{"app": "test"} + s := newSecret("my-secret", "my-ns", data, labels) + Expect(s.Name).To(Equal("my-secret")) + Expect(s.Namespace).To(Equal("my-ns")) + Expect(s.Data).To(Equal(data)) + Expect(s.Labels).To(Equal(labels)) + }) +}) + +var _ = Describe("DropLocalGitPaths", func() { + It("should remove the vp temp folder", func() { + td := filepath.Join(os.TempDir(), VPTmpFolder, "test-drop") + err := os.MkdirAll(td, 0755) + Expect(err).ToNot(HaveOccurred()) + + err = DropLocalGitPaths() + Expect(err).ToNot(HaveOccurred()) + + _, err = os.Stat(td) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("should not error if folder does not exist", func() { + err := DropLocalGitPaths() + Expect(err).ToNot(HaveOccurred()) + }) +}) + +var _ = Describe("getPatternConditionByStatus", func() { + var conditions []api.PatternCondition + + BeforeEach(func() { + conditions = []api.PatternCondition{ + { + Type: api.GitInSync, + Status: corev1.ConditionTrue, + }, + { + Type: api.Degraded, + Status: corev1.ConditionFalse, + }, + { + Type: api.Progressing, + Status: corev1.ConditionUnknown, + }, + } + }) + + Context("when condition with given status exists", func() { + It("should return the index and the condition for ConditionTrue", func() { + idx, cond := getPatternConditionByStatus(conditions, corev1.ConditionTrue) + Expect(idx).To(Equal(0)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Type).To(Equal(api.GitInSync)) + }) + + It("should return the index and the condition for ConditionFalse", func() { + idx, cond := getPatternConditionByStatus(conditions, corev1.ConditionFalse) + Expect(idx).To(Equal(1)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Type).To(Equal(api.Degraded)) + }) + + It("should return the index and the condition for ConditionUnknown", func() { + idx, cond := getPatternConditionByStatus(conditions, corev1.ConditionUnknown) + Expect(idx).To(Equal(2)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Type).To(Equal(api.Progressing)) + }) + }) + + Context("when condition with given status does not exist", func() { + It("should return -1 and nil", func() { + // All statuses are accounted for, so create a new slice with only one status + limited := []api.PatternCondition{ + {Type: api.GitInSync, Status: corev1.ConditionTrue}, + } + idx, cond := getPatternConditionByStatus(limited, corev1.ConditionFalse) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) + + Context("when conditions slice is nil", func() { + It("should return -1 and nil", func() { + idx, cond := getPatternConditionByStatus(nil, corev1.ConditionTrue) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) + + Context("when conditions slice is empty", func() { + It("should return -1 and nil", func() { + idx, cond := getPatternConditionByStatus([]api.PatternCondition{}, corev1.ConditionTrue) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) + + Context("when multiple conditions have the same status", func() { + It("should return the first matching index", func() { + dupes := []api.PatternCondition{ + {Type: api.GitInSync, Status: corev1.ConditionTrue}, + {Type: api.Synced, Status: corev1.ConditionTrue}, + } + idx, cond := getPatternConditionByStatus(dupes, corev1.ConditionTrue) + Expect(idx).To(Equal(0)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Type).To(Equal(api.GitInSync)) + }) + }) +}) + +var _ = Describe("getPatternConditionByType", func() { + var conditions []api.PatternCondition + + BeforeEach(func() { + conditions = []api.PatternCondition{ + { + Type: api.GitInSync, + Status: corev1.ConditionTrue, + Message: "in sync", + }, + { + Type: api.Degraded, + Status: corev1.ConditionFalse, + Message: "not degraded", + }, + { + Type: api.Progressing, + Status: corev1.ConditionTrue, + Message: "progressing", + }, + } + }) + + Context("when condition with given type exists", func() { + It("should return the index and the condition for GitInSync", func() { + idx, cond := getPatternConditionByType(conditions, api.GitInSync) + Expect(idx).To(Equal(0)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Message).To(Equal("in sync")) + }) + + It("should return the index and the condition for Degraded", func() { + idx, cond := getPatternConditionByType(conditions, api.Degraded) + Expect(idx).To(Equal(1)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(corev1.ConditionFalse)) + }) + + It("should return the index and the condition for Progressing", func() { + idx, cond := getPatternConditionByType(conditions, api.Progressing) + Expect(idx).To(Equal(2)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Message).To(Equal("progressing")) + }) + }) + + Context("when condition with given type does not exist", func() { + It("should return -1 and nil for Missing type", func() { + idx, cond := getPatternConditionByType(conditions, api.Missing) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + + It("should return -1 and nil for Suspended type", func() { + idx, cond := getPatternConditionByType(conditions, api.Suspended) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) + + Context("when conditions slice is nil", func() { + It("should return -1 and nil", func() { + idx, cond := getPatternConditionByType(nil, api.GitInSync) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) + + Context("when conditions slice is empty", func() { + It("should return -1 and nil", func() { + idx, cond := getPatternConditionByType([]api.PatternCondition{}, api.GitInSync) + Expect(idx).To(Equal(-1)) + Expect(cond).To(BeNil()) + }) + }) +}) + +var _ = Describe("getClusterWideArgoNamespace", func() { + It("should return the ApplicationNamespace constant", func() { + ns := getClusterWideArgoNamespace() + Expect(ns).To(Equal(ApplicationNamespace)) + Expect(ns).To(Equal("openshift-gitops")) + }) +}) + +var _ = Describe("writeConfigMapKeyToFile", func() { + var ( + kubeClient kubernetes.Interface + tmpDir string + ) + + BeforeEach(func() { + kubeClient = fake.NewSimpleClientset() + tmpDir = createTempDir("vp-writecm-test") + }) + + AfterEach(func() { + cleanupTempDir(tmpDir) + }) + + Context("when the ConfigMap and key exist", func() { + BeforeEach(func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + Data: map[string]string{ + "my-key": "my-value-content", + }, + } + _, err := kubeClient.CoreV1().ConfigMaps("default").Create(context.Background(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should write the value to a file (truncate mode)", func() { + filePath := filepath.Join(tmpDir, "output.txt") + err := writeConfigMapKeyToFile(kubeClient, "default", "test-cm", "my-key", filePath, false) + Expect(err).ToNot(HaveOccurred()) + + content, err := os.ReadFile(filePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(Equal("my-value-content\n")) + }) + + It("should append the value to a file (append mode)", func() { + filePath := filepath.Join(tmpDir, "output.txt") + // Write initial content + err := os.WriteFile(filePath, []byte("existing-content\n"), 0600) + Expect(err).ToNot(HaveOccurred()) + + err = writeConfigMapKeyToFile(kubeClient, "default", "test-cm", "my-key", filePath, true) + Expect(err).ToNot(HaveOccurred()) + + content, err := os.ReadFile(filePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(Equal("existing-content\nmy-value-content\n")) + }) + + It("should overwrite existing content in truncate mode", func() { + filePath := filepath.Join(tmpDir, "output.txt") + err := os.WriteFile(filePath, []byte("old-content\n"), 0600) + Expect(err).ToNot(HaveOccurred()) + + err = writeConfigMapKeyToFile(kubeClient, "default", "test-cm", "my-key", filePath, false) + Expect(err).ToNot(HaveOccurred()) + + content, err := os.ReadFile(filePath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(Equal("my-value-content\n")) + }) + }) + + Context("when the ConfigMap does not exist", func() { + It("should return an error", func() { + filePath := filepath.Join(tmpDir, "output.txt") + err := writeConfigMapKeyToFile(kubeClient, "default", "nonexistent-cm", "my-key", filePath, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("error getting ConfigMap")) + }) + }) + + Context("when the key does not exist in the ConfigMap", func() { + BeforeEach(func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + Data: map[string]string{ + "other-key": "other-value", + }, + } + _, err := kubeClient.CoreV1().ConfigMaps("default").Create(context.Background(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return an error about missing key", func() { + filePath := filepath.Join(tmpDir, "output.txt") + err := writeConfigMapKeyToFile(kubeClient, "default", "test-cm", "missing-key", filePath, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("key missing-key not found")) + }) + }) +}) + +var _ = Describe("getConfigMapKey", func() { + var kubeClient kubernetes.Interface + + BeforeEach(func() { + kubeClient = fake.NewSimpleClientset() + }) + + Context("when ConfigMap and key exist", func() { + BeforeEach(func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + Data: map[string]string{ + "ca.crt": "certificate-data-here", + }, + } + _, err := kubeClient.CoreV1().ConfigMaps("default").Create(context.Background(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return the value", func() { + val, err := getConfigMapKey(kubeClient, "default", "test-cm", "ca.crt") + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal("certificate-data-here")) + }) + }) + + Context("when the ConfigMap does not exist", func() { + It("should return an error", func() { + _, err := getConfigMapKey(kubeClient, "default", "nonexistent", "key") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("error getting ConfigMap")) + }) + }) + + Context("when the key does not exist", func() { + BeforeEach(func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + Data: map[string]string{ + "other-key": "value", + }, + } + _, err := kubeClient.CoreV1().ConfigMaps("default").Create(context.Background(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return an error about missing key", func() { + _, err := getConfigMapKey(kubeClient, "default", "test-cm", "missing-key") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("key missing-key not found")) + }) + }) +}) + +var _ = Describe("getHTTPSTransport", func() { + Context("with nil client", func() { + It("should return a transport with system cert pool", func() { + transport := getHTTPSTransport(nil) + Expect(transport).ToNot(BeNil()) + Expect(transport.TLSClientConfig).ToNot(BeNil()) + Expect(transport.TLSClientConfig.MinVersion).To(Equal(uint16(tls.VersionTLS12))) + }) + }) + + Context("with a fake client and no configmaps", func() { + It("should return a transport falling back to system certs", func() { + kubeClient := fake.NewSimpleClientset() + transport := getHTTPSTransport(kubeClient) + Expect(transport).ToNot(BeNil()) + Expect(transport.TLSClientConfig).ToNot(BeNil()) + }) + }) + + Context("with a fake client and kube-root-ca.crt configmap", func() { + It("should use the CA data from the configmap", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-root-ca.crt", + Namespace: "openshift-config-managed", + }, + Data: map[string]string{ + "ca.crt": string(testCACert), + }, + } + kubeClient := fake.NewSimpleClientset(cm) + transport := getHTTPSTransport(kubeClient) + Expect(transport).ToNot(BeNil()) + Expect(transport.TLSClientConfig).ToNot(BeNil()) + Expect(transport.TLSClientConfig.RootCAs).ToNot(BeNil()) + }) + }) + + Context("with both kube-root-ca and trusted-ca-bundle", func() { + It("should merge both CA bundles", func() { + cm1 := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-root-ca.crt", + Namespace: "openshift-config-managed", + }, + Data: map[string]string{ + "ca.crt": string(testCACert), + }, + } + cm2 := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "trusted-ca-bundle", + Namespace: "openshift-config-managed", + }, + Data: map[string]string{ + "ca-bundle.crt": string(testCACert), + }, + } + kubeClient := fake.NewSimpleClientset(cm1, cm2) + transport := getHTTPSTransport(kubeClient) + Expect(transport).ToNot(BeNil()) + Expect(transport.TLSClientConfig.RootCAs).ToNot(BeNil()) + }) + }) +}) + +var _ = Describe("IsCommonSlimmed", func() { + var tmpDir string + + BeforeEach(func() { + tmpDir = createTempDir("vp-slimmed-test") + }) + + AfterEach(func() { + cleanupTempDir(tmpDir) + }) + + Context("when common/operator-install directory exists", func() { + It("should return false (not slimmed)", func() { + err := os.MkdirAll(filepath.Join(tmpDir, "common", "operator-install"), 0755) + Expect(err).ToNot(HaveOccurred()) + + Expect(IsCommonSlimmed(tmpDir)).To(BeFalse()) + }) + }) + + Context("when common/operator-install directory does not exist", func() { + It("should return true (slimmed)", func() { + Expect(IsCommonSlimmed(tmpDir)).To(BeTrue()) + }) + }) + + Context("when common directory exists but operator-install does not", func() { + It("should return true (slimmed)", func() { + err := os.MkdirAll(filepath.Join(tmpDir, "common"), 0755) + Expect(err).ToNot(HaveOccurred()) + + Expect(IsCommonSlimmed(tmpDir)).To(BeTrue()) + }) + }) +}) + +var _ = Describe("IntOrZero", func() { + Context("when the key exists and has a valid integer value", func() { + It("should return the integer value", func() { + secret := map[string][]byte{ + "appID": []byte("12345"), + } + val, err := IntOrZero(secret, "appID") + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(int64(12345))) + }) + }) + + Context("when the key does not exist", func() { + It("should return 0", func() { + secret := map[string][]byte{} + val, err := IntOrZero(secret, "appID") + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(int64(0))) + }) + }) + + Context("when the key exists but has an invalid value", func() { + It("should return an error", func() { + secret := map[string][]byte{ + "appID": []byte("not-a-number"), + } + _, err := IntOrZero(secret, "appID") + Expect(err).To(HaveOccurred()) + }) + }) + + Context("when the key exists with a negative value", func() { + It("should return the negative integer", func() { + secret := map[string][]byte{ + "appID": []byte("-42"), + } + val, err := IntOrZero(secret, "appID") + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(int64(-42))) + }) + }) + + Context("when the key exists with a zero value", func() { + It("should return 0", func() { + secret := map[string][]byte{ + "appID": []byte("0"), + } + val, err := IntOrZero(secret, "appID") + Expect(err).ToNot(HaveOccurred()) + Expect(val).To(Equal(int64(0))) + }) + }) + + Context("when the key exists with an empty string value", func() { + It("should return an error", func() { + secret := map[string][]byte{ + "appID": []byte(""), + } + _, err := IntOrZero(secret, "appID") + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("DefaultRandRead", func() { + It("should fill the buffer with random bytes", func() { + buf := make([]byte, 32) + n, err := DefaultRandRead(buf) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(32)) + // Verify not all zeros (extremely unlikely with random data) + allZeros := true + for _, b := range buf { + if b != 0 { + allZeros = false + break + } + } + Expect(allZeros).To(BeFalse()) + }) + + It("should fill different buffers with different data", func() { + buf1 := make([]byte, 32) + buf2 := make([]byte, 32) + _, err1 := DefaultRandRead(buf1) + _, err2 := DefaultRandRead(buf2) + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + Expect(buf1).ToNot(Equal(buf2)) + }) +}) + +var _ = Describe("logOnce", func() { + It("should not panic when called multiple times with same message", func() { + // Reset the logKeys map for a clean test + logKeys = map[string]bool{} + Expect(func() { + logOnce("test message for logOnce") + logOnce("test message for logOnce") + logOnce("test message for logOnce") + }).ToNot(Panic()) + }) + + It("should record the message in the logKeys map", func() { + logKeys = map[string]bool{} + logOnce("unique log message") + Expect(logKeys).To(HaveKey("unique log message")) + Expect(logKeys["unique log message"]).To(BeTrue()) + }) + + It("should handle multiple different messages", func() { + logKeys = map[string]bool{} + logOnce("message one") + logOnce("message two") + Expect(logKeys).To(HaveKey("message one")) + Expect(logKeys).To(HaveKey("message two")) + Expect(logKeys).To(HaveLen(2)) + }) +}) + +var _ = Describe("createNamespace", func() { + var kubeClient kubernetes.Interface + + BeforeEach(func() { + kubeClient = fake.NewSimpleClientset() + }) + + Context("when the namespace does not exist", func() { + It("should create it", func() { + err := createNamespace(kubeClient, "new-namespace") + Expect(err).ToNot(HaveOccurred()) + + ns, err := kubeClient.CoreV1().Namespaces().Get(context.Background(), "new-namespace", metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(ns.Name).To(Equal("new-namespace")) + }) + }) + + Context("when the namespace already exists", func() { + BeforeEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "existing-ns"}} + _, err := kubeClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should not return an error", func() { + err := createNamespace(kubeClient, "existing-ns") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("when there is an API error", func() { + It("should return the error", func() { + fakeClient := fake.NewSimpleClientset() + fakeClient.PrependReactor("get", "namespaces", func(testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, kubeerrors.NewInternalError(fmt.Errorf("internal error")) + }) + err := createNamespace(fakeClient, "error-ns") + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("createTrustedBundleCM", func() { + var kubeClient kubernetes.Interface + + BeforeEach(func() { + kubeClient = fake.NewSimpleClientset() + }) + + Context("when the configmap does not exist", func() { + It("should create it with the correct labels", func() { + err := createTrustedBundleCM(kubeClient, "test-namespace") + Expect(err).ToNot(HaveOccurred()) + + cm, err := kubeClient.CoreV1().ConfigMaps("test-namespace").Get(context.Background(), "trusted-ca-bundle", metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(cm.Labels).To(HaveKeyWithValue("config.openshift.io/inject-trusted-cabundle", "true")) + }) + }) + + Context("when the configmap already exists", func() { + BeforeEach(func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "trusted-ca-bundle", + Namespace: "test-namespace", + }, + } + _, err := kubeClient.CoreV1().ConfigMaps("test-namespace").Create(context.Background(), cm, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should not return an error", func() { + err := createTrustedBundleCM(kubeClient, "test-namespace") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("when there is a get error other than NotFound", func() { + It("should return the error", func() { + fakeClient := fake.NewSimpleClientset() + fakeClient.PrependReactor("get", "configmaps", func(testing.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, kubeerrors.NewInternalError(fmt.Errorf("internal error")) + }) + err := createTrustedBundleCM(fakeClient, "test-namespace") + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/internal/controller/values_test.go b/internal/controller/values_test.go index 2b8a6c9ad..882378211 100644 --- a/internal/controller/values_test.go +++ b/internal/controller/values_test.go @@ -204,4 +204,241 @@ var _ = Describe("helmTpl", func() { Expect(err).To(HaveOccurred()) Expect(rendered).To(BeEmpty()) }) + + It("should render template with empty values", func() { + simpleTemplate := "static text" + values := map[string]any{} + valueFiles := []string{} + + rendered, err := helmTpl(simpleTemplate, valueFiles, values) + Expect(err).ToNot(HaveOccurred()) + Expect(rendered).To(Equal("static text")) + }) +}) + +var _ = Describe("MergeHelmValues", func() { + var td string + + BeforeEach(func() { + var err error + td, err = os.MkdirTemp("", "vp-merge-test") + Expect(err).ToNot(HaveOccurred()) + }) + AfterEach(func() { + os.RemoveAll(td) + }) + + Context("with no files", func() { + It("should return empty map", func() { + result, err := mergeHelmValues() + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Context("with non-existent files", func() { + It("should skip missing files", func() { + result, err := mergeHelmValues("/nonexistent/file.yaml") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Context("with valid values files", func() { + It("should merge values from multiple files", func() { + file1 := filepath.Join(td, "values1.yaml") + file2 := filepath.Join(td, "values2.yaml") + err := os.WriteFile(file1, []byte("key1: value1\nshared: from-file1\n"), 0600) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(file2, []byte("key2: value2\nshared: from-file2\n"), 0600) + Expect(err).ToNot(HaveOccurred()) + + result, err := mergeHelmValues(file1, file2) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveKeyWithValue("key1", "value1")) + Expect(result).To(HaveKeyWithValue("key2", "value2")) + // Later files take precedence (CoalesceTables with dst precedence) + Expect(result).To(HaveKey("shared")) + }) + }) + + Context("with invalid YAML file", func() { + It("should return an error", func() { + file := filepath.Join(td, "invalid.yaml") + err := os.WriteFile(file, []byte("{{invalid yaml}}"), 0600) + Expect(err).ToNot(HaveOccurred()) + + _, err = mergeHelmValues(file) + Expect(err).To(HaveOccurred()) + }) + }) +}) + +var _ = Describe("GetClusterGroupValue", func() { + Context("when clusterGroup key exists", func() { + It("should return the value for the requested key", func() { + values := map[string]any{ + "clusterGroup": map[string]any{ + "name": "test-group", + }, + } + result := getClusterGroupValue("name", values) + Expect(result).To(Equal("test-group")) + }) + }) + + Context("when clusterGroup key does not exist", func() { + It("should return nil", func() { + values := map[string]any{ + "other": "value", + } + result := getClusterGroupValue("name", values) + Expect(result).To(BeNil()) + }) + }) + + Context("when requested key does not exist in clusterGroup", func() { + It("should return nil", func() { + values := map[string]any{ + "clusterGroup": map[string]any{ + "name": "test-group", + }, + } + result := getClusterGroupValue("nonexistent", values) + Expect(result).To(BeNil()) + }) + }) +}) + +var _ = Describe("CountApplicationsAndSets", func() { + Context("with nil input", func() { + It("should return 0, 0", func() { + apps, appsets := countApplicationsAndSets(nil) + Expect(apps).To(Equal(0)) + Expect(appsets).To(Equal(0)) + }) + }) + + Context("with non-map input", func() { + It("should return 0, 0", func() { + apps, appsets := countApplicationsAndSets("not a map") + Expect(apps).To(Equal(0)) + Expect(appsets).To(Equal(0)) + }) + }) + + Context("with applications only", func() { + It("should count applications correctly", func() { + input := map[string]any{ + "app1": map[string]any{"name": "app1"}, + "app2": map[string]any{"name": "app2"}, + } + apps, appsets := countApplicationsAndSets(input) + Expect(apps).To(Equal(2)) + Expect(appsets).To(Equal(0)) + }) + }) + + Context("with applicationSets only", func() { + It("should count applicationSets correctly", func() { + input := map[string]any{ + "appset1": map[string]any{"generators": []any{"gen1"}}, + "appset2": map[string]any{"generatorFile": "file.yaml"}, + } + apps, appsets := countApplicationsAndSets(input) + Expect(apps).To(Equal(0)) + Expect(appsets).To(Equal(2)) + }) + }) + + Context("with mixed applications and applicationSets", func() { + It("should count both correctly", func() { + input := map[string]any{ + "app1": map[string]any{"name": "app1"}, + "appset1": map[string]any{"generators": []any{"gen1"}}, + "app2": map[string]any{"chart": "chart1"}, + } + apps, appsets := countApplicationsAndSets(input) + Expect(apps).To(Equal(2)) + Expect(appsets).To(Equal(1)) + }) + }) + + Context("with applicationSet using different keys", func() { + It("should detect useGeneratorValues key", func() { + input := map[string]any{ + "appset": map[string]any{"useGeneratorValues": true}, + } + apps, appsets := countApplicationsAndSets(input) + Expect(apps).To(Equal(0)) + Expect(appsets).To(Equal(1)) + }) + + It("should detect destinationServer key", func() { + input := map[string]any{ + "appset": map[string]any{"destinationServer": "server"}, + } + apps, appsets := countApplicationsAndSets(input) + Expect(apps).To(Equal(0)) + Expect(appsets).To(Equal(1)) + }) + + It("should detect destinationNamespace key", func() { + input := map[string]any{ + "appset": map[string]any{"destinationNamespace": "ns"}, + } + apps, appsets := countApplicationsAndSets(input) + Expect(apps).To(Equal(0)) + Expect(appsets).To(Equal(1)) + }) + }) + + Context("with non-map sub-entries", func() { + It("should skip non-map values", func() { + input := map[string]any{ + "app1": map[string]any{"name": "app1"}, + "scalar": "not-a-map", + } + apps, appsets := countApplicationsAndSets(input) + Expect(apps).To(Equal(1)) + Expect(appsets).To(Equal(0)) + }) + }) +}) + +var _ = Describe("GitOpsConfig getValueWithDefault", func() { + Context("when value exists in config", func() { + It("should return the configured value", func() { + config := GitOpsConfig{"key1": "value1"} + Expect(config.getValueWithDefault("key1")).To(Equal("value1")) + }) + }) + + Context("when value does not exist in config but has a default", func() { + It("should return the default value", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("gitops.channel")).To(Equal(GitOpsDefaultChannel)) + }) + }) + + Context("when value does not exist anywhere", func() { + It("should return empty string", func() { + config := GitOpsConfig{} + Expect(config.getValueWithDefault("nonexistent.key")).To(Equal("")) + }) + }) + + Context("when config overrides a default", func() { + It("should return the config value not the default", func() { + config := GitOpsConfig{"gitops.channel": "custom-channel"} + Expect(config.getValueWithDefault("gitops.channel")).To(Equal("custom-channel")) + }) + }) + + Context("when config is nil", func() { + It("should return the default value", func() { + var config GitOpsConfig + Expect(config.getValueWithDefault("gitops.channel")).To(Equal(GitOpsDefaultChannel)) + }) + }) })