From c974f449f3f715ae7b7a714373eec0898eaca7c6 Mon Sep 17 00:00:00 2001 From: Russell Centanni Date: Wed, 23 Mar 2022 15:10:29 -0400 Subject: [PATCH] feat: enable multiple pull secrets to be used by a service account --- e2e/tests/pullsecret/pullsecrets.go | 47 +++-- .../pullsecret/testdata/simple/devspace.yaml | 15 ++ pkg/devspace/pullsecrets/registry.go | 160 ++++++++++++++---- 3 files changed, 162 insertions(+), 60 deletions(-) diff --git a/e2e/tests/pullsecret/pullsecrets.go b/e2e/tests/pullsecret/pullsecrets.go index 480c32815c..1e87165915 100644 --- a/e2e/tests/pullsecret/pullsecrets.go +++ b/e2e/tests/pullsecret/pullsecrets.go @@ -3,6 +3,9 @@ package pullsecret import ( "context" "encoding/base64" + "os" + "sort" + "github.com/loft-sh/devspace/cmd" "github.com/loft-sh/devspace/cmd/flags" "github.com/loft-sh/devspace/e2e/framework" @@ -11,8 +14,6 @@ import ( "github.com/onsi/ginkgo" k8sv1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "os" - "sort" ) var _ = DevSpaceDescribe("pullsecret", func() { @@ -58,42 +59,40 @@ var _ = DevSpaceDescribe("pullsecret", func() { err = deployCmd.Run(f) framework.ExpectNoError(err) - // check if secrets are created + // check if named secret is created pullSecret, err := kubeClient.RawClient().CoreV1().Secrets(ns).Get(context.TODO(), "test-secret", metav1.GetOptions{}) framework.ExpectNoError(err) framework.ExpectEqual(len(pullSecret.Data), 1) registryAuthEncoded := base64.StdEncoding.EncodeToString([]byte("my-user:my-password")) - pullSecretDataValue := []byte(`{ - "auths": { - "ghcr.io": { - "auth": "` + registryAuthEncoded + `", - "email": "noreply@devspace.sh" - } - } - }`) + pullSecretDataValue := []byte(`{"auths":{"ghcr.io":{"auth":"` + registryAuthEncoded + `","email":"noreply@devspace.sh"}}}`) + framework.ExpectEqual(string(pullSecret.Data[k8sv1.DockerConfigJsonKey]), string(pullSecretDataValue)) + + // check if default secrets are created and merged + pullSecret, err = kubeClient.RawClient().CoreV1().Secrets(ns).Get(context.TODO(), "devspace-pull-secrets", metav1.GetOptions{}) + framework.ExpectNoError(err) + framework.ExpectEqual(len(pullSecret.Data), 1) + registryAuth2Encoded := base64.StdEncoding.EncodeToString([]byte("my-user2:my-password2")) + registryAuth3Encoded := base64.StdEncoding.EncodeToString([]byte("my-user3:my-password3")) + pullSecretDataValue = []byte(`{"auths":{"ghcr2.io":{"auth":"` + registryAuth2Encoded + `","email":"noreply@devspace.sh"},"ghcr3.io":{"auth":"` + registryAuth3Encoded + `","email":"noreply@devspace.sh"}}}`) framework.ExpectEqual(string(pullSecret.Data[k8sv1.DockerConfigJsonKey]), string(pullSecretDataValue)) - pullSecret, err = kubeClient.RawClient().CoreV1().Secrets(ns).Get(context.TODO(), "devspace-auth-ghcr2-io", metav1.GetOptions{}) + // check if named secrets are created and merged + pullSecret, err = kubeClient.RawClient().CoreV1().Secrets(ns).Get(context.TODO(), "merged-secret", metav1.GetOptions{}) framework.ExpectNoError(err) framework.ExpectEqual(len(pullSecret.Data), 1) - registryAuthEncoded = base64.StdEncoding.EncodeToString([]byte("my-user2:my-password2")) - pullSecretDataValue = []byte(`{ - "auths": { - "ghcr2.io": { - "auth": "` + registryAuthEncoded + `", - "email": "noreply@devspace.sh" - } - } - }`) + registryAuth4Encoded := base64.StdEncoding.EncodeToString([]byte("my-user4:my-password4")) + registryAuth5Encoded := base64.StdEncoding.EncodeToString([]byte("my-user5:my-password5")) + pullSecretDataValue = []byte(`{"auths":{"ghcr4.io":{"auth":"` + registryAuth4Encoded + `","email":"noreply@devspace.sh"},"ghcr5.io":{"auth":"` + registryAuth5Encoded + `","email":"noreply@devspace.sh"}}}`) framework.ExpectEqual(string(pullSecret.Data[k8sv1.DockerConfigJsonKey]), string(pullSecretDataValue)) serviceAccount, err := kubeClient.RawClient().CoreV1().ServiceAccounts(ns).Get(context.TODO(), "default", metav1.GetOptions{}) framework.ExpectNoError(err) - framework.ExpectEqual(len(serviceAccount.ImagePullSecrets), 2) + framework.ExpectEqual(len(serviceAccount.ImagePullSecrets), 3) sort.Slice(serviceAccount.ImagePullSecrets, func(i, j int) bool { return serviceAccount.ImagePullSecrets[i].Name < serviceAccount.ImagePullSecrets[j].Name }) - framework.ExpectEqual(serviceAccount.ImagePullSecrets[0].Name, "devspace-auth-ghcr2-io") - framework.ExpectEqual(serviceAccount.ImagePullSecrets[1].Name, "test-secret") + framework.ExpectEqual(serviceAccount.ImagePullSecrets[0].Name, "devspace-pull-secrets") + framework.ExpectEqual(serviceAccount.ImagePullSecrets[1].Name, "merged-secret") + framework.ExpectEqual(serviceAccount.ImagePullSecrets[2].Name, "test-secret") }) }) diff --git a/e2e/tests/pullsecret/testdata/simple/devspace.yaml b/e2e/tests/pullsecret/testdata/simple/devspace.yaml index 14185fec72..46357df572 100644 --- a/e2e/tests/pullsecret/testdata/simple/devspace.yaml +++ b/e2e/tests/pullsecret/testdata/simple/devspace.yaml @@ -6,8 +6,23 @@ pullSecrets: username: my-user2 password: my-password2 serviceAccounts: ["default"] + test3: + registry: ghcr3.io + username: my-user3 + password: my-password3 + serviceAccounts: ["default"] test: registry: ghcr.io username: my-user password: my-password secret: test-secret + test4: + registry: ghcr4.io + username: my-user4 + password: my-password4 + secret: merged-secret + test5: + registry: ghcr5.io + username: my-user5 + password: my-password5 + secret: merged-secret diff --git a/pkg/devspace/pullsecrets/registry.go b/pkg/devspace/pullsecrets/registry.go index c6220314a7..83dd1c76e1 100644 --- a/pkg/devspace/pullsecrets/registry.go +++ b/pkg/devspace/pullsecrets/registry.go @@ -4,11 +4,12 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" - devspacecontext "github.com/loft-sh/devspace/pkg/devspace/context" + "encoding/json" "regexp" - "strings" "time" + devspacecontext "github.com/loft-sh/devspace/pkg/devspace/context" + kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/util/wait" @@ -32,6 +33,23 @@ type PullSecretOptions struct { Secret string } +// DockerConfigJSON represents a local docker auth config file +// for pulling images. +type DockerConfigJSON struct { + Auths DockerConfig `json:"auths"` +} + +// DockerConfig represents the config file used by the docker CLI. +// This config that represents the credentials that should be used +// when pulling images from specific image repositories. +type DockerConfig map[string]DockerConfigEntry + +// DockerConfigEntry holds the user information that grant the access to docker registry +type DockerConfigEntry struct { + Auth string `json:"auth"` + Email string `json:"email"` +} + // CreatePullSecret creates an image pull secret for a registry func (r *client) CreatePullSecret(ctx *devspacecontext.Context, options *PullSecretOptions) error { pullSecretName := options.Secret @@ -39,8 +57,9 @@ func (r *client) CreatePullSecret(ctx *devspacecontext.Context, options *PullSec pullSecretName = GetRegistryAuthSecretName(options.RegistryURL) } - if options.RegistryURL == "hub.docker.com" || options.RegistryURL == "" { - options.RegistryURL = "https://index.docker.io/v1/" + registryURL := options.RegistryURL + if registryURL == "hub.docker.com" || registryURL == "" { + registryURL = "https://index.docker.io/v1/" } authToken := options.PasswordOrToken @@ -53,48 +72,64 @@ func (r *client) CreatePullSecret(ctx *devspacecontext.Context, options *PullSec email = "noreply@devspace.sh" } - registryAuthEncoded := base64.StdEncoding.EncodeToString([]byte(authToken)) - pullSecretDataValue := []byte(`{ - "auths": { - "` + options.RegistryURL + `": { - "auth": "` + registryAuthEncoded + `", - "email": "` + email + `" + err := wait.PollImmediate(time.Second, time.Second*30, func() (bool, error) { + secret, err := ctx.KubeClient.KubeClient().CoreV1().Secrets(options.Namespace).Get(ctx.Context, pullSecretName, metav1.GetOptions{}) + if err != nil { + if kerrors.IsNotFound(err) { + // Create the pull secret + secret, err := newPullSecret(pullSecretName, registryURL, authToken, email) + if err != nil { + return false, err } - } - }`) - pullSecretData := map[string][]byte{} - pullSecretDataKey := k8sv1.DockerConfigJsonKey - pullSecretData[pullSecretDataKey] = pullSecretDataValue + _, err = ctx.KubeClient.KubeClient().CoreV1().Secrets(options.Namespace).Create(ctx.Context, secret, metav1.CreateOptions{}) + if err != nil { + if kerrors.IsAlreadyExists(err) { + // Retry + return false, nil + } - registryPullSecret := &k8sv1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: pullSecretName, - }, - Data: pullSecretData, - Type: k8sv1.SecretTypeDockerConfigJson, - } + return false, errors.Wrap(err, "create pull secret") + } - err := wait.PollImmediate(time.Second, time.Second*30, func() (bool, error) { - secret, err := ctx.KubeClient.KubeClient().CoreV1().Secrets(options.Namespace).Get(ctx.Context, pullSecretName, metav1.GetOptions{}) + ctx.Log.Donef("Created image pull secret %s/%s", options.Namespace, pullSecretName) + return true, nil + } else { + // Retry + return false, nil + } + } + + dockerConfigJSON, err := fromPullSecretData(secret.Data) if err != nil { - _, err = ctx.KubeClient.KubeClient().CoreV1().Secrets(options.Namespace).Create(ctx.Context, registryPullSecret, metav1.CreateOptions{}) + return false, err + } + + existingEntry := dockerConfigJSON.Auths[registryURL] + updatedEntry := newDockerConfigEntry(authToken, email) + if hasChanges(existingEntry, updatedEntry) { + // Update secret entry + dockerConfigJSON.Auths[registryURL] = updatedEntry + + // Update secret data + secret.Data, err = toPullSecretData(dockerConfigJSON) if err != nil { - return false, errors.Errorf("Unable to create image pull secret: %s", err.Error()) + return false, err } - ctx.Log.Donef("Created image pull secret %s/%s", options.Namespace, pullSecretName) - } else if secret.Data == nil || string(secret.Data[pullSecretDataKey]) != string(pullSecretData[pullSecretDataKey]) { - _, err = ctx.KubeClient.KubeClient().CoreV1().Secrets(options.Namespace).Update(ctx.Context, registryPullSecret, metav1.UpdateOptions{}) + // Update secret + _, err = ctx.KubeClient.KubeClient().CoreV1().Secrets(options.Namespace).Update(ctx.Context, secret, metav1.UpdateOptions{}) if err != nil { if kerrors.IsConflict(err) { + // Retry return false, nil } - return false, errors.Errorf("Unable to update image pull secret: %s", err.Error()) + return false, errors.Wrap(err, "update pull secret") } - } + ctx.Log.Donef("Updated image pull secret %s/%s", options.Namespace, pullSecretName) + } return true, nil }) if err != nil { @@ -106,11 +141,7 @@ func (r *client) CreatePullSecret(ctx *devspacecontext.Context, options *PullSec // GetRegistryAuthSecretName returns the name of the image pull secret for a registry func GetRegistryAuthSecretName(registryURL string) string { - if registryURL == "" { - return registryAuthSecretNamePrefix + "docker" - } - - return SafeName(registryAuthSecretNamePrefix + registryNameReplaceRegex.ReplaceAllString(strings.ToLower(registryURL), "-")) + return "devspace-pull-secrets" } func SafeName(name string) string { @@ -120,3 +151,60 @@ func SafeName(name string) string { } return name } + +func newPullSecret(name, registryURL, authToken, email string) (*k8sv1.Secret, error) { + dockerConfig := &DockerConfigJSON{ + Auths: DockerConfig{ + registryURL: newDockerConfigEntry(authToken, email), + }, + } + + pullSecretData, err := toPullSecretData(dockerConfig) + if err != nil { + return nil, errors.Wrap(err, "new pull secret") + } + + return &k8sv1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Data: pullSecretData, + Type: k8sv1.SecretTypeDockerConfigJson, + }, nil +} + +func newDockerConfigEntry(authToken, email string) DockerConfigEntry { + return DockerConfigEntry{ + Auth: base64.StdEncoding.EncodeToString([]byte(authToken)), + Email: email, + } +} + +func hasChanges(existing, updated DockerConfigEntry) bool { + return existing.Auth != updated.Auth || existing.Email != updated.Email +} + +func toPullSecretData(dockerConfig *DockerConfigJSON) (map[string][]byte, error) { + data, err := json.Marshal(dockerConfig) + if err != nil { + return nil, errors.Wrap(err, "marshal docker config") + } + + return map[string][]byte{ + k8sv1.DockerConfigJsonKey: data, + }, nil +} + +func fromPullSecretData(data map[string][]byte) (*DockerConfigJSON, error) { + dockerConfig := &DockerConfigJSON{} + if data == nil { + return dockerConfig, nil + } + + err := json.Unmarshal(data[k8sv1.DockerConfigJsonKey], &dockerConfig) + if err != nil { + return nil, errors.Wrap(err, "unmarshal docker config") + } + + return dockerConfig, nil +}