From 1682d624964dc5ebac0f1e18f3b86018921de23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Thu, 30 Apr 2026 13:44:56 +0200 Subject: [PATCH 1/3] feat: use registry auth secret as image pull secret on default ServiceAccount When a registry auth secret is configured via spec.registry.authSecretRef, it is now also added to the default ServiceAccount's imagePullSecrets so that function pods can pull images from private registries at runtime. --- config/rbac/role.yaml | 1 + internal/controller/function_controller.go | 2 +- internal/controller/function_deploy.go | 30 +++++ internal/controller/function_deploy_test.go | 142 ++++++++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 internal/controller/function_deploy_test.go diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d3f2546..a885251 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -19,6 +19,7 @@ rules: - pods - pods/attach - secrets + - serviceaccounts - services verbs: - create diff --git a/internal/controller/function_controller.go b/internal/controller/function_controller.go index f413de6..6c44787 100644 --- a/internal/controller/function_controller.go +++ b/internal/controller/function_controller.go @@ -69,7 +69,7 @@ type FunctionReconciler struct { // +kubebuilder:rbac:groups=functions.dev,resources=functions,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=functions.dev,resources=functions/status,verbs=get;update;patch // +kubebuilder:rbac:groups=functions.dev,resources=functions/finalizers,verbs=update -// +kubebuilder:rbac:groups="",resources=pods;pods/attach;secrets;services;persistentvolumeclaims,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=pods;pods/attach;secrets;serviceaccounts;services;persistentvolumeclaims,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch // +kubebuilder:rbac:groups="apps",resources=deployments;replicasets,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 diff --git a/internal/controller/function_deploy.go b/internal/controller/function_deploy.go index c372c62..1ecf00a 100644 --- a/internal/controller/function_deploy.go +++ b/internal/controller/function_deploy.go @@ -49,6 +49,10 @@ func (r *FunctionReconciler) deploy(ctx context.Context, function *v1alpha1.Func defer os.Remove(authFile) deployOptions.RegistryAuthFile = authFile + + if err := r.ensureImagePullSecret(ctx, function); err != nil { + return fmt.Errorf("failed to ensure image pull secret: %w", err) + } } logger.Info("Deploying function", "deployOptions", deployOptions) @@ -62,6 +66,32 @@ func (r *FunctionReconciler) deploy(ctx context.Context, function *v1alpha1.Func return nil } +func (r *FunctionReconciler) ensureImagePullSecret(ctx context.Context, function *v1alpha1.Function) error { + logger := log.FromContext(ctx) + + secretName := function.Spec.Registry.AuthSecretRef.Name + + sa := &v1.ServiceAccount{} + if err := r.Get(ctx, types.NamespacedName{Name: "default", Namespace: function.Namespace}, sa); err != nil { + return fmt.Errorf("failed to get default service account: %w", err) + } + + for _, ref := range sa.ImagePullSecrets { + if ref.Name == secretName { + logger.Info("Image pull secret already present on default ServiceAccount", "secret", secretName) + return nil + } + } + + sa.ImagePullSecrets = append(sa.ImagePullSecrets, v1.LocalObjectReference{Name: secretName}) + if err := r.Update(ctx, sa); err != nil { + return fmt.Errorf("failed to update default service account with image pull secret: %w", err) + } + + logger.Info("Added image pull secret to default ServiceAccount", "secret", secretName) + return nil +} + func (r *FunctionReconciler) persistRegistryAuthSecret(ctx context.Context, function *v1alpha1.Function) (string, error) { logger := log.FromContext(ctx) diff --git a/internal/controller/function_deploy_test.go b/internal/controller/function_deploy_test.go new file mode 100644 index 0000000..bbc6b1c --- /dev/null +++ b/internal/controller/function_deploy_test.go @@ -0,0 +1,142 @@ +package controller + +import ( + functionsdevv1alpha1 "github.com/functions-dev/func-operator/api/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/client-go/tools/events" +) + +var _ = Describe("Function Deploy", func() { + Context("ensureImagePullSecret", func() { + var reconciler *FunctionReconciler + var testNamespace string + + BeforeEach(func() { + testNamespace = "deploy-test-" + rand.String(6) + ns := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNamespace}} + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + + sa := &v1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: testNamespace}} + Expect(k8sClient.Create(ctx, sa)).To(Succeed()) + + reconciler = &FunctionReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Recorder: &events.FakeRecorder{}, + } + }) + + It("should add the registry auth secret to the default ServiceAccount's imagePullSecrets", func() { + function := &functionsdevv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: "func-pull-secret", + Namespace: testNamespace, + }, + Spec: functionsdevv1alpha1.FunctionSpec{ + Repository: functionsdevv1alpha1.FunctionSpecRepository{ + URL: "https://github.com/foo/bar", + }, + Registry: functionsdevv1alpha1.FunctionSpecRegistry{ + AuthSecretRef: &v1.LocalObjectReference{ + Name: "my-registry-secret", + }, + }, + }, + } + + Expect(reconciler.ensureImagePullSecret(ctx, function)).To(Succeed()) + + sa := &v1.ServiceAccount{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "default", + Namespace: testNamespace, + }, sa)).To(Succeed()) + + Expect(sa.ImagePullSecrets).To(ContainElement(v1.LocalObjectReference{ + Name: "my-registry-secret", + })) + }) + + It("should be idempotent and not duplicate imagePullSecrets", func() { + function := &functionsdevv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: "func-idempotent", + Namespace: testNamespace, + }, + Spec: functionsdevv1alpha1.FunctionSpec{ + Repository: functionsdevv1alpha1.FunctionSpecRepository{ + URL: "https://github.com/foo/bar", + }, + Registry: functionsdevv1alpha1.FunctionSpecRegistry{ + AuthSecretRef: &v1.LocalObjectReference{ + Name: "my-registry-secret", + }, + }, + }, + } + + Expect(reconciler.ensureImagePullSecret(ctx, function)).To(Succeed()) + Expect(reconciler.ensureImagePullSecret(ctx, function)).To(Succeed()) + + sa := &v1.ServiceAccount{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "default", + Namespace: testNamespace, + }, sa)).To(Succeed()) + + count := 0 + for _, ref := range sa.ImagePullSecrets { + if ref.Name == "my-registry-secret" { + count++ + } + } + Expect(count).To(Equal(1)) + }) + + It("should preserve existing imagePullSecrets on the ServiceAccount", func() { + sa := &v1.ServiceAccount{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "default", + Namespace: testNamespace, + }, sa)).To(Succeed()) + + sa.ImagePullSecrets = []v1.LocalObjectReference{ + {Name: "existing-secret"}, + } + Expect(k8sClient.Update(ctx, sa)).To(Succeed()) + + function := &functionsdevv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: "func-preserve", + Namespace: testNamespace, + }, + Spec: functionsdevv1alpha1.FunctionSpec{ + Repository: functionsdevv1alpha1.FunctionSpecRepository{ + URL: "https://github.com/foo/bar", + }, + Registry: functionsdevv1alpha1.FunctionSpecRegistry{ + AuthSecretRef: &v1.LocalObjectReference{ + Name: "my-registry-secret", + }, + }, + }, + } + + Expect(reconciler.ensureImagePullSecret(ctx, function)).To(Succeed()) + + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "default", + Namespace: testNamespace, + }, sa)).To(Succeed()) + + Expect(sa.ImagePullSecrets).To(HaveLen(2)) + Expect(sa.ImagePullSecrets).To(ContainElement(v1.LocalObjectReference{Name: "existing-secret"})) + Expect(sa.ImagePullSecrets).To(ContainElement(v1.LocalObjectReference{Name: "my-registry-secret"})) + }) + }) +}) From 32122715e3bb2a8e949760decc7856ba868dae03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Thu, 30 Apr 2026 14:21:00 +0200 Subject: [PATCH 2/3] test: add e2e test for registry auth secret as imagePullSecret Verifies the operator adds spec.registry.authSecretRef to the default ServiceAccount's imagePullSecrets during a middleware-update redeploy. Uses a dummy dockerconfigjson secret against the unauthenticated kind-registry since enabling registry auth would require either per-repository scoping (unsupported by htpasswd) or a second registry container, both adding too much infra overhead for this wiring test. --- test/e2e/func_deploy_test.go | 119 +++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/test/e2e/func_deploy_test.go b/test/e2e/func_deploy_test.go index 50175c9..28ff15d 100644 --- a/test/e2e/func_deploy_test.go +++ b/test/e2e/func_deploy_test.go @@ -649,6 +649,125 @@ var _ = Describe("Operator", func() { sshKeyPath, sshRepoURL, functionNamespace, "my-ssh-function-") }) }) + // This test verifies that the operator adds the registry auth secret as an + // imagePullSecret to the default ServiceAccount during a redeploy. + // + // It uses a dummy dockerconfigjson secret and the unauthenticated kind-registry + // because the kind-registry's built-in htpasswd auth is all-or-nothing (no + // per-repository scoping), so enabling auth would break all other tests. Running + // a second authenticated registry adds too much infrastructure overhead for + // verifying this wiring. The unit tests in function_deploy_test.go cover the + // ensureImagePullSecret logic itself; this test confirms the operator calls it + // during a real redeploy. + Context("with a registry auth secret", func() { + var repoURL string + var repoDir string + var functionName, functionNamespace string + + BeforeEach(func() { + if os.Getenv("DEFAULT_DEPLOYER") == "keda" || os.Getenv("DEFAULT_DEPLOYER") == "raw" { + Skip("Skipping registry auth test for Keda & raw deployer, " + + "as those are not supported on used CLI version (1.20.x) of this tests") + } + + var err error + + username, password, _, cleanup, err := repoProvider.CreateRandomUser() + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(cleanup) + + _, repoURL, cleanup, err = repoProvider.CreateRandomRepo(username, false) + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(cleanup) + + functionNamespace, err = utils.GetTestNamespace() + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(cleanupNamespaces, functionNamespace) + + oldFuncVersion := "v1.20.2" + repoDir, err = utils.InitializeRepoWithFunction( + repoURL, + username, + password, + "go", + utils.WithCliVersion(oldFuncVersion)) + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(os.RemoveAll, repoDir) + + out, err := utils.RunFuncDeploy(repoDir, + utils.WithNamespace(functionNamespace), + utils.WithDeployCliVersion(oldFuncVersion)) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + utils.DeferCleanupOnSuccess(func() { + _, _ = utils.RunFunc("delete", "--path", repoDir, "--namespace", functionNamespace) + }) + + err = utils.CommitAndPush(repoDir, "Update func.yaml after deploy", "func.yaml") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + logFailedTestDetails(functionName, functionNamespace) + }) + + It("should add the registry auth secret as imagePullSecret on the default ServiceAccount", func() { + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "registry-auth-", + Namespace: functionNamespace, + }, + Type: v1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + v1.DockerConfigJsonKey: []byte(`{"auths":{"kind-registry:5000":{"auth":"dGVzdDp0ZXN0"}}}`), + }, + } + err := k8sClient.Create(ctx, secret) + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(func() { + _ = k8sClient.Delete(ctx, secret) + }) + + function := &functionsdevv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "my-function-pullsecret-", + Namespace: functionNamespace, + }, + Spec: functionsdevv1alpha1.FunctionSpec{ + Repository: functionsdevv1alpha1.FunctionSpecRepository{ + URL: repoURL, + }, + Registry: functionsdevv1alpha1.FunctionSpecRegistry{ + AuthSecretRef: &v1.LocalObjectReference{ + Name: secret.Name, + }, + }, + }, + } + + err = k8sClient.Create(ctx, function) + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(func() { + _, _ = utils.RunCmd("kubectl", "delete", "function", function.Name, "--namespace", function.Namespace) + }) + + functionName = function.Name + + Eventually(functionBecomesReady(functionName, functionNamespace)).Should(Succeed()) + + sa := &v1.ServiceAccount{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "default", + Namespace: functionNamespace, + }, sa) + Expect(err).NotTo(HaveOccurred()) + + Expect(sa.ImagePullSecrets).To(ContainElement(v1.LocalObjectReference{ + Name: secret.Name, + })) + }) + }) Context("with a private SSH repository", func() { var sshRepoURL string var repoDir string From a2af4a7913b7691924de7a79bbf71b1a72a7bd40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20St=C3=A4bler?= Date: Thu, 30 Apr 2026 14:52:51 +0200 Subject: [PATCH 3/3] fix: resolve lint issues in e2e tests Extract shared string constants (deployerKeda, deployerRaw, oldFuncCLIVersion) to fix goconst warnings and add nolint:dupl directives on intentionally duplicated BeforeEach blocks. --- test/e2e/e2e_test.go | 6 ++++++ test/e2e/func_deploy_test.go | 7 ++++--- test/e2e/func_middleware_update_test.go | 12 +++++++----- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 662d1d4..0910091 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -30,6 +30,12 @@ const namespace = "func-operator-system" // serviceAccountName created for the project const serviceAccountName = "func-operator-controller-manager" +const ( + deployerKeda = "keda" + deployerRaw = "raw" + oldFuncCLIVersion = "v1.20.2" +) + // logFailedTestDetails logs function resource and controller logs on test failure func logFailedTestDetails(functionName, functionNamespace string) { specReport := CurrentSpecReport() diff --git a/test/e2e/func_deploy_test.go b/test/e2e/func_deploy_test.go index 28ff15d..18150da 100644 --- a/test/e2e/func_deploy_test.go +++ b/test/e2e/func_deploy_test.go @@ -664,8 +664,9 @@ var _ = Describe("Operator", func() { var repoDir string var functionName, functionNamespace string - BeforeEach(func() { - if os.Getenv("DEFAULT_DEPLOYER") == "keda" || os.Getenv("DEFAULT_DEPLOYER") == "raw" { + BeforeEach(func() { //nolint:dupl + if os.Getenv("DEFAULT_DEPLOYER") == deployerKeda || + os.Getenv("DEFAULT_DEPLOYER") == deployerRaw { Skip("Skipping registry auth test for Keda & raw deployer, " + "as those are not supported on used CLI version (1.20.x) of this tests") } @@ -684,7 +685,7 @@ var _ = Describe("Operator", func() { Expect(err).NotTo(HaveOccurred()) utils.DeferCleanupOnSuccess(cleanupNamespaces, functionNamespace) - oldFuncVersion := "v1.20.2" + oldFuncVersion := oldFuncCLIVersion repoDir, err = utils.InitializeRepoWithFunction( repoURL, username, diff --git a/test/e2e/func_middleware_update_test.go b/test/e2e/func_middleware_update_test.go index 053f3a0..de2c3de 100644 --- a/test/e2e/func_middleware_update_test.go +++ b/test/e2e/func_middleware_update_test.go @@ -44,8 +44,9 @@ var _ = Describe("Middleware Update", func() { var repoDir string var functionName, functionNamespace string - BeforeEach(func() { - if os.Getenv("DEFAULT_DEPLOYER") == "keda" || os.Getenv("DEFAULT_DEPLOYER") == "raw" { + BeforeEach(func() { //nolint:dupl + if os.Getenv("DEFAULT_DEPLOYER") == deployerKeda || + os.Getenv("DEFAULT_DEPLOYER") == deployerRaw { Skip("Skipping middleware test for Keda & raw deployer, " + "as those are not supported on used CLI version (1.20.x) of this tests") } @@ -67,7 +68,7 @@ var _ = Describe("Middleware Update", func() { // Initialize repository with function code using OLD func CLI version // v1.20.2 has no middleware-version label and uses instance-compatible templates - oldFuncVersion := "v1.20.2" + oldFuncVersion := oldFuncCLIVersion repoDir, err = utils.InitializeRepoWithFunction( repoURL, username, @@ -253,7 +254,8 @@ var _ = Describe("Middleware Update", func() { var originalConfigMapData map[string]string BeforeEach(func() { - if os.Getenv("DEFAULT_DEPLOYER") == "keda" || os.Getenv("DEFAULT_DEPLOYER") == "raw" { + if os.Getenv("DEFAULT_DEPLOYER") == deployerKeda || + os.Getenv("DEFAULT_DEPLOYER") == deployerRaw { Skip("Skipping middleware test for Keda & raw deployer, " + "as those are not supported on used CLI version (1.20.x) of this tests") } @@ -302,7 +304,7 @@ var _ = Describe("Middleware Update", func() { // Initialize repository with function code using OLD func CLI version // to ensure middleware will be outdated - oldFuncVersion := "v1.20.2" + oldFuncVersion := oldFuncCLIVersion repoDir, err = utils.InitializeRepoWithFunction( repoURL, username,