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"})) + }) + }) +}) 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 50175c9..18150da 100644 --- a/test/e2e/func_deploy_test.go +++ b/test/e2e/func_deploy_test.go @@ -649,6 +649,126 @@ 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() { //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") + } + + 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 := oldFuncCLIVersion + 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 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,