diff --git a/.github/workflows/test-e2e-bundle.yml b/.github/workflows/test-e2e-bundle.yml new file mode 100644 index 0000000..096f764 --- /dev/null +++ b/.github/workflows/test-e2e-bundle.yml @@ -0,0 +1,45 @@ +name: Bundle E2E Tests + +on: + push: + pull_request: + +jobs: + test-bundle-e2e: + name: Run on Ubuntu + runs-on: ubuntu-latest + steps: + - name: Clone the code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install the latest version of kind + run: | + curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + + - name: Verify kind installation + run: kind version + + - name: Install helm + uses: azure/setup-helm@v4.3.0 + + # func CLI is needed in some e2e tests ATM + - name: Install func cli + uses: functions-dev/action@main + with: + version: nightly # use nightly as long as we use the latest in the operator too + + - name: Setup KinD cluster + run: make create-kind-cluster + + - name: Running Bundle Test e2e + env: + REGISTRY_INSECURE: true + REGISTRY: kind-registry:5000 + run: make test-e2e-bundle diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 9d897b0..aeadf34 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -32,6 +32,8 @@ jobs: # func CLI is needed in some e2e tests ATM - name: Install func cli uses: functions-dev/action@main + with: + version: nightly # use nightly as long as we use the latest in the operator too - name: Setup KinD cluster run: make create-kind-cluster diff --git a/Makefile b/Makefile index 845d973..c4fa061 100644 --- a/Makefile +++ b/Makefile @@ -120,7 +120,15 @@ test: manifests generate fmt vet setup-envtest ## Run tests. .PHONY: test-e2e ## Run e2e tests. test-e2e: - go test ./test/e2e/ -v -ginkgo.v + go test ./test/e2e/ -v -ginkgo.v -ginkgo.timeout=1h -ginkgo.label-filter="!bundle" + +.PHONY: test-e2e-bundle ## Run bundle e2e tests. +test-e2e-bundle: operator-sdk docker-build docker-push bundle bundle-build bundle-push install-olm-in-cluster + OPERATOR_SDK=$(OPERATOR_SDK) BUNDLE_IMG=$(BUNDLE_IMG) go test -timeout 1h ./test/e2e/ -v -ginkgo.v -ginkgo.timeout=1h -ginkgo.label-filter="bundle" + +.PHONY: install-olm-in-cluster +install-olm-in-cluster: operator-sdk ## Install OLM in cluster if not already installed. + @$(OPERATOR_SDK) olm status || $(OPERATOR_SDK) olm install .PHONY: lint lint: golangci-lint ## Run golangci-lint linter @@ -381,4 +389,4 @@ catalog-push: ## Push a catalog image. .PHONY: mocksgen gen-mocks: ${MOCKERY} - ${MOCKERY} \ No newline at end of file + ${MOCKERY} diff --git a/cmd/main.go b/cmd/main.go index 04bede4..c11fa2c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,10 +22,12 @@ import ( "flag" "os" "path/filepath" + "strings" "time" "github.com/functions-dev/func-operator/internal/git" "github.com/functions-dev/func-operator/internal/monitoring" + "sigs.k8s.io/controller-runtime/pkg/cache" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -194,6 +196,20 @@ func main() { }) } + watchNamespaces := getWatchNamespaces() + var cacheOpts cache.Options + if len(watchNamespaces) > 0 { + setupLog.Info("Operator watching specific namespaces", "namespaces", watchNamespaces) + + // Map the namespaces into the Cache DefaultNamespaces map + cacheOpts.DefaultNamespaces = make(map[string]cache.Config) + for _, ns := range watchNamespaces { + cacheOpts.DefaultNamespaces[strings.TrimSpace(ns)] = cache.Config{} + } + } else { + setupLog.Info("Operator watching all namespaces") + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, @@ -212,6 +228,7 @@ func main() { // if you are doing or is intended to do any operation such as perform cleanups // after the manager stops then its usage might be unsafe. // LeaderElectionReleaseOnCancel: true, + Cache: cacheOpts, }) if err != nil { setupLog.Error(err, "unable to start manager") @@ -282,3 +299,15 @@ func main() { os.Exit(1) } } + +// getWatchNamespaces returns the Namespaces the operator should be watching for changes +func getWatchNamespaces() []string { + watchNamespaceEnvVar := "WATCH_NAMESPACE" + ns, found := os.LookupEnv(watchNamespaceEnvVar) + if !found || ns == "" { + return nil // Return nil to signify "watch all namespaces" + } + + // Split by comma to support multiple namespaces + return strings.Split(ns, ",") +} diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 00243d5..d3bf9c6 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -65,6 +65,11 @@ spec: - --health-probe-bind-address=:8081 - --disable-func-cli-update=true - --func-cli-path=/func + env: + - name: WATCH_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.annotations['olm.targetNamespaces'] image: controller:latest imagePullPolicy: Always name: manager diff --git a/config/manifests/bases/func-operator.clusterserviceversion.yaml b/config/manifests/bases/func-operator.clusterserviceversion.yaml index 27fd6e2..3a315af 100644 --- a/config/manifests/bases/func-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/func-operator.clusterserviceversion.yaml @@ -25,11 +25,11 @@ spec: deployments: null strategy: "" installModes: - - supported: false + - supported: true type: OwnNamespace - - supported: false + - supported: true type: SingleNamespace - - supported: false + - supported: true type: MultiNamespace - supported: true type: AllNamespaces diff --git a/config/rbac/deploy_function_clusterrole.yaml b/config/rbac/deploy_function_clusterrole.yaml deleted file mode 100644 index 28cbe29..0000000 --- a/config/rbac/deploy_function_clusterrole.yaml +++ /dev/null @@ -1,21 +0,0 @@ ---- -# Role needed for service accounts running the deploy task of the deploy pipeline - -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: deploy-function -rules: -- apiGroups: - - "serving.knative.dev" - resources: - - services - - routes - verbs: - - create - - delete - - get - - list - - patch - - update - - watch diff --git a/config/rbac/deploy_function_clusterrole_binding.yaml b/config/rbac/deploy_function_clusterrole_binding.yaml deleted file mode 100644 index 18493ef..0000000 --- a/config/rbac/deploy_function_clusterrole_binding.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: manager-deploy-function -roleRef: - # we need to bind this to the controller/manager too, otherwise we cannot grant it from the controller - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: deploy-function -subjects: -- kind: ServiceAccount - name: controller-manager - namespace: system diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index ccdfa78..95a5900 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -26,6 +26,3 @@ resources: - function_editor_role.yaml - function_viewer_role.yaml -- deploy_function_clusterrole.yaml -- deploy_function_clusterrole_binding.yaml - diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 14949bf..20d20a4 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -84,6 +84,7 @@ rules: - rbac.authorization.k8s.io resources: - rolebindings + - roles verbs: - create - delete @@ -95,6 +96,7 @@ rules: - apiGroups: - serving.knative.dev resources: + - routes - services verbs: - create diff --git a/internal/controller/function_controller.go b/internal/controller/function_controller.go index 339692a..6c28b92 100644 --- a/internal/controller/function_controller.go +++ b/internal/controller/function_controller.go @@ -44,6 +44,10 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" ) +const ( + deployFunctionRoleName = "func-operator-deploy-function" +) + // FunctionReconciler reconciles a Function object type FunctionReconciler struct { client.Client @@ -58,11 +62,11 @@ type FunctionReconciler struct { // +kubebuilder:rbac:groups=functions.dev,resources=functions/finalizers,verbs=update // +kubebuilder:rbac:groups="",resources=secrets;services;persistentvolumeclaims,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="apps",resources=deployments,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups="serving.knative.dev",resources=services,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="serving.knative.dev",resources=services;routes,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="eventing.knative.dev",resources=triggers,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=tekton.dev,resources=pipelines;pipelineruns,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=tekton.dev,resources=taskruns,verbs=get;list;watch -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings;roles,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=http.keda.sh,resources=httpscaledobjects,verbs=get;list;watch;create;update;patch;delete // Reconcile a Function with status update @@ -213,9 +217,67 @@ func (r *FunctionReconciler) updateFunctionStatus(function *v1alpha1.Function, m } func (r *FunctionReconciler) setupPipelineRBAC(ctx context.Context, function *v1alpha1.Function) error { + if err := r.ensureDeployFunctionRole(ctx, function.Namespace); err != nil { + return fmt.Errorf("failed to ensure deploy-function role: %w", err) + } + + if err := r.ensureDeployFunctionRoleBinding(ctx, function); err != nil { + return fmt.Errorf("failed to ensure deploy-function role binding: %w", err) + } + + return nil +} + +// ensureDeployFunctionRole ensures the deploy-function Role exists in the namespace and is up-to-date. +// This is a namespace-scoped Role so multiple operator instances won't conflict. +func (r *FunctionReconciler) ensureDeployFunctionRole(ctx context.Context, namespace string) error { + logger := log.FromContext(ctx) + + expectedRole := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: deployFunctionRoleName, + Namespace: namespace, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"serving.knative.dev"}, + Resources: []string{"services", "routes"}, + Verbs: []string{"create", "delete", "get", "list", "patch", "update", "watch"}, + }, + }, + } + + foundRole := &rbacv1.Role{} + err := r.Get(ctx, types.NamespacedName{Name: expectedRole.Name, Namespace: expectedRole.Namespace}, foundRole) + if err != nil { + if apierrors.IsNotFound(err) { + if err := r.Create(ctx, expectedRole); err != nil { + return fmt.Errorf("failed to create role: %w", err) + } + logger.Info("Created deploy-function role") + return nil + } + return fmt.Errorf("failed to get role: %w", err) + } + + // Role exists - update if needed + if !equality.Semantic.DeepEqual(expectedRole.Rules, foundRole.Rules) { + foundRole.Rules = expectedRole.Rules + if err := r.Update(ctx, foundRole); err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + logger.Info("Updated deploy-function role") + } else { + logger.Info("Deploy-function role already up to date") + } + + return nil +} + +// ensureDeployFunctionRoleBinding ensures the RoleBinding for the deploy-function role exists and is up-to-date. +func (r *FunctionReconciler) ensureDeployFunctionRoleBinding(ctx context.Context, function *v1alpha1.Function) error { logger := log.FromContext(ctx) - logger.Info("Create rolebinding for deploy-function role") expectedRoleBinding := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ Name: "deploy-function-default", @@ -236,40 +298,39 @@ func (r *FunctionReconciler) setupPipelineRBAC(ctx context.Context, function *v1 Namespace: function.Namespace, }}, RoleRef: rbacv1.RoleRef{ - Kind: "ClusterRole", - Name: "func-operator-deploy-function", APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: deployFunctionRoleName, }, } + foundRoleBinding := &rbacv1.RoleBinding{} err := r.Get(ctx, types.NamespacedName{Name: expectedRoleBinding.Name, Namespace: expectedRoleBinding.Namespace}, foundRoleBinding) if err != nil { if apierrors.IsNotFound(err) { if err := r.Create(ctx, expectedRoleBinding); err != nil { - return fmt.Errorf("failed to create role binding for deploy-function role: %w", err) + return fmt.Errorf("failed to create role binding: %w", err) } - logger.Info("Created role binding for deploy-function role") + logger.Info("Created deploy-function role binding") return nil } - return fmt.Errorf("failed to check if deploy-function role binding already exists: %w", err) + return fmt.Errorf("failed to get role binding: %w", err) } // Update if needed if !equality.Semantic.DeepDerivative(expectedRoleBinding, foundRoleBinding) { - // Copy expected values into found object foundRoleBinding.Subjects = expectedRoleBinding.Subjects foundRoleBinding.RoleRef = expectedRoleBinding.RoleRef foundRoleBinding.OwnerReferences = expectedRoleBinding.OwnerReferences if err := r.Update(ctx, foundRoleBinding); err != nil { - return fmt.Errorf("failed to update deploy-function role binding: %w", err) + return fmt.Errorf("failed to update role binding: %w", err) } - logger.Info("Updated deploy-function role binding") - return nil + } else { + logger.Info("Deploy-function role binding already up to date") } - logger.Info("Role binding already exists and is up to date. No need to update") return nil } diff --git a/test/e2e/bundle_test.go b/test/e2e/bundle_test.go new file mode 100644 index 0000000..726c3d0 --- /dev/null +++ b/test/e2e/bundle_test.go @@ -0,0 +1,410 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "time" + + functionsdevv1alpha1 "github.com/functions-dev/func-operator/api/v1alpha1" + "github.com/functions-dev/func-operator/test/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" +) + +// The bundle e2e test run with a dedicated build tag to not infer with the other tests, as the bundle offers different +// installation modes and also can make the operator to run in multiple namespaces + +var _ = Describe("Bundle", Label("bundle"), Ordered, func() { + + var ( + bundleImage string // set in BeforeAll + + namespaces []string + ) + + SetDefaultEventuallyTimeout(5 * time.Minute) + SetDefaultEventuallyPollingInterval(time.Second) + + BeforeAll(func() { + bundleImage = os.Getenv("BUNDLE_IMG") + Expect(bundleImage).ToNot(BeEmpty(), "BUNDLE_IMG must be given") + }) + + AfterEach(func() { + specReport := CurrentSpecReport() + if specReport.Failed() { + // collect logs in case it failed + By("Collecting logs from deployed operators") + for _, ns := range namespaces { + By("Logs from operator in namespace " + ns) + cmd := exec.Command("kubectl", "logs", "-l", "control-plane=controller-manager", "--namespace", ns) + controllerLogs, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err) + } + } + + By("Collecting functions") + cmd := exec.Command("kubectl", "get", "function", "-A", "-o", "yaml") + out, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Functions:\n %s", out) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get functions: %s", err) + } + } + }) + + Context("with OwnNamespace installMode", func() { + + BeforeAll(func() { + namespaces = createMultipleNamespaceAndDeployFunction(2) + + By("Installing the operator into " + namespaces[0]) + out, err := utils.OperatorSdkRun("run", "bundle", + "--namespace", namespaces[0], + "--install-mode", "OwnNamespace", + fmt.Sprintf("--skip-tls-verify=%v", registryInsecure), + bundleImage) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + }) + + AfterAll(func() { + specReport := CurrentSpecReport() + if !specReport.Failed() { + By("Uninstalling the operator") + out, err := utils.OperatorSdkRun("cleanup", + "func-operator", + "--namespace", namespaces[0]) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + By("Cleanup resources") + cleanupNamespaces(namespaces) + } + }) + + It("should reconcile function in own namespace", func() { + CreateFunctionAndWaitForReady(namespaces[0]) + }) + It("should not reconcile function in other namespace", func() { + CreateFunctionAndWaitForConsistentlyNotReconciled(namespaces[1]) + }) + }) + + Context("with SingleNamespace installMode", func() { + BeforeAll(func() { + By("Setting up test namespaces") + namespaces = createMultipleNamespaceAndDeployFunction(3) + + By("Installing the operator into " + namespaces[0] + " for " + namespaces[1]) + out, err := utils.OperatorSdkRun("run", "bundle", + "--namespace", namespaces[0], + "--install-mode", fmt.Sprintf("SingleNamespace=%s", namespaces[1]), + fmt.Sprintf("--skip-tls-verify=%v", registryInsecure), + bundleImage) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + }) + + AfterAll(func() { + specReport := CurrentSpecReport() + if !specReport.Failed() { + By("Uninstalling the operator") + out, err := utils.OperatorSdkRun("cleanup", + "func-operator", + "--namespace", namespaces[0]) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + By("Cleanup resources") + cleanupNamespaces(namespaces) + } + }) + + It("should reconcile function in dedicated namespace", func() { + CreateFunctionAndWaitForReady(namespaces[1]) + }) + It("should not reconcile function in other namespace", func() { + CreateFunctionAndWaitForConsistentlyNotReconciled(namespaces[0]) + CreateFunctionAndWaitForConsistentlyNotReconciled(namespaces[2]) + }) + }) + + Context("with MultiNamespace installMode", func() { + BeforeAll(func() { + By("Setting up test namespaces") + namespaces = createMultipleNamespaceAndDeployFunction(4) + + By("Installing the operator into " + namespaces[0] + " for " + namespaces[1] + " and " + namespaces[2]) + out, err := utils.OperatorSdkRun("run", "bundle", + "--namespace", namespaces[0], + "--install-mode", fmt.Sprintf("MultiNamespace=%s,%s", namespaces[1], namespaces[2]), + fmt.Sprintf("--skip-tls-verify=%v", registryInsecure), + bundleImage) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + }) + + AfterAll(func() { + specReport := CurrentSpecReport() + if !specReport.Failed() { + By("Uninstalling the operator") + out, err := utils.OperatorSdkRun("cleanup", + "func-operator", + "--namespace", namespaces[0]) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + By("Cleanup resources") + cleanupNamespaces(namespaces) + } + }) + + It("should reconcile function in dedicated namespaces", func() { + CreateFunctionAndWaitForReady(namespaces[1]) + CreateFunctionAndWaitForReady(namespaces[2]) + }) + It("should not reconcile function in other namespace", func() { + CreateFunctionAndWaitForConsistentlyNotReconciled(namespaces[0]) + CreateFunctionAndWaitForConsistentlyNotReconciled(namespaces[3]) + }) + }) + + Context("with two instances with SingleNamespace installMode installed into two distinct namespaces", func() { + BeforeAll(func() { + By("Setting up test namespaces") + namespaces = createMultipleNamespaceAndDeployFunction(4) + + By("Installing the operator into " + namespaces[0] + " for " + namespaces[1]) + out, err := utils.OperatorSdkRun("run", "bundle", + "--namespace", namespaces[0], + "--install-mode", fmt.Sprintf("SingleNamespace=%s", namespaces[1]), + fmt.Sprintf("--skip-tls-verify=%v", registryInsecure), + bundleImage) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + By("Installing the operator into " + namespaces[2] + " for " + namespaces[3]) + out, err = utils.OperatorSdkRun("run", "bundle", + "--namespace", namespaces[2], + "--install-mode", fmt.Sprintf("SingleNamespace=%s", namespaces[3]), + fmt.Sprintf("--skip-tls-verify=%v", registryInsecure), + bundleImage) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + }) + + AfterAll(func() { + specReport := CurrentSpecReport() + if !specReport.Failed() { + By("Uninstalling the operator from " + namespaces[0]) + out, err := utils.OperatorSdkRun("cleanup", + "func-operator", + "--namespace", namespaces[0], + "--delete-operator-groups") // dont delete CRDs, as operator in ns3 still has them + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + By("Uninstalling the operator from " + namespaces[2]) + out, err = utils.OperatorSdkRun("cleanup", + "func-operator", + "--namespace", namespaces[2]) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + By("Cleanup resources") + cleanupNamespaces(namespaces) + } + }) + + It("should reconcile function in dedicated namespaces", func() { + CreateFunctionAndWaitForReady(namespaces[1]) + CreateFunctionAndWaitForReady(namespaces[3]) + }) + It("should not reconcile function in other namespace", func() { + CreateFunctionAndWaitForConsistentlyNotReconciled(namespaces[0]) + CreateFunctionAndWaitForConsistentlyNotReconciled(namespaces[2]) + }) + }) + + Context("with AllNamespace installMode", func() { + BeforeAll(func() { + By("Setting up test namespaces") + namespaces = createMultipleNamespaceAndDeployFunction(2) + + By("Installing the operator into " + namespaces[0]) + out, err := utils.OperatorSdkRun("run", "bundle", + "--namespace", namespaces[0], + "--install-mode", "AllNamespaces", + fmt.Sprintf("--skip-tls-verify=%v", registryInsecure), + bundleImage) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + }) + + AfterAll(func() { + specReport := CurrentSpecReport() + if !specReport.Failed() { + By("Uninstalling the operator") + out, err := utils.OperatorSdkRun("cleanup", + "func-operator", + "--namespace", namespaces[0]) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + By("Cleanup resources") + cleanupNamespaces(namespaces) + } + }) + + It("should reconcile function in all namespaces", func() { + CreateFunctionAndWaitForReady(namespaces[0]) + CreateFunctionAndWaitForReady(namespaces[1]) + }) + }) +}) + +func CreateFunctionAndWaitForReady(namespace string) { + // Create a Function resource + function := &functionsdevv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "my-function-", + Namespace: namespace, + }, + Spec: functionsdevv1alpha1.FunctionSpec{ + Source: functionsdevv1alpha1.FunctionSpecSource{ + RepositoryURL: "https://github.com/creydr/func-go-hello-world", + }, + Registry: functionsdevv1alpha1.FunctionSpecRegistry{ + Path: registry, + Insecure: registryInsecure, + }, + }, + } + + err := k8sClient.Create(ctx, function) + Expect(err).NotTo(HaveOccurred()) + + funcBecomeReady := func(g Gomega) { + fn := &functionsdevv1alpha1.Function{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: function.Name, Namespace: function.Namespace}, fn) + g.Expect(err).NotTo(HaveOccurred()) + + for _, cond := range fn.Status.Conditions { + if cond.Type == functionsdevv1alpha1.TypeReady { + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + return + } + } + g.Expect(false).To(BeTrue(), "Ready condition not found") + } + + Eventually(funcBecomeReady, 5*time.Minute).Should(Succeed()) +} + +func CreateFunctionAndWaitForConsistentlyNotReconciled(namespace string) { + // Create a Function resource + function := &functionsdevv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "my-function-", + Namespace: namespace, + }, + Spec: functionsdevv1alpha1.FunctionSpec{ + Source: functionsdevv1alpha1.FunctionSpecSource{ + RepositoryURL: "https://github.com/creydr/func-go-hello-world", + }, + Registry: functionsdevv1alpha1.FunctionSpecRegistry{ + Path: registry, + Insecure: registryInsecure, + }, + }, + } + + err := k8sClient.Create(ctx, function) + Expect(err).NotTo(HaveOccurred()) + + funcNotReconciled := func(g Gomega) { + fn := &functionsdevv1alpha1.Function{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: function.Name, Namespace: function.Namespace}, fn) + g.Expect(err).NotTo(HaveOccurred()) + + // If the controller reconciled this, it would have set ObservedGeneration and Conditions + g.Expect(fn.Status.Conditions).To(BeEmpty(), "Conditions should remain empty if not reconciled") + } + + Consistently(funcNotReconciled, time.Minute).Should(Succeed()) +} + +func createNamespaceAndDeployFunction() string { + ns, err := utils.GetTestNamespace() + Expect(err).NotTo(HaveOccurred()) + + tempDir := fmt.Sprintf("%s/func-operator-e2e-%s", os.TempDir(), rand.String(10)) + Expect(err).NotTo(HaveOccurred()) + + cmd := exec.Command("git", "clone", "https://github.com/creydr/func-go-hello-world", tempDir) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + cmd = exec.Command("func", "deploy", + "--path", tempDir, + "--registry", registry, + "--registry-insecure", strconv.FormatBool(registryInsecure), + "--namespace", ns) + out, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + // cleanup the repo to not run into resource issues + cmd = exec.Command("rm", "-rf", tempDir) + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + return ns +} + +// createMultipleNamespaceAndDeployFunction creates multiple namespaces with functions +func createMultipleNamespaceAndDeployFunction(count int) []string { + namespaces := make([]string, count) + + for i := 0; i < count; i++ { + // parallelizing this via goroutines seems to lead to resource issues, therefore keeping it sequential + namespaces[i] = createNamespaceAndDeployFunction() + } + + return namespaces +} + +func cleanupNamespaces(namespaces []string) { + By("Cleaning up all resources") + for _, ns := range namespaces { + cmd := exec.Command("kubectl", "delete", "namespace", ns, "--ignore-not-found") + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + } +} diff --git a/test/utils/operator-sdk.go b/test/utils/operator-sdk.go new file mode 100644 index 0000000..43f4938 --- /dev/null +++ b/test/utils/operator-sdk.go @@ -0,0 +1,29 @@ +package utils + +import ( + "os" + "os/exec" + "sync" +) + +func OperatorSdkRun(command string, args ...string) (string, error) { + cmd := exec.Command(operatorSdkBinary(), append([]string{command}, args...)...) + + return Run(cmd) +} + +var ( + operatorSdkBinaryPath string + operatorSdkBinaryGetOnce sync.Once +) + +func operatorSdkBinary() string { + operatorSdkBinaryGetOnce.Do(func() { + operatorSdkBinaryPath = os.Getenv("OPERATOR_SDK") + if operatorSdkBinaryPath == "" { + operatorSdkBinaryPath = "operator-sdk" + } + }) + + return operatorSdkBinaryPath +} diff --git a/test/utils/utils.go b/test/utils/utils.go index 976920a..eb8de2c 100644 --- a/test/utils/utils.go +++ b/test/utils/utils.go @@ -23,6 +23,7 @@ import ( "strings" . "github.com/onsi/ginkgo/v2" // nolint:revive,staticcheck + "k8s.io/apimachinery/pkg/util/rand" ) // Run executes the provided command within this context @@ -68,3 +69,15 @@ func GetProjectDir() (string, error) { wd = strings.ReplaceAll(wd, "/test/e2e", "") return wd, nil } + +func GetTestNamespace() (string, error) { + name := fmt.Sprintf("test-%s", rand.String(8)) + cmd := exec.Command("kubectl", "create", "namespace", name) + _, err := Run(cmd) + + if err != nil { + return "", err + } + + return name, nil +}