diff options
| author | Ishita Sequeira <46771830+ishitasequeira@users.noreply.github.com> | 2024-12-04 21:34:58 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-12-04 11:04:58 -0500 |
| commit | 8076d2005ea625c73604073fca43df38eb675751 (patch) | |
| tree | 1570ba5969882a26e021875da86bee6850a9cfc6 /registry-scanner/pkg | |
| parent | c3f0eff54daf871fa1c274462b17f5149c11d368 (diff) | |
Add image folder to registry scanner (#952)
Signed-off-by: Ishita Sequeira <ishiseq29@gmail.com>
Diffstat (limited to 'registry-scanner/pkg')
| -rw-r--r-- | registry-scanner/pkg/cache/memcache_test.go | 2 | ||||
| -rw-r--r-- | registry-scanner/pkg/common/constants.go | 65 | ||||
| -rw-r--r-- | registry-scanner/pkg/env/env.go | 2 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/credentials.go | 261 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/credentials_test.go | 410 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/image.go | 275 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/image_test.go | 226 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/kustomize.go | 39 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/kustomize_test.go | 26 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/matchfunc.go | 27 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/matchfunc_test.go | 27 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/options.go | 296 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/options_test.go | 493 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/version.go | 220 | ||||
| -rw-r--r-- | registry-scanner/pkg/image/version_test.go | 196 | ||||
| -rw-r--r-- | registry-scanner/pkg/kube/kubernetes.go | 7 | ||||
| -rw-r--r-- | registry-scanner/pkg/registry/registry_test.go | 202 |
17 files changed, 2668 insertions, 106 deletions
diff --git a/registry-scanner/pkg/cache/memcache_test.go b/registry-scanner/pkg/cache/memcache_test.go index 8fcb47b..ff8bc5f 100644 --- a/registry-scanner/pkg/cache/memcache_test.go +++ b/registry-scanner/pkg/cache/memcache_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/argoproj-labs/argocd-image-updater/pkg/tag" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/tag" ) func Test_MemCache(t *testing.T) { diff --git a/registry-scanner/pkg/common/constants.go b/registry-scanner/pkg/common/constants.go new file mode 100644 index 0000000..eeb9ccb --- /dev/null +++ b/registry-scanner/pkg/common/constants.go @@ -0,0 +1,65 @@ +package common + +// This file contains a list of constants required by other packages + +const ImageUpdaterAnnotationPrefix = "argocd-image-updater.argoproj.io" + +// The annotation on the application resources to indicate the list of images +// allowed for updates. +const ImageUpdaterAnnotation = ImageUpdaterAnnotationPrefix + "/image-list" + +// Defaults for Helm parameter names +const ( + DefaultHelmImageName = "image.name" + DefaultHelmImageTag = "image.tag" +) + +// Helm related annotations +const ( + HelmParamImageNameAnnotation = ImageUpdaterAnnotationPrefix + "/%s.helm.image-name" + HelmParamImageTagAnnotation = ImageUpdaterAnnotationPrefix + "/%s.helm.image-tag" + HelmParamImageSpecAnnotation = ImageUpdaterAnnotationPrefix + "/%s.helm.image-spec" +) + +// Kustomize related annotations +const ( + KustomizeApplicationNameAnnotation = ImageUpdaterAnnotationPrefix + "/%s.kustomize.image-name" +) + +// Image specific configuration annotations +const ( + OldMatchOptionAnnotation = ImageUpdaterAnnotationPrefix + "/%s.tag-match" // Deprecated and will be removed + AllowTagsOptionAnnotation = ImageUpdaterAnnotationPrefix + "/%s.allow-tags" + IgnoreTagsOptionAnnotation = ImageUpdaterAnnotationPrefix + "/%s.ignore-tags" + ForceUpdateOptionAnnotation = ImageUpdaterAnnotationPrefix + "/%s.force-update" + UpdateStrategyAnnotation = ImageUpdaterAnnotationPrefix + "/%s.update-strategy" + PullSecretAnnotation = ImageUpdaterAnnotationPrefix + "/%s.pull-secret" + PlatformsAnnotation = ImageUpdaterAnnotationPrefix + "/%s.platforms" +) + +// Application-wide update strategy related annotations +const ( + ApplicationWideAllowTagsOptionAnnotation = ImageUpdaterAnnotationPrefix + "/allow-tags" + ApplicationWideIgnoreTagsOptionAnnotation = ImageUpdaterAnnotationPrefix + "/ignore-tags" + ApplicationWideForceUpdateOptionAnnotation = ImageUpdaterAnnotationPrefix + "/force-update" + ApplicationWideUpdateStrategyAnnotation = ImageUpdaterAnnotationPrefix + "/update-strategy" + ApplicationWidePullSecretAnnotation = ImageUpdaterAnnotationPrefix + "/pull-secret" +) + +// Application update configuration related annotations +const ( + WriteBackMethodAnnotation = ImageUpdaterAnnotationPrefix + "/write-back-method" + GitBranchAnnotation = ImageUpdaterAnnotationPrefix + "/git-branch" + GitRepositoryAnnotation = ImageUpdaterAnnotationPrefix + "/git-repository" + WriteBackTargetAnnotation = ImageUpdaterAnnotationPrefix + "/write-back-target" + KustomizationPrefix = "kustomization" + HelmPrefix = "helmvalues" +) + +// The default Git commit message's template +const DefaultGitCommitMessage = `build: automatic update of {{ .AppName }} + +{{ range .AppChanges -}} +updates image {{ .Image }} tag '{{ .OldTag }}' to '{{ .NewTag }}' +{{ end -}} +` diff --git a/registry-scanner/pkg/env/env.go b/registry-scanner/pkg/env/env.go index e780067..f000aa2 100644 --- a/registry-scanner/pkg/env/env.go +++ b/registry-scanner/pkg/env/env.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/argoproj-labs/argocd-image-updater/pkg/log" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log" ) // Package env provides some utility functions to interact with the environment diff --git a/registry-scanner/pkg/image/credentials.go b/registry-scanner/pkg/image/credentials.go new file mode 100644 index 0000000..a19d01a --- /dev/null +++ b/registry-scanner/pkg/image/credentials.go @@ -0,0 +1,261 @@ +package image + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + argoexec "github.com/argoproj/pkg/exec" + + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/kube" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log" +) + +type CredentialSourceType int + +const ( + CredentialSourceUnknown CredentialSourceType = 0 + CredentialSourcePullSecret CredentialSourceType = 1 + CredentialSourceSecret CredentialSourceType = 2 + CredentialSourceEnv CredentialSourceType = 3 + CredentialSourceExt CredentialSourceType = 4 +) + +type CredentialSource struct { + Type CredentialSourceType + Registry string + SecretNamespace string + SecretName string + SecretField string + EnvName string + ScriptPath string +} + +type Credential struct { + Username string + Password string +} + +const pullSecretField = ".dockerconfigjson" + +// gcr.io=secret:foo/bar#baz +// gcr.io=pullsecret:foo/bar +// gcr.io=env:FOOBAR + +func ParseCredentialSource(credentialSource string, requirePrefix bool) (*CredentialSource, error) { + src := CredentialSource{} + var secretDef string + tokens := strings.SplitN(credentialSource, "=", 2) + if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" { + if requirePrefix { + return nil, fmt.Errorf("invalid credential spec: %s", credentialSource) + } + secretDef = credentialSource + } else { + src.Registry = tokens[0] + secretDef = tokens[1] + } + + tokens = strings.Split(secretDef, ":") + if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" { + return nil, fmt.Errorf("invalid credential spec: %s", credentialSource) + } + + var err error + switch strings.ToLower(tokens[0]) { + case "secret": + err = src.parseSecretDefinition(tokens[1]) + src.Type = CredentialSourceSecret + case "pullsecret": + err = src.parsePullSecretDefinition(tokens[1]) + src.Type = CredentialSourcePullSecret + case "env": + err = src.parseEnvDefinition(tokens[1]) + src.Type = CredentialSourceEnv + case "ext": + err = src.parseExtDefinition(tokens[1]) + src.Type = CredentialSourceExt + default: + err = fmt.Errorf("unknown credential source: %s", tokens[0]) + } + + if err != nil { + return nil, err + } + + return &src, nil +} + +// FetchCredentials fetches the credentials for a given registry according to +// the credential source. +func (src *CredentialSource) FetchCredentials(registryURL string, kubeclient *kube.KubernetesClient) (*Credential, error) { + var creds Credential + log.Tracef("Fetching credentials for registry %s", registryURL) + switch src.Type { + case CredentialSourceEnv: + credEnv := os.Getenv(src.EnvName) + if credEnv == "" { + return nil, fmt.Errorf("could not fetch credentials: env '%s' is not set", src.EnvName) + } + tokens := strings.SplitN(credEnv, ":", 2) + if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" { + return nil, fmt.Errorf("could not fetch credentials: value of %s is malformed", src.EnvName) + } + creds.Username = tokens[0] + creds.Password = tokens[1] + return &creds, nil + case CredentialSourceSecret: + if kubeclient == nil { + return nil, fmt.Errorf("could not fetch credentials: no Kubernetes client given") + } + data, err := kubeclient.GetSecretField(src.SecretNamespace, src.SecretName, src.SecretField) + if err != nil { + return nil, fmt.Errorf("could not fetch secret '%s' from namespace '%s' (field: '%s'): %v", src.SecretName, src.SecretNamespace, src.SecretField, err) + } + tokens := strings.SplitN(data, ":", 2) + if len(tokens) != 2 { + return nil, fmt.Errorf("invalid credentials in secret '%s' from namespace '%s' (field '%s')", src.SecretName, src.SecretNamespace, src.SecretField) + } + creds.Username = tokens[0] + creds.Password = tokens[1] + return &creds, nil + case CredentialSourcePullSecret: + if kubeclient == nil { + return nil, fmt.Errorf("could not fetch credentials: no Kubernetes client given") + } + src.SecretField = pullSecretField + data, err := kubeclient.GetSecretField(src.SecretNamespace, src.SecretName, src.SecretField) + if err != nil { + return nil, fmt.Errorf("could not fetch secret '%s' from namespace '%s' (field: '%s'): %v", src.SecretName, src.SecretNamespace, src.SecretField, err) + } + creds.Username, creds.Password, err = parseDockerConfigJson(registryURL, data) + if err != nil { + return nil, err + } + return &creds, nil + case CredentialSourceExt: + if !strings.HasPrefix(src.ScriptPath, "/") { + return nil, fmt.Errorf("path to script must be absolute, but is '%s'", src.ScriptPath) + } + _, err := os.Stat(src.ScriptPath) + if err != nil { + return nil, fmt.Errorf("could not stat %s: %v", src.ScriptPath, err) + } + cmd := exec.Command(src.ScriptPath) + out, err := argoexec.RunCommandExt(cmd, argoexec.CmdOpts{Timeout: 10 * time.Second}) + if err != nil { + return nil, fmt.Errorf("error executing %s: %v", src.ScriptPath, err) + } + tokens := strings.SplitN(out, ":", 2) + if len(tokens) != 2 { + return nil, fmt.Errorf("invalid script output, must be single line with syntax <username>:<password>") + } + creds.Username = tokens[0] + creds.Password = tokens[1] + return &creds, nil + + default: + return nil, fmt.Errorf("unknown credential type") + } +} + +// Parse a secret definition in form of 'namespace/name#field' +func (src *CredentialSource) parseSecretDefinition(definition string) error { + tokens := strings.Split(definition, "#") + if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" { + return fmt.Errorf("invalid secret definition: %s", definition) + } + src.SecretField = tokens[1] + tokens = strings.Split(tokens[0], "/") + if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" { + return fmt.Errorf("invalid secret definition: %s", definition) + } + src.SecretNamespace = tokens[0] + src.SecretName = tokens[1] + + return nil +} + +// Parse an image pull secret definition in form of 'namespace/name' +func (src *CredentialSource) parsePullSecretDefinition(definition string) error { + tokens := strings.Split(definition, "/") + if len(tokens) != 2 || tokens[0] == "" || tokens[1] == "" { + return fmt.Errorf("invalid secret definition: %s", definition) + } + + src.SecretNamespace = tokens[0] + src.SecretName = tokens[1] + src.SecretField = pullSecretField + + return nil +} + +// Parse an environment definition +// nolint:unparam +func (src *CredentialSource) parseEnvDefinition(definition string) error { + src.EnvName = definition + return nil +} + +// Parse an external script definition +// nolint:unparam +func (src *CredentialSource) parseExtDefinition(definition string) error { + src.ScriptPath = definition + return nil +} + +// This unmarshals & parses Docker's config.json file, returning username and +// password for given registry URL +func parseDockerConfigJson(registryURL string, jsonSource string) (string, string, error) { + var dockerConf map[string]interface{} + err := json.Unmarshal([]byte(jsonSource), &dockerConf) + if err != nil { + return "", "", err + } + auths, ok := dockerConf["auths"].(map[string]interface{}) + if !ok { + return "", "", fmt.Errorf("no credentials in image pull secret") + } + + var regPrefix string + if strings.HasPrefix(registryURL, "http://") { + regPrefix = strings.TrimPrefix(registryURL, "http://") + } else if strings.HasPrefix(registryURL, "https://") { + regPrefix = strings.TrimPrefix(registryURL, "https://") + } else { + regPrefix = registryURL + } + + regPrefix = strings.TrimSuffix(regPrefix, "/") + + for registry, authConf := range auths { + if !strings.HasPrefix(registry, registryURL) && !strings.HasPrefix(registry, regPrefix) { + log.Tracef("found registry %s in image pull secret, but we want %s (%s) - skipping", registry, registryURL, regPrefix) + continue + } + authEntry, ok := authConf.(map[string]interface{}) + if !ok { + return "", "", fmt.Errorf("invalid auth entry for registry entry %s ('auths' entry should be map)", registry) + } + authString, ok := authEntry["auth"].(string) + if !ok { + return "", "", fmt.Errorf("invalid auth token for registry entry %s ('auth' should be string')", registry) + } + authToken, err := base64.StdEncoding.DecodeString(authString) + if err != nil { + return "", "", fmt.Errorf("could not base64-decode auth data for registry entry %s: %v", registry, err) + } + tokens := strings.SplitN(string(authToken), ":", 2) + if len(tokens) != 2 { + return "", "", fmt.Errorf("invalid data after base64 decoding auth entry for registry entry %s", registry) + } + + return tokens[0], tokens[1], nil + } + + return "", "", fmt.Errorf("no valid auth entry for registry %s found in image pull secret", registryURL) +} diff --git a/registry-scanner/pkg/image/credentials_test.go b/registry-scanner/pkg/image/credentials_test.go new file mode 100644 index 0000000..6d53bf4 --- /dev/null +++ b/registry-scanner/pkg/image/credentials_test.go @@ -0,0 +1,410 @@ +package image + +import ( + "fmt" + "os" + "path" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/kube" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/test/fake" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/test/fixture" +) + +func Test_ParseCredentialAnnotation(t *testing.T) { + t.Run("Parse valid credentials definition of type secret", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=secret:mynamespace/mysecret#anyfield", true) + assert.NoError(t, err) + assert.Equal(t, "gcr.io", src.Registry) + assert.Equal(t, "mynamespace", src.SecretNamespace) + assert.Equal(t, "mysecret", src.SecretName) + assert.Equal(t, "anyfield", src.SecretField) + }) + + t.Run("Parse valid credentials definition of type pullsecret", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=pullsecret:mynamespace/mysecret", true) + assert.NoError(t, err) + assert.Equal(t, "gcr.io", src.Registry) + assert.Equal(t, "mynamespace", src.SecretNamespace) + assert.Equal(t, "mysecret", src.SecretName) + assert.Equal(t, ".dockerconfigjson", src.SecretField) + }) + + t.Run("Parse invalid secret definition - missing registry", func(t *testing.T) { + src, err := ParseCredentialSource("secret:mynamespace/mysecret#anyfield", true) + assert.Error(t, err) + assert.Nil(t, src) + }) + + t.Run("Parse invalid secret definition - empty registry", func(t *testing.T) { + src, err := ParseCredentialSource("=secret:mynamespace/mysecret#anyfield", true) + assert.Error(t, err) + assert.Nil(t, src) + }) + + t.Run("Parse invalid secret definition - unknown credential type", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=secrets:mynamespace/mysecret#anyfield", true) + assert.Error(t, err) + assert.Nil(t, src) + }) + + t.Run("Parse invalid secret definition - missing field", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=secret:mynamespace/mysecret#", true) + assert.Error(t, err) + assert.Nil(t, src) + }) + + t.Run("Parse invalid secret definition - missing namespace", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=secret:/mysecret#anyfield", true) + assert.Error(t, err) + assert.Nil(t, src) + }) + + t.Run("Parse invalid credential definition - missing name", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=secret:mynamespace/#anyfield", true) + assert.Error(t, err) + assert.Nil(t, src) + }) + + t.Run("Parse invalid credential definition - missing most", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=secret:", true) + assert.Error(t, err) + assert.Nil(t, src) + }) + + t.Run("Parse invalid pullsecret definition - missing namespace", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=pullsecret:/mysecret", true) + assert.Error(t, err) + assert.Nil(t, src) + }) + + t.Run("Parse invalid credential definition - missing name", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=pullsecret:mynamespace", true) + assert.Error(t, err) + assert.Nil(t, src) + }) + + t.Run("Parse valid credentials definition from environment", func(t *testing.T) { + src, err := ParseCredentialSource("env:DUMMY_SECRET", false) + require.NoError(t, err) + require.NotNil(t, src) + assert.Equal(t, "DUMMY_SECRET", src.EnvName) + }) + + t.Run("Parse valid credentials definition from environment", func(t *testing.T) { + src, err := ParseCredentialSource("env:DUMMY_SECRET", false) + require.NoError(t, err) + require.NotNil(t, src) + assert.Equal(t, "DUMMY_SECRET", src.EnvName) + }) + + t.Run("Parse external script credentials", func(t *testing.T) { + src, err := ParseCredentialSource("ext:/tmp/a.sh", false) + require.NoError(t, err) + assert.Equal(t, CredentialSourceExt, src.Type) + assert.Equal(t, "/tmp/a.sh", src.ScriptPath) + }) +} + +func Test_ParseCredentialReference(t *testing.T) { + t.Run("Parse valid credentials definition of type secret", func(t *testing.T) { + src, err := ParseCredentialSource("secret:mynamespace/mysecret#anyfield", false) + assert.NoError(t, err) + assert.Equal(t, "", src.Registry) + assert.Equal(t, "mynamespace", src.SecretNamespace) + assert.Equal(t, "mysecret", src.SecretName) + assert.Equal(t, "anyfield", src.SecretField) + }) + + t.Run("Parse valid credentials definition of type pullsecret", func(t *testing.T) { + src, err := ParseCredentialSource("gcr.io=pullsecret:mynamespace/mysecret", false) + assert.NoError(t, err) + assert.Equal(t, "gcr.io", src.Registry) + assert.Equal(t, "mynamespace", src.SecretNamespace) + assert.Equal(t, "mysecret", src.SecretName) + assert.Equal(t, ".dockerconfigjson", src.SecretField) + }) + + t.Run("Parse invalid secret definition - empty registry", func(t *testing.T) { + src, err := ParseCredentialSource("=secret:mynamespace/mysecret#anyfield", false) + assert.Error(t, err) + assert.Nil(t, src) + }) + +} + +func Test_FetchCredentialsFromSecret(t *testing.T) { + t.Run("Fetch credentials from secret", func(t *testing.T) { + secretData := make(map[string][]byte) + secretData["username_password"] = []byte(fmt.Sprintf("%s:%s", "foo", "bar")) + secret := fixture.NewSecret("test", "test", secretData) + clientset := fake.NewFakeClientsetWithResources(secret) + credSrc := &CredentialSource{ + Type: CredentialSourceSecret, + SecretNamespace: "test", + SecretName: "test", + SecretField: "username_password", + } + creds, err := credSrc.FetchCredentials("NA", &kube.KubernetesClient{Clientset: clientset}) + require.NoError(t, err) + require.NotNil(t, creds) + assert.Equal(t, "foo", creds.Username) + assert.Equal(t, "bar", creds.Password) + + credSrc.SecretNamespace = "test1" // test with a wrong SecretNamespace + creds, err = credSrc.FetchCredentials("NA", &kube.KubernetesClient{Clientset: clientset}) + require.Error(t, err) + require.Nil(t, creds) + }) + + t.Run("Fetch credentials from secret with invalid config", func(t *testing.T) { + secretData := make(map[string][]byte) + secretData["username_password"] = []byte(fmt.Sprintf("%s:%s", "foo", "bar")) + secret := fixture.NewSecret("test", "test", secretData) + clientset := fake.NewFakeClientsetWithResources(secret) + credSrc := &CredentialSource{ + Type: CredentialSourceSecret, + SecretNamespace: "test", + SecretName: "test", + SecretField: "username_password", + } + creds, err := credSrc.FetchCredentials("NA", nil) + require.Error(t, err) // should fail with "could not fetch credentials: no Kubernetes client given" + require.Nil(t, creds) + + credSrc.SecretField = "BAD" // test with a wrong SecretField + creds, err = credSrc.FetchCredentials("NA", &kube.KubernetesClient{Clientset: clientset}) + require.Error(t, err) + require.Nil(t, creds) + + }) +} + +func Test_FetchCredentialsFromPullSecret(t *testing.T) { + t.Run("Fetch credentials from pull secret", func(t *testing.T) { + dockerJson := fixture.MustReadFile("../../test/testdata/docker/valid-config.json") + secretData := make(map[string][]byte) + secretData[pullSecretField] = []byte(dockerJson) + pullSecret := fixture.NewSecret("test", "test", secretData) + clientset := fake.NewFakeClientsetWithResources(pullSecret) + credSrc := &CredentialSource{ + Type: CredentialSourcePullSecret, + Registry: "https://registry-1.docker.io/v2", + SecretNamespace: "test", + SecretName: "test", + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", &kube.KubernetesClient{Clientset: clientset}) + require.NoError(t, err) + require.NotNil(t, creds) + assert.Equal(t, "foo", creds.Username) + assert.Equal(t, "bar", creds.Password) + + credSrc.SecretNamespace = "test1" // test with a wrong SecretNamespace + creds, err = credSrc.FetchCredentials("https://registry-1.docker.io", &kube.KubernetesClient{Clientset: clientset}) + require.Error(t, err) + require.Nil(t, creds) + }) + + t.Run("Fetch credentials from pull secret with invalid config", func(t *testing.T) { + dockerJson := fixture.MustReadFile("../../test/testdata/docker/valid-config.json") + dockerJson = strings.ReplaceAll(dockerJson, "auths", "BAD-KEY") + secretData := make(map[string][]byte) + secretData[pullSecretField] = []byte(dockerJson) + pullSecret := fixture.NewSecret("test", "test", secretData) + clientset := fake.NewFakeClientsetWithResources(pullSecret) + credSrc := &CredentialSource{ + Type: CredentialSourcePullSecret, + Registry: "https://registry-1.docker.io/v2", + SecretNamespace: "test", + SecretName: "test", + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", &kube.KubernetesClient{Clientset: clientset}) + require.Error(t, err) // should fail with "no credentials in image pull secret" + require.Nil(t, creds) + + creds, err = credSrc.FetchCredentials("https://registry-1.docker.io", nil) + require.Error(t, err) // should fail with "could not fetch credentials: no Kubernetes client given" + require.Nil(t, creds) + }) + + t.Run("Fetch credentials from pull secret with protocol stripped", func(t *testing.T) { + dockerJson := fixture.MustReadFile("../../test/testdata/docker/valid-config-noproto.json") + secretData := make(map[string][]byte) + secretData[pullSecretField] = []byte(dockerJson) + pullSecret := fixture.NewSecret("test", "test", secretData) + clientset := fake.NewFakeClientsetWithResources(pullSecret) + credSrc := &CredentialSource{ + Type: CredentialSourcePullSecret, + Registry: "https://registry-1.docker.io/v2", + SecretNamespace: "test", + SecretName: "test", + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", &kube.KubernetesClient{Clientset: clientset}) + require.NoError(t, err) + require.NotNil(t, creds) + assert.Equal(t, "foo", creds.Username) + assert.Equal(t, "bar", creds.Password) + }) +} + +func Test_FetchCredentialsFromEnv(t *testing.T) { + t.Run("Fetch credentials from environment", func(t *testing.T) { + err := os.Setenv("MY_SECRET_ENV", "foo:bar") + require.NoError(t, err) + credSrc := &CredentialSource{ + Type: CredentialSourceEnv, + Registry: "https://registry-1.docker.io/v2", + EnvName: "MY_SECRET_ENV", + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", nil) + require.NoError(t, err) + require.NotNil(t, creds) + assert.Equal(t, "foo", creds.Username) + assert.Equal(t, "bar", creds.Password) + }) + + t.Run("Fetch credentials from environment with missing env var", func(t *testing.T) { + err := os.Setenv("MY_SECRET_ENV", "") + require.NoError(t, err) + credSrc := &CredentialSource{ + Type: CredentialSourceEnv, + Registry: "https://registry-1.docker.io/v2", + EnvName: "MY_SECRET_ENV", + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", nil) + require.Error(t, err) + require.Nil(t, creds) + }) + + t.Run("Fetch credentials from environment with invalid value in env var", func(t *testing.T) { + for _, value := range []string{"babayaga", "foo:", "bar:", ":"} { + err := os.Setenv("MY_SECRET_ENV", value) + require.NoError(t, err) + credSrc := &CredentialSource{ + Type: CredentialSourceEnv, + Registry: "https://registry-1.docker.io/v2", + EnvName: "MY_SECRET_ENV", + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", nil) + require.Error(t, err) + require.Nil(t, creds) + } + }) +} + +func Test_FetchCredentialsFromExt(t *testing.T) { + t.Run("Fetch credentials from external script - valid output", func(t *testing.T) { + pwd, err := os.Getwd() + require.NoError(t, err) + credSrc := &CredentialSource{ + Type: CredentialSourceExt, + Registry: "https://registry-1.docker.io/v2", + ScriptPath: path.Join(pwd, "..", "..", "test", "testdata", "scripts", "get-credentials-valid.sh"), + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", nil) + require.NoError(t, err) + require.NotNil(t, creds) + assert.Equal(t, "username", creds.Username) + assert.Equal(t, "password", creds.Password) + }) + t.Run("Fetch credentials from external script - invalid script output", func(t *testing.T) { + pwd, err := os.Getwd() + require.NoError(t, err) + credSrc := &CredentialSource{ + Type: CredentialSourceExt, + Registry: "https://registry-1.docker.io/v2", + ScriptPath: path.Join(pwd, "..", "..", "test", "testdata", "scripts", "get-credentials-invalid.sh"), + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", nil) + require.Errorf(t, err, "invalid script output") + require.Nil(t, creds) + }) + t.Run("Fetch credentials from external script - script does not exist", func(t *testing.T) { + pwd, err := os.Getwd() + require.NoError(t, err) + credSrc := &CredentialSource{ + Type: CredentialSourceExt, + Registry: "https://registry-1.docker.io/v2", + ScriptPath: path.Join(pwd, "..", "..", "test", "testdata", "scripts", "get-credentials-notexist.sh"), + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", nil) + require.Errorf(t, err, "no such file or directory") + require.Nil(t, creds) + }) + t.Run("Fetch credentials from external script - relative path", func(t *testing.T) { + credSrc := &CredentialSource{ + Type: CredentialSourceExt, + Registry: "https://registry-1.docker.io/v2", + ScriptPath: "get-credentials-notexist.sh", + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", nil) + require.Errorf(t, err, "path to script must be absolute") + require.Nil(t, creds) + }) +} + +func Test_FetchCredentialsFromUnknown(t *testing.T) { + t.Run("Fetch credentials from unknown type", func(t *testing.T) { + credSrc := &CredentialSource{ + Type: CredentialSourceType(-1), + Registry: "https://registry-1.docker.io/v2", + } + creds, err := credSrc.FetchCredentials("https://registry-1.docker.io", nil) + require.Error(t, err) // should fail with "unknown credential type" + require.Nil(t, creds) + }) +} + +func Test_ParseDockerConfig(t *testing.T) { + t.Run("Parse valid Docker configuration with matching registry", func(t *testing.T) { + config := fixture.MustReadFile("../../test/testdata/docker/valid-config.json") + username, password, err := parseDockerConfigJson("https://registry-1.docker.io", config) + require.NoError(t, err) + assert.Equal(t, "foo", username) + assert.Equal(t, "bar", password) + }) + + t.Run("Parse valid Docker configuration with matching registry as prefix", func(t *testing.T) { + config := fixture.MustReadFile("../../test/testdata/docker/valid-config-noproto.json") + username, password, err := parseDockerConfigJson("https://registry-1.docker.io", config) + require.NoError(t, err) + assert.Equal(t, "foo", username) + assert.Equal(t, "bar", password) + }) + + t.Run("Parse valid Docker configuration with matching http registry as prefix", func(t *testing.T) { + config := fixture.MustReadFile("../../test/testdata/docker/valid-config-noproto.json") + username, password, err := parseDockerConfigJson("http://registry-1.docker.io", config) + require.NoError(t, err) + assert.Equal(t, "foo", username) + assert.Equal(t, "bar", password) + }) + + t.Run("Parse valid Docker configuration with matching no-protocol registry as prefix", func(t *testing.T) { + config := fixture.MustReadFile("../../test/testdata/docker/valid-config-noproto.json") + username, password, err := parseDockerConfigJson("registry-1.docker.io", config) + require.NoError(t, err) + assert.Equal(t, "foo", username) + assert.Equal(t, "bar", password) + }) + + t.Run("Parse valid Docker configuration with matching registry as prefix with / in the end", func(t *testing.T) { + config := fixture.MustReadFile("../../test/testdata/docker/valid-config-noproto.json") + username, password, err := parseDockerConfigJson("https://registry-1.docker.io/", config) + require.NoError(t, err) + assert.Equal(t, "foo", username) + assert.Equal(t, "bar", password) + }) + + t.Run("Parse valid Docker configuration without matching registry", func(t *testing.T) { + config := fixture.MustReadFile("../../test/testdata/docker/valid-config.json") + username, password, err := parseDockerConfigJson("https://gcr.io", config) + assert.Error(t, err) + assert.Empty(t, username) + assert.Empty(t, password) + }) +} diff --git a/registry-scanner/pkg/image/image.go b/registry-scanner/pkg/image/image.go new file mode 100644 index 0000000..01261be --- /dev/null +++ b/registry-scanner/pkg/image/image.go @@ -0,0 +1,275 @@ +package image + +import ( + "strings" + "time" + + "github.com/distribution/distribution/v3/reference" + + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/tag" +) + +type ContainerImage struct { + RegistryURL string + ImageName string + ImageTag *tag.ImageTag + ImageAlias string + HelmParamImageName string + HelmParamImageVersion string + KustomizeImage *ContainerImage + original string +} + +type ContainerImageList []*ContainerImage + +// NewFromIdentifier parses an image identifier and returns a populated ContainerImage +func NewFromIdentifier(identifier string) *ContainerImage { + imgRef := identifier + alias := "" + if strings.Contains(identifier, "=") { + n := strings.SplitN(identifier, "=", 2) + imgRef = n[1] + alias = n[0] + } + if parsed, err := reference.ParseNormalizedNamed(imgRef); err == nil { + img := ContainerImage{} + img.RegistryURL = reference.Domain(parsed) + // remove default registry for backwards-compatibility + if img.RegistryURL == "docker.io" && !strings.HasPrefix(imgRef, "docker.io") { + img.RegistryURL = "" + } + img.ImageAlias = alias + img.ImageName = reference.Path(parsed) + // if library/ was added to the image name, remove it + if !strings.HasPrefix(imgRef, "library/") { + img.ImageName = strings.TrimPrefix(img.ImageName, "library/") + } + if digested, ok := parsed.(reference.Digested); ok { + img.ImageTag = &tag.ImageTag{ + TagDigest: string(digested.Digest()), + } + } else if tagged, ok := parsed.(reference.Tagged); ok { + img.ImageTag = &tag.ImageTag{ + TagName: tagged.Tag(), + } + } + img.original = identifier + return &img + } + + // if distribution couldn't parse it, fall back to the legacy parsing logic + img := ContainerImage{} + img.RegistryURL = getRegistryFromIdentifier(identifier) + img.ImageAlias, img.ImageName, img.ImageTag = getImageTagFromIdentifier(identifier) + img.original = identifier + return &img +} + +// String returns the string representation of given ContainerImage +func (img *ContainerImage) String() string { + str := "" + if img.ImageAlias != "" { + str += img.ImageAlias + str += "=" + } + str += img.GetFullNameWithTag() + return str +} + +func (img *ContainerImage) GetFullNameWithoutTag() string { + str := "" + if img.RegistryURL != "" { + str += img.RegistryURL + "/" + } + str += img.ImageName + return str +} + +// GetFullNameWithTag returns the complete image slug, including the registry +// and any tag digest or tag name set for the image. +func (img *ContainerImage) GetFullNameWithTag() string { + str := "" + if img.RegistryURL != "" { + str += img.RegistryURL + "/" + } + str += img.ImageName + if img.ImageTag != nil { + if img.ImageTag.TagName != "" { + str += ":" + str += img.ImageTag.TagName + } + if img.ImageTag.TagDigest != "" { + str += "@" + str += img.ImageTag.TagDigest + } + } + return str +} + +// GetTagWithDigest returns tag name along with any tag digest set for the image +func (img *ContainerImage) GetTagWithDigest() string { + str := "" + if img.ImageTag != nil { + if img.ImageTag.TagName != "" { + str += img.ImageTag.TagName + } + if img.ImageTag.TagDigest != "" { + if str == "" { + str += "latest" + } + str += "@" + str += img.ImageTag.TagDigest + } + } + return str +} + +func (img *ContainerImage) Original() string { + return img.original +} + +// IsUpdatable checks whether the given image can be updated with newTag while +// taking tagSpec into account. tagSpec must be given as a semver compatible +// version spec, i.e. ^1.0 or ~2.1 +func (img *ContainerImage) IsUpdatable(newTag, tagSpec string) bool { + return false +} + +// WithTag returns a copy of img with new tag information set +func (img *ContainerImage) WithTag(newTag *tag.ImageTag) *ContainerImage { + nimg := &ContainerImage{} + nimg.RegistryURL = img.RegistryURL + nimg.ImageName = img.ImageName + nimg.ImageTag = newTag + nimg.ImageAlias = img.ImageAlias + nimg.HelmParamImageName = img.HelmParamImageName + nimg.HelmParamImageVersion = img.HelmParamImageVersion + return nimg +} + +func (img *ContainerImage) DiffersFrom(other *ContainerImage, checkVersion bool) bool { + return img.RegistryURL != other.RegistryURL || img.ImageName != other.ImageName || (checkVersion && img.ImageTag.TagName != other.ImageTag.TagName) +} + +// ContainsImage checks whether img is contained in a list of images +func (list *ContainerImageList) ContainsImage(img *ContainerImage, checkVersion bool) *ContainerImage { + // if there is a KustomizeImage override, check it for a match first + if img.KustomizeImage != nil { + if kustomizeMatch := list.ContainsImage(img.KustomizeImage, checkVersion); kustomizeMatch != nil { + return kustomizeMatch + } + } + for _, image := range *list { + if img.ImageName == image.ImageName && image.RegistryURL == img.RegistryURL { + if !checkVersion || image.ImageTag.TagName == img.ImageTag.TagName { + return image + } + } + } + return nil +} + +func (list *ContainerImageList) Originals() []string { + results := make([]string, len(*list)) + for i, img := range *list { + results[i] = img.Original() + } + return results +} + +// String Returns the name of all images as a string, separated using comma +func (list *ContainerImageList) String() string { + imgNameList := make([]string, 0) + for _, image := range *list { + imgNameList = append(imgNameList, image.String()) + } + return strings.Join(imgNameList, ",") +} + +// Gets the registry URL from an image identifier +func getRegistryFromIdentifier(identifier string) string { + var imageString string + comp := strings.Split(identifier, "=") + if len(comp) > 1 { + imageString = comp[1] + } else { + imageString = identifier + } + comp = strings.Split(imageString, "/") + if len(comp) > 1 && strings.Contains(comp[0], ".") { + return comp[0] + } else { + return "" + } +} + +// Gets the image name and tag from an image identifier +func getImageTagFromIdentifier(identifier string) (string, string, *tag.ImageTag) { + var imageString string + var sourceName string + + // The original name is prepended to the image name, separated by = + comp := strings.SplitN(identifier, "=", 2) + if len(comp) == 2 { + sourceName = comp[0] + imageString = comp[1] + } else { + imageString = identifier + } + + // Strip any repository identifier from the string + comp = strings.Split(imageString, "/") + if len(comp) > 1 && strings.Contains(comp[0], ".") { + imageString = strings.Join(comp[1:], "/") + } + + // We can either have a tag name or a digest reference, or both + // jannfis/test-image:0.1 + // gcr.io/jannfis/test-image:0.1 + // gcr.io/jannfis/test-image@sha256:abcde + // gcr.io/jannfis/test-image:test-tag@sha256:abcde + if strings.Contains(imageString, "@") { + comp = strings.SplitN(imageString, "@", 2) + colonPos := strings.LastIndex(comp[0], ":") + slashPos := strings.LastIndex(comp[0], "/") + if colonPos > slashPos { + // first half (before @) contains image and tag name + return sourceName, comp[0][:colonPos], tag.NewImageTag(comp[0][colonPos+1:], time.Unix(0, 0), comp[1]) + } else { + // first half contains image name without tag name + return sourceName, comp[0], tag.NewImageTag("", time.Unix(0, 0), comp[1]) + } + } else { + comp = strings.SplitN(imageString, ":", 2) + if len(comp) != 2 { + return sourceName, imageString, nil + } else { + tagName, tagDigest := getImageDigestFromTag(comp[1]) + return sourceName, comp[0], tag.NewImageTag(tagName, time.Unix(0, 0), tagDigest) + } + } +} + +func getImageDigestFromTag(tagStr string) (string, string) { + a := strings.Split(tagStr, "@") + if len(a) != 2 { + return tagStr, "" + } else { + return a[0], a[1] + } +} + +// LogContext returns a log context for the given image, with required fields +// set to the image's information. +func (img *ContainerImage) LogContext() *log.LogContext { + logCtx := log.WithContext() + logCtx.AddField("image_name", img.GetFullNameWithoutTag()) + logCtx.AddField("image_alias", img.ImageAlias) + logCtx.AddField("registry_url", img.RegistryURL) + if img.ImageTag != nil { + logCtx.AddField("image_tag", img.ImageTag.TagName) + logCtx.AddField("image_digest", img.ImageTag.TagDigest) + } + return logCtx +} diff --git a/registry-scanner/pkg/image/image_test.go b/registry-scanner/pkg/image/image_test.go new file mode 100644 index 0000000..1709461 --- /dev/null +++ b/registry-scanner/pkg/image/image_test.go @@ -0,0 +1,226 @@ +package image + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" + + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/tag" +) + +func Test_ParseImageTags(t *testing.T) { + t.Run("Parse valid image name without registry info", func(t *testing.T) { + image := NewFromIdentifier("jannfis/test-image:0.1") + assert.Empty(t, image.RegistryURL) + assert.Empty(t, image.ImageAlias) + assert.Equal(t, "jannfis/test-image", image.ImageName) + require.NotNil(t, image.ImageTag) + assert.Equal(t, "0.1", image.ImageTag.TagName) + assert.Equal(t, "jannfis/test-image:0.1", image.GetFullNameWithTag()) + assert.Equal(t, "jannfis/test-image", image.GetFullNameWithoutTag()) + }) + + t.Run("Single element image name is unmodified", func(t *testing.T) { + image := NewFromIdentifier("test-image") + assert.Empty(t, image.RegistryURL) + assert.Empty(t, image.ImageAlias) + assert.Equal(t, "test-image", image.ImageName) + require.Nil(t, image.ImageTag) + assert.Equal(t, "test-image", image.GetFullNameWithTag()) + assert.Equal(t, "test-image", image.GetFullNameWithoutTag()) + }) + + t.Run("library image name is unmodified", func(t *testing.T) { + image := NewFromIdentifier("library/test-image") + assert.Empty(t, image.RegistryURL) + assert.Empty(t, image.ImageAlias) + assert.Equal(t, "library/test-image", image.ImageName) + require.Nil(t, image.ImageTag) + assert.Equal(t, "library/test-image", image.GetFullNameWithTag()) + assert.Equal(t, "library/test-image", image.GetFullNameWithoutTag()) + }) + + t.Run("Parse valid image name with registry info", func(t *testing.T) { + image := NewFromIdentifier("gcr.io/jannfis/test-image:0.1") + assert.Equal(t, "gcr.io", image.RegistryURL) + assert.Empty(t, image.ImageAlias) + assert.Equal(t, "jannfis/test-image", image.ImageName) + require.NotNil(t, image.ImageTag) + assert.Equal(t, "0.1", image.ImageTag.TagName) + assert.Equal(t, "gcr.io/jannfis/test-image:0.1", image.GetFullNameWithTag()) + assert.Equal(t, "gcr.io/jannfis/test-image", image.GetFullNameWithoutTag()) + }) + + t.Run("Parse valid image name with default registry info", func(t *testing.T) { + image := NewFromIdentifier("docker.io/jannfis/test-image:0.1") + assert.Equal(t, "docker.io", image.RegistryURL) + assert.Empty(t, image.ImageAlias) + assert.Equal(t, "jannfis/test-image", image.ImageName) + require.NotNil(t, image.ImageTag) + assert.Equal(t, "0.1", image.ImageTag.TagName) + assert.Equal(t, "docker.io/jannfis/test-image:0.1", image.GetFullNameWithTag()) + assert.Equal(t, "docker.io/jannfis/test-image", image.GetFullNameWithoutTag()) + }) + + t.Run("Parse valid image name with digest tag", func(t *testing.T) { + image := NewFromIdentifier("gcr.io/jannfis/test-image@sha256:abcde") + assert.Equal(t, "gcr.io", image.RegistryURL) + assert.Empty(t, image.ImageAlias) + assert.Equal(t, "jannfis/test-image", image.ImageName) + require.NotNil(t, image.ImageTag) + assert.Empty(t, image.ImageTag.TagName) + assert.Equal(t, "sha256:abcde", image.ImageTag.TagDigest) + assert.Equal(t, "latest@sha256:abcde", image.GetTagWithDigest()) + assert.Equal(t, "gcr.io/jannfis/test-image@sha256:abcde", image.GetFullNameWithTag()) + assert.Equal(t, "gcr.io/jannfis/test-image", image.GetFullNameWithoutTag()) + }) + + t.Run("Parse valid image name with tag and digest", func(t *testing.T) { + image := NewFromIdentifier("gcr.io/jannfis/test-image:test-tag@sha256:abcde") + require.NotNil(t, image.ImageTag) + assert.Equal(t, "test-tag", image.ImageTag.TagName) + assert.Equal(t, "sha256:abcde", image.ImageTag.TagDigest) + assert.Equal(t, "test-tag@sha256:abcde", image.GetTagWithDigest()) + assert.Equal(t, "gcr.io/jannfis/test-image", image.GetFullNameWithoutTag()) + assert.Equal(t, "gcr.io/jannfis/test-image:test-tag@sha256:abcde", image.GetFullNameWithTag()) + }) + + t.Run("Parse valid image name with source name and registry info", func(t *testing.T) { + image := NewFromIdentifier("jannfis/orig-image=gcr.io/jannfis/test-image:0.1") + assert.Equal(t, "gcr.io", image.RegistryURL) + assert.Equal(t, "jannfis/orig-image", image.ImageAlias) + assert.Equal(t, "jannfis/test-image", image.ImageName) + require.NotNil(t, image.ImageTag) + assert.Equal(t, "0.1", image.ImageTag.TagName) + }) + + t.Run("Parse valid image name with source name and registry info with port", func(t *testing.T) { + image := NewFromIdentifier("ghcr.io:4567/jannfis/orig-image=gcr.io:1234/jannfis/test-image:0.1") + assert.Equal(t, "gcr.io:1234", image.RegistryURL) + assert.Equal(t, "ghcr.io:4567/jannfis/orig-image", image.ImageAlias) + assert.Equal(t, "jannfis/test-image", image.ImageName) + require.NotNil(t, image.ImageTag) + assert.Equal(t, "0.1", image.ImageTag.TagName) + }) + + t.Run("Parse image without version source name and registry info", func(t *testing.T) { + image := NewFromIdentifier("jannfis/orig-image=gcr.io/jannfis/test-image") + assert.Equal(t, "gcr.io", image.RegistryURL) + assert.Equal(t, "jannfis/orig-image", image.ImageAlias) + assert.Equal(t, "jannfis/test-image", image.ImageName) + assert.Nil(t, image.ImageTag) + }) + t.Run("#273 classic-web=registry:5000/classic-web", func(t *testing.T) { + image := NewFromIdentifier("classic-web=registry:5000/classic-web") + assert.Equal(t, "registry:5000", image.RegistryURL) + assert.Equal(t, "classic-web", image.ImageAlias) + assert.Equal(t, "classic-web", image.ImageName) + assert.Nil(t, image.ImageTag) + }) +} + +func Test_ImageToString(t *testing.T) { + t.Run("Get string representation of full-qualified image name", func(t *testing.T) { + imageName := "jannfis/argocd=jannfis/orig-image:0.1" + img := NewFromIdentifier(imageName) + assert.Equal(t, imageName, img.String()) + }) + t.Run("Get string representation of full-qualified image name with registry", func(t *testing.T) { + imageName := "jannfis/argocd=gcr.io/jannfis/orig-image:0.1" + img := NewFromIdentifier(imageName) + assert.Equal(t, imageName, img.String()) + }) + t.Run("Get string representation of full-qualified image name with registry", func(t *testing.T) { + imageName := "jannfis/argocd=gcr.io/jannfis/orig-image" + img := NewFromIdentifier(imageName) + assert.Equal(t, imageName, img.String()) + }) + t.Run("Get original value", func(t *testing.T) { + imageName := "invalid==foo" + img := NewFromIdentifier(imageName) + assert.Equal(t, imageName, img.Original()) + }) +} + +func Test_WithTag(t *testing.T) { + t.Run("Get string representation of full-qualified image name", func(t *testing.T) { + imageName := "jannfis/argocd=jannfis/orig-image:0.1" + nimageName := "jannfis/argocd=jannfis/orig-image:0.2" + oImg := NewFromIdentifier(imageName) + nImg := oImg.WithTag(tag.NewImageTag("0.2", time.Unix(0, 0), "")) + assert.Equal(t, nimageName, nImg.String()) + }) +} + +func Test_ContainerList(t *testing.T) { + t.Run("Test whether image is contained in list", func(t *testing.T) { + images := make(ContainerImageList, 0) + image_names := []string{"a/a:0.1", "a/b:1.2", "x/y=foo.bar/a/c:0.23"} + for _, n := range image_names { + images = append(images, NewFromIdentifier(n)) + } + withKustomizeOverride := NewFromIdentifier("k1/k2:k3") + withKustomizeOverride.KustomizeImage = images[0] + images = append(images, withKustomizeOverride) + + assert.NotNil(t, images.ContainsImage(NewFromIdentifier(image_names[0]), false)) + assert.NotNil(t, images.ContainsImage(NewFromIdentifier(image_names[1]), false)) + assert.NotNil(t, images.ContainsImage(NewFromIdentifier(image_names[2]), false)) + assert.Nil(t, images.ContainsImage(NewFromIdentifier("foo/bar"), false)) + + imageMatch := images.ContainsImage(withKustomizeOverride, false) + assert.Equal(t, images[0], imageMatch) + }) +} + +func Test_getImageDigestFromTag(t *testing.T) { + tagAndDigest := "test-tag@sha256:abcde" + tagName, tagDigest := getImageDigestFromTag(tagAndDigest) + assert.Equal(t, "test-tag", tagName) + assert.Equal(t, "sha256:abcde", tagDigest) + + tagAndDigest = "test-tag" + tagName, tagDigest = getImageDigestFromTag(tagAndDigest) + assert.Equal(t, "test-tag", tagName) + assert.Empty(t, tagDigest) +} + +func Test_ContainerImageList_String_Originals(t *testing.T) { + images := make(ContainerImageList, 0) + originals := []string{} + + assert.Equal(t, "", images.String()) + assert.True(t, slices.Equal(originals, images.Originals())) + + images = append(images, NewFromIdentifier("foo/bar:0.1")) + originals = append(originals, "foo/bar:0.1") + assert.Equal(t, "foo/bar:0.1", images.String()) + assert.True(t, slices.Equal(originals, images.Originals())) + + images = append(images, NewFromIdentifier("alias=foo/bar:0.2")) + originals = append(originals, "alias=foo/bar:0.2") + assert.Equal(t, "foo/bar:0.1,alias=foo/bar:0.2", images.String()) + assert.True(t, slices.Equal(originals, images.Originals())) +} + +func TestContainerImage_DiffersFrom(t *testing.T) { + foo1 := NewFromIdentifier("x/foo:1") + foo2 := NewFromIdentifier("x/foo:2") + bar1 := NewFromIdentifier("x/bar:1") + bar1WithRegistry := NewFromIdentifier("docker.io/x/bar:1") + + assert.False(t, foo1.DiffersFrom(foo1, true)) + assert.False(t, foo1.DiffersFrom(foo2, false)) + assert.True(t, foo1.DiffersFrom(foo2, true)) + + assert.True(t, foo1.DiffersFrom(bar1, false)) + assert.True(t, bar1.DiffersFrom(foo1, false)) + assert.True(t, foo1.DiffersFrom(bar1, true)) + assert.True(t, bar1.DiffersFrom(foo1, true)) + assert.True(t, bar1.DiffersFrom(bar1WithRegistry, false)) + + assert.False(t, foo1.IsUpdatable("0.1", "^1.0")) +} diff --git a/registry-scanner/pkg/image/kustomize.go b/registry-scanner/pkg/image/kustomize.go new file mode 100644 index 0000000..ef7c88b --- /dev/null +++ b/registry-scanner/pkg/image/kustomize.go @@ -0,0 +1,39 @@ +package image + +import ( + "strings" +) + +// Shamelessly ripped from ArgoCD CLI code + +type KustomizeImage string + +func (i KustomizeImage) delim() string { + for _, d := range []string{"=", ":", "@"} { + if strings.Contains(string(i), d) { + return d + } + } + return ":" +} + +// if the image name matches (i.e. up to the first delimiter) +func (i KustomizeImage) Match(j KustomizeImage) bool { + delim := j.delim() + if !strings.Contains(string(j), delim) { + return false + } + return strings.HasPrefix(string(i), strings.Split(string(j), delim)[0]) +} + +type KustomizeImages []KustomizeImage + +// find the image or -1 +func (images KustomizeImages) Find(image KustomizeImage) int { + for i, a := range images { + if a.Match(image) { + return i + } + } + return -1 +} diff --git a/registry-scanner/pkg/image/kustomize_test.go b/registry-scanner/pkg/image/kustomize_test.go new file mode 100644 index 0000000..98dede9 --- /dev/null +++ b/registry-scanner/pkg/image/kustomize_test.go @@ -0,0 +1,26 @@ +package image + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_KustomizeImages_Find(t *testing.T) { + images := KustomizeImages{ + "a/b:1.0", + "a/b@sha256:aabb", + "a/b:latest@sha256:aabb", + "x/y=busybox", + "x/y=foo.bar/a/c:0.23", + } + for _, image := range images { + assert.True(t, images.Find(image) >= 0) + } + for _, image := range []string{"a/b:2", "x/y=foo.bar"} { + assert.True(t, images.Find(KustomizeImage(image)) >= 0) + } + for _, image := range []string{"a/b", "x", "x/y"} { + assert.Equal(t, -1, images.Find(KustomizeImage(image))) + } +} diff --git a/registry-scanner/pkg/image/matchfunc.go b/registry-scanner/pkg/image/matchfunc.go new file mode 100644 index 0000000..036e6fb --- /dev/null +++ b/registry-scanner/pkg/image/matchfunc.go @@ -0,0 +1,27 @@ +package image + +import ( + "regexp" + + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log" +) + +// MatchFuncAny matches any pattern, i.e. always returns true +func MatchFuncAny(tagName string, args interface{}) bool { + return true +} + +// MatchFuncNone matches no pattern, i.e. always returns false +func MatchFuncNone(tagName string, args interface{}) bool { + return false +} + +// MatchFuncRegexp matches the tagName against regexp pattern and returns the result +func MatchFuncRegexp(tagName string, args interface{}) bool { + pattern, ok := args.(*regexp.Regexp) + if !ok { + log.Errorf("args is not a RegExp") + return false + } + return pattern.Match([]byte(tagName)) +} diff --git a/registry-scanner/pkg/image/matchfunc_test.go b/registry-scanner/pkg/image/matchfunc_test.go new file mode 100644 index 0000000..11929b1 --- /dev/null +++ b/registry-scanner/pkg/image/matchfunc_test.go @@ -0,0 +1,27 @@ +package image + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_MatchFuncAny(t *testing.T) { + assert.True(t, MatchFuncAny("whatever", nil)) +} + +func Test_MatchFuncNone(t *testing.T) { + assert.False(t, MatchFuncNone("whatever", nil)) +} + +func Test_MatchFuncRegexp(t *testing.T) { + t.Run("Test with valid expression", func(t *testing.T) { + re := regexp.MustCompile("[a-z]+") + assert.True(t, MatchFuncRegexp("lemon", re)) + assert.False(t, MatchFuncRegexp("31337", re)) + }) + t.Run("Test with invalid type", func(t *testing.T) { + assert.False(t, MatchFuncRegexp("lemon", "[a-z]+")) + }) +} diff --git a/registry-scanner/pkg/image/options.go b/registry-scanner/pkg/image/options.go new file mode 100644 index 0000000..3c599dc --- /dev/null +++ b/registry-scanner/pkg/image/options.go @@ -0,0 +1,296 @@ +package image + +import ( + "fmt" + "regexp" + "runtime" + "strings" + + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/common" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/options" +) + +// GetParameterHelmImageName gets the value for image-name option for the image +// from a set of annotations +func (img *ContainerImage) GetParameterHelmImageName(annotations map[string]string) string { + key := fmt.Sprintf(common.HelmParamImageNameAnnotation, img.normalizedSymbolicName()) + val, ok := annotations[key] + if !ok { + return "" + } + return val +} + +// GetParameterHelmImageTag gets the value for image-tag option for the image +// from a set of annotations +func (img *ContainerImage) GetParameterHelmImageTag(annotations map[string]string) string { + key := fmt.Sprintf(common.HelmParamImageTagAnnotation, img.normalizedSymbolicName()) + val, ok := annotations[key] + if !ok { + return "" + } + return val +} + +// GetParameterHelmImageSpec gets the value for image-spec option for the image +// from a set of annotations +func (img *ContainerImage) GetParameterHelmImageSpec(annotations map[string]string) string { + key := fmt.Sprintf(common.HelmParamImageSpecAnnotation, img.normalizedSymbolicName()) + val, ok := annotations[key] + if !ok { + return "" + } + return val +} + +// GetParameterKustomizeImageName gets the value for image-spec option for the +// image from a set of annotations +func (img *ContainerImage) GetParameterKustomizeImageName(annotations map[string]string) string { + key := fmt.Sprintf(common.KustomizeApplicationNameAnnotation, img.normalizedSymbolicName()) + val, ok := annotations[key] + if !ok { + return "" + } + return val +} + +// HasForceUpdateOptionAnnotation gets the value for force-update option for the +// image from a set of annotations +func (img *ContainerImage) HasForceUpdateOptionAnnotation(annotations map[string]string) bool { + forceUpdateAnnotations := []string{ + fmt.Sprintf(common.ForceUpdateOptionAnnotation, img.normalizedSymbolicName()), + common.ApplicationWideForceUpdateOptionAnnotation, + } + var forceUpdateVal = "" + for _, key := range forceUpdateAnnotations { + if val, ok := annotations[key]; ok { + forceUpdateVal = val + break + } + } + return forceUpdateVal == "true" +} + +// GetParameterSort gets and validates the value for the sort option for the +// image from a set of annotations +func (img *ContainerImage) GetParameterUpdateStrategy(annotations map[string]string) UpdateStrategy { + updateStrategyAnnotations := []string{ + fmt.Sprintf(common.UpdateStrategyAnnotation, img.normalizedSymbolicName()), + common.ApplicationWideUpdateStrategyAnnotation, + } + var updateStrategyVal = "" + for _, key := range updateStrategyAnnotations { + if val, ok := annotations[key]; ok { + updateStrategyVal = val + break + } + } + logCtx := img.LogContext() + if updateStrategyVal == "" { + logCtx.Tracef("No sort option found") + // Default is sort by version + return StrategySemVer + } + logCtx.Tracef("Found update strategy %s", updateStrategyVal) + return img.ParseUpdateStrategy(updateStrategyVal) +} + +func (img *ContainerImage) ParseUpdateStrategy(val string) UpdateStrategy { + logCtx := img.LogContext() + switch strings.ToLower(val) { + case "semver": + return StrategySemVer + case "latest": + logCtx.Warnf("\"latest\" strategy has been renamed to \"newest-build\". Please switch to the new convention as support for the old naming convention will be removed in future versions.") + fallthrough + case "newest-build": + return StrategyNewestBuild + case "name": + logCtx.Warnf("\"name\" strategy has been renamed to \"alphabetical\". Please switch to the new convention as support for the old naming convention will be removed in future versions.") + fallthrough + case "alphabetical": + return StrategyAlphabetical + case "digest": + return StrategyDigest + default: + logCtx.Warnf("Unknown sort option %s -- using semver", val) + return StrategySemVer + } +} + +// GetParameterMatch returns the match function and pattern to use for matching +// tag names. If an invalid option is found, it returns MatchFuncNone as the +// default, to prevent accidental matches. +func (img *ContainerImage) GetParameterMatch(annotations map[string]string) (MatchFuncFn, interface{}) { + allowTagsAnnotations := []string{ + fmt.Sprintf(common.AllowTagsOptionAnnotation, img.normalizedSymbolicName()), + common.ApplicationWideAllowTagsOptionAnnotation, + } + var allowTagsVal = "" + for _, key := range allowTagsAnnotations { + if val, ok := annotations[key]; ok { + allowTagsVal = val + break + } + } + logCtx := img.LogContext() + if allowTagsVal == "" { + // The old match-tag annotation is deprecated and will be subject to removal + // in a future version. + key := fmt.Sprintf(common.OldMatchOptionAnnotation, img.normalizedSymbolicName()) + val, ok := annotations[key] + if ok { + logCtx.Warnf("The 'tag-match' annotation is deprecated and subject to removal. Please use 'allow-tags' annotation instead.") + allowTagsVal = val + } + } + if allowTagsVal == "" { + logCtx.Tracef("No match annotation found") + return MatchFuncAny, "" + } + return img.ParseMatchfunc(allowTagsVal) +} + +// ParseMatchfunc returns a matcher function and its argument from given value +func (img *ContainerImage) ParseMatchfunc(val string) (MatchFuncFn, interface{}) { + logCtx := img.LogContext() + + // The special value "any" doesn't take any parameter + if strings.ToLower(val) == "any" { + return MatchFuncAny, nil + } + + opt := strings.SplitN(val, ":", 2) + if len(opt) != 2 { + logCtx.Warnf("Invalid match option syntax '%s', ignoring", val) + return MatchFuncNone, nil + } + switch strings.ToLower(opt[0]) { + case "regexp": + re, err := regexp.Compile(opt[1]) + if err != nil { + logCtx.Warnf("Could not compile regexp '%s'", opt[1]) + return MatchFuncNone, nil + } + return MatchFuncRegexp, re + default: + logCtx.Warnf("Unknown match function: %s", opt[0]) + return MatchFuncNone, nil + } +} + +// GetParameterPullSecret retrieves an image's pull secret credentials +func (img *ContainerImage) GetParameterPullSecret(annotations map[string]string) *CredentialSource { + pullSecretAnnotations := []string{ + fmt.Sprintf(common.PullSecretAnnotation, img.normalizedSymbolicName()), + common.ApplicationWidePullSecretAnnotation, + } + var pullSecretVal = "" + for _, key := range pullSecretAnnotations { + if val, ok := annotations[key]; ok { + pullSecretVal = val + break + } + } + logCtx := img.LogContext() + if pullSecretVal == "" { + logCtx.Tracef("No pull-secret annotation found") + return nil + } + credSrc, err := ParseCredentialSource(pullSecretVal, false) + if err != nil { + logCtx.Warnf("Invalid credential reference specified: %s", pullSecretVal) + return nil + } + return credSrc +} + +// GetParameterIgnoreTags retrieves a list of tags to ignore from a comma-separated string +func (img *ContainerImage) GetParameterIgnoreTags(annotations map[string]string) []string { + ignoreTagsAnnotations := []string{ + fmt.Sprintf(common.IgnoreTagsOptionAnnotation, img.normalizedSymbolicName()), + common.ApplicationWideIgnoreTagsOptionAnnotation, + } + var ignoreTagsVal = "" + for _, key := range ignoreTagsAnnotations { + if val, ok := annotations[key]; ok { + ignoreTagsVal = val + break + } + } + logCtx := img.LogContext() + if ignoreTagsVal == "" { + logCtx.Tracef("No ignore-tags annotation found") + return nil + } + ignoreList := make([]string, 0) + tags := strings.Split(strings.TrimSpace(ignoreTagsVal), ",") + for _, tag := range tags { + // We ignore empty tags + trimmed := strings.TrimSpace(tag) + if trimmed != "" { + ignoreList = append(ignoreList, trimmed) + } + } + return ignoreList +} + +// GetPlatformOptions sets up platform constraints for an image. If no platform +// is specified in the annotations, we restrict the platform for images to the +// platform we're executed on unless unrestricted is set to true, in which case +// we do not setup a platform restriction if no platform annotation is found. +func (img *ContainerImage) GetPlatformOptions(annotations map[string]string, unrestricted bool) *options.ManifestOptions { + logCtx := img.LogContext() + var opts *options.ManifestOptions = options.NewManifestOptions() + key := fmt.Sprintf(common.PlatformsAnnotation, img.normalizedSymbolicName()) + val, ok := annotations[key] + if !ok { + if !unrestricted { + os := runtime.GOOS + arch := runtime.GOARCH + variant := "" + if strings.Contains(runtime.GOARCH, "/") { + a := strings.SplitN(runtime.GOARCH, "/", 2) + arch = a[0] + variant = a[1] + } + logCtx.Tracef("Using runtime platform constraint %s", options.PlatformKey(os, arch, variant)) + opts = opts.WithPlatform(os, arch, variant) + } + } else { + platforms := strings.Split(val, ",") + for _, ps := range platforms { + pt := strings.TrimSpace(ps) + os, arch, variant, err := ParsePlatform(pt) + if err != nil { + // If the platform identifier could not be parsed, we set the + // constraint intentionally to the invalid value so we don't + // end up updating to the wrong architecture possibly. + os = ps + logCtx.Warnf("could not parse platform identifier '%v': invalid format", pt) + } + logCtx.Tracef("Adding platform constraint %s", options.PlatformKey(os, arch, variant)) + opts = opts.WithPlatform(os, arch, variant) + } + } + + return opts +} + +func ParsePlatform(platformID string) (string, string, string, error) { + p := strings.SplitN(platformID, "/", 3) + if len(p) < 2 { + return "", "", "", fmt.Errorf("could not parse platform constraint '%s'", platformID) + } + os := p[0] + arch := p[1] + variant := "" + if len(p) == 3 { + variant = p[2] + } + return os, arch, variant, nil +} + +func (img *ContainerImage) normalizedSymbolicName() string { + return strings.ReplaceAll(img.ImageAlias, "/", "_") +} diff --git a/registry-scanner/pkg/image/options_test.go b/registry-scanner/pkg/image/options_test.go new file mode 100644 index 0000000..c1c7613 --- /dev/null +++ b/registry-scanner/pkg/image/options_test.go @@ -0,0 +1,493 @@ +package image + +import ( + "fmt" + "regexp" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/common" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/options" +) + +func Test_GetHelmOptions(t *testing.T) { + t.Run("Get Helm parameter for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.HelmParamImageNameAnnotation, "dummy"): "release.name", + fmt.Sprintf(common.HelmParamImageTagAnnotation, "dummy"): "release.tag", + fmt.Sprintf(common.HelmParamImageSpecAnnotation, "dummy"): "release.image", + } + + img := NewFromIdentifier("dummy=foo/bar:1.12") + paramName := img.GetParameterHelmImageName(annotations) + paramTag := img.GetParameterHelmImageTag(annotations) + paramSpec := img.GetParameterHelmImageSpec(annotations) + assert.Equal(t, "release.name", paramName) + assert.Equal(t, "release.tag", paramTag) + assert.Equal(t, "release.image", paramSpec) + }) + + t.Run("Get Helm parameter for non-configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.HelmParamImageNameAnnotation, "dummy"): "release.name", + fmt.Sprintf(common.HelmParamImageTagAnnotation, "dummy"): "release.tag", + fmt.Sprintf(common.HelmParamImageSpecAnnotation, "dummy"): "release.image", + } + + img := NewFromIdentifier("foo=foo/bar:1.12") + paramName := img.GetParameterHelmImageName(annotations) + paramTag := img.GetParameterHelmImageTag(annotations) + paramSpec := img.GetParameterHelmImageSpec(annotations) + assert.Equal(t, "", paramName) + assert.Equal(t, "", paramTag) + assert.Equal(t, "", paramSpec) + }) + + t.Run("Get Helm parameter for configured application with normalized name", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.HelmParamImageNameAnnotation, "foo_dummy"): "release.name", + fmt.Sprintf(common.HelmParamImageTagAnnotation, "foo_dummy"): "release.tag", + fmt.Sprintf(common.HelmParamImageSpecAnnotation, "foo_dummy"): "release.image", + } + + img := NewFromIdentifier("foo/dummy=foo/bar:1.12") + paramName := img.GetParameterHelmImageName(annotations) + paramTag := img.GetParameterHelmImageTag(annotations) + paramSpec := img.GetParameterHelmImageSpec(annotations) + assert.Equal(t, "release.name", paramName) + assert.Equal(t, "release.tag", paramTag) + assert.Equal(t, "release.image", paramSpec) + }) +} + +func Test_GetKustomizeOptions(t *testing.T) { + t.Run("Get Kustomize parameter for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.KustomizeApplicationNameAnnotation, "dummy"): "argoproj/argo-cd", + } + + img := NewFromIdentifier("dummy=foo/bar:1.12") + paramName := img.GetParameterKustomizeImageName(annotations) + assert.Equal(t, "argoproj/argo-cd", paramName) + + img = NewFromIdentifier("dummy2=foo2/bar2:1.12") + paramName = img.GetParameterKustomizeImageName(annotations) + assert.Equal(t, "", paramName) + }) +} + +func Test_GetSortOption(t *testing.T) { + t.Run("Get update strategy semver for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.UpdateStrategyAnnotation, "dummy"): "semver", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategySemVer, sortMode) + }) + + t.Run("Use update strategy newest-build for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.UpdateStrategyAnnotation, "dummy"): "newest-build", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategyNewestBuild, sortMode) + }) + + t.Run("Get update strategy date for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.UpdateStrategyAnnotation, "dummy"): "latest", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategyNewestBuild, sortMode) + }) + + t.Run("Get update strategy name for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.UpdateStrategyAnnotation, "dummy"): "name", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategyAlphabetical, sortMode) + }) + + t.Run("Use update strategy alphabetical for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.UpdateStrategyAnnotation, "dummy"): "alphabetical", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategyAlphabetical, sortMode) + }) + + t.Run("Get update strategy option configured application because of invalid option", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.UpdateStrategyAnnotation, "dummy"): "invalid", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategySemVer, sortMode) + }) + + t.Run("Get update strategy option configured application because of option not set", func(t *testing.T) { + annotations := map[string]string{} + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategySemVer, sortMode) + }) + + t.Run("Prefer update strategy option from image-specific annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.UpdateStrategyAnnotation, "dummy"): "alphabetical", + common.ApplicationWideUpdateStrategyAnnotation: "newest-build", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategyAlphabetical, sortMode) + }) + + t.Run("Get update strategy option from application-wide annotation", func(t *testing.T) { + annotations := map[string]string{ + common.ApplicationWideUpdateStrategyAnnotation: "newest-build", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategyNewestBuild, sortMode) + }) + + t.Run("Get update strategy option digest from application-wide annotation", func(t *testing.T) { + annotations := map[string]string{ + common.ApplicationWideUpdateStrategyAnnotation: "digest", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + sortMode := img.GetParameterUpdateStrategy(annotations) + assert.Equal(t, StrategyDigest, sortMode) + }) +} + +func Test_GetMatchOption(t *testing.T) { + t.Run("Get regexp match option for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.AllowTagsOptionAnnotation, "dummy"): "regexp:a-z", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + matchFunc, matchArgs := img.GetParameterMatch(annotations) + require.NotNil(t, matchFunc) + require.NotNil(t, matchArgs) + assert.IsType(t, ®exp.Regexp{}, matchArgs) + }) + + t.Run("Get regexp match option for configured application with invalid expression", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.AllowTagsOptionAnnotation, "dummy"): `regexp:/foo\`, + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + matchFunc, matchArgs := img.GetParameterMatch(annotations) + require.NotNil(t, matchFunc) + require.Nil(t, matchArgs) + }) + + t.Run("Get invalid match option for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.AllowTagsOptionAnnotation, "dummy"): "invalid", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + matchFunc, matchArgs := img.GetParameterMatch(annotations) + require.NotNil(t, matchFunc) + require.Equal(t, false, matchFunc("", nil)) + assert.Nil(t, matchArgs) + }) + + t.Run("No match option for configured application", func(t *testing.T) { + annotations := map[string]string{} + img := NewFromIdentifier("dummy=foo/bar:1.12") + matchFunc, matchArgs := img.GetParameterMatch(annotations) + require.NotNil(t, matchFunc) + require.Equal(t, true, matchFunc("", nil)) + assert.Equal(t, "", matchArgs) + }) + + t.Run("Prefer match option from image-specific annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.AllowTagsOptionAnnotation, "dummy"): "regexp:^[0-9]", + common.ApplicationWideAllowTagsOptionAnnotation: "regexp:^v", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + matchFunc, matchArgs := img.GetParameterMatch(annotations) + require.NotNil(t, matchFunc) + require.NotNil(t, matchArgs) + assert.IsType(t, ®exp.Regexp{}, matchArgs) + assert.True(t, matchFunc("0.0.1", matchArgs)) + assert.False(t, matchFunc("v0.0.1", matchArgs)) + }) + + t.Run("Get match option from application-wide annotation", func(t *testing.T) { + annotations := map[string]string{ + common.ApplicationWideAllowTagsOptionAnnotation: "regexp:^v", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + matchFunc, matchArgs := img.GetParameterMatch(annotations) + require.NotNil(t, matchFunc) + require.NotNil(t, matchArgs) + assert.IsType(t, ®exp.Regexp{}, matchArgs) + assert.False(t, matchFunc("0.0.1", matchArgs)) + assert.True(t, matchFunc("v0.0.1", matchArgs)) + }) +} + +func Test_GetSecretOption(t *testing.T) { + t.Run("Get cred source from annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.PullSecretAnnotation, "dummy"): "pullsecret:foo/bar", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + credSrc := img.GetParameterPullSecret(annotations) + require.NotNil(t, credSrc) + assert.Equal(t, CredentialSourcePullSecret, credSrc.Type) + assert.Equal(t, "foo", credSrc.SecretNamespace) + assert.Equal(t, "bar", credSrc.SecretName) + assert.Equal(t, ".dockerconfigjson", credSrc.SecretField) + }) + + t.Run("Invalid reference in annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.PullSecretAnnotation, "dummy"): "foo/bar", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + credSrc := img.GetParameterPullSecret(annotations) + require.Nil(t, credSrc) + }) + + t.Run("Missing pull secret in annotation", func(t *testing.T) { + annotations := map[string]string{} + img := NewFromIdentifier("dummy=foo/bar:1.12") + credSrc := img.GetParameterPullSecret(annotations) + require.Nil(t, credSrc) + }) + + t.Run("Prefer cred source from image-specific annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.PullSecretAnnotation, "dummy"): "pullsecret:image/specific", + common.ApplicationWidePullSecretAnnotation: "pullsecret:app/wide", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + credSrc := img.GetParameterPullSecret(annotations) + require.NotNil(t, credSrc) + assert.Equal(t, CredentialSourcePullSecret, credSrc.Type) + assert.Equal(t, "image", credSrc.SecretNamespace) + assert.Equal(t, "specific", credSrc.SecretName) + assert.Equal(t, ".dockerconfigjson", credSrc.SecretField) + }) + + t.Run("Get cred source from application-wide annotation", func(t *testing.T) { + annotations := map[string]string{ + common.ApplicationWidePullSecretAnnotation: "pullsecret:app/wide", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + credSrc := img.GetParameterPullSecret(annotations) + require.NotNil(t, credSrc) + assert.Equal(t, CredentialSourcePullSecret, credSrc.Type) + assert.Equal(t, "app", credSrc.SecretNamespace) + assert.Equal(t, "wide", credSrc.SecretName) + assert.Equal(t, ".dockerconfigjson", credSrc.SecretField) + }) +} + +func Test_GetIgnoreTags(t *testing.T) { + t.Run("Get list of tags to ignore from image-specific annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.IgnoreTagsOptionAnnotation, "dummy"): "tag1, ,tag2, tag3 , tag4", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + tags := img.GetParameterIgnoreTags(annotations) + require.Len(t, tags, 4) + assert.Equal(t, "tag1", tags[0]) + assert.Equal(t, "tag2", tags[1]) + assert.Equal(t, "tag3", tags[2]) + assert.Equal(t, "tag4", tags[3]) + }) + + t.Run("No tags to ignore from image-specific annotation", func(t *testing.T) { + annotations := map[string]string{} + img := NewFromIdentifier("dummy=foo/bar:1.12") + tags := img.GetParameterIgnoreTags(annotations) + require.Nil(t, tags) + }) + + t.Run("Prefer list of tags to ignore from image-specific annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.IgnoreTagsOptionAnnotation, "dummy"): "tag1, tag2", + common.ApplicationWideIgnoreTagsOptionAnnotation: "tag3, tag4", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + tags := img.GetParameterIgnoreTags(annotations) + require.Len(t, tags, 2) + assert.Equal(t, "tag1", tags[0]) + assert.Equal(t, "tag2", tags[1]) + }) + + t.Run("Get list of tags to ignore from application-wide annotation", func(t *testing.T) { + annotations := map[string]string{ + common.ApplicationWideIgnoreTagsOptionAnnotation: "tag3, tag4", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + tags := img.GetParameterIgnoreTags(annotations) + require.Len(t, tags, 2) + assert.Equal(t, "tag3", tags[0]) + assert.Equal(t, "tag4", tags[1]) + }) +} + +func Test_HasForceUpdateOptionAnnotation(t *testing.T) { + t.Run("Get force-update option from image-specific annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.ForceUpdateOptionAnnotation, "dummy"): "true", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + forceUpdate := img.HasForceUpdateOptionAnnotation(annotations) + assert.True(t, forceUpdate) + }) + + t.Run("Prefer force-update option from image-specific annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.ForceUpdateOptionAnnotation, "dummy"): "true", + common.ApplicationWideForceUpdateOptionAnnotation: "false", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + forceUpdate := img.HasForceUpdateOptionAnnotation(annotations) + assert.True(t, forceUpdate) + }) + + t.Run("Get force-update option from application-wide annotation", func(t *testing.T) { + annotations := map[string]string{ + common.ApplicationWideForceUpdateOptionAnnotation: "false", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + forceUpdate := img.HasForceUpdateOptionAnnotation(annotations) + assert.False(t, forceUpdate) + }) +} + +func Test_GetPlatformOptions(t *testing.T) { + t.Run("Empty platform options with restriction", func(t *testing.T) { + annotations := map[string]string{} + img := NewFromIdentifier("dummy=foo/bar:1.12") + opts := img.GetPlatformOptions(annotations, false) + os := runtime.GOOS + arch := runtime.GOARCH + platform := opts.Platforms()[0] + slashCount := strings.Count(platform, "/") + if slashCount == 1 { + assert.True(t, opts.WantsPlatform(os, arch, "")) + assert.True(t, opts.WantsPlatform(os, arch, "invalid")) + } else if slashCount == 2 { + assert.False(t, opts.WantsPlatform(os, arch, "")) + assert.False(t, opts.WantsPlatform(os, arch, "invalid")) + } else { + t.Fatal("invalid platform options ", platform) + } + }) + t.Run("Empty platform options without restriction", func(t *testing.T) { + annotations := map[string]string{} + img := NewFromIdentifier("dummy=foo/bar:1.12") + opts := img.GetPlatformOptions(annotations, true) + os := runtime.GOOS + arch := runtime.GOARCH + assert.True(t, opts.WantsPlatform(os, arch, "")) + assert.True(t, opts.WantsPlatform(os, arch, "invalid")) + assert.True(t, opts.WantsPlatform("windows", "amd64", "")) + }) + t.Run("Single platform without variant requested", func(t *testing.T) { + os := "linux" + arch := "arm64" + variant := "v8" + annotations := map[string]string{ + fmt.Sprintf(common.PlatformsAnnotation, "dummy"): options.PlatformKey(os, arch, variant), + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + opts := img.GetPlatformOptions(annotations, false) + assert.True(t, opts.WantsPlatform(os, arch, variant)) + assert.False(t, opts.WantsPlatform(os, arch, "invalid")) + }) + t.Run("Single platform with variant requested", func(t *testing.T) { + os := "linux" + arch := "arm" + variant := "v6" + annotations := map[string]string{ + fmt.Sprintf(common.PlatformsAnnotation, "dummy"): options.PlatformKey(os, arch, variant), + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + opts := img.GetPlatformOptions(annotations, false) + assert.True(t, opts.WantsPlatform(os, arch, variant)) + assert.False(t, opts.WantsPlatform(os, arch, "")) + assert.False(t, opts.WantsPlatform(runtime.GOOS, runtime.GOARCH, "")) + assert.False(t, opts.WantsPlatform(runtime.GOOS, runtime.GOARCH, variant)) + }) + t.Run("Multiple platforms requested", func(t *testing.T) { + os := "linux" + arch := "arm" + variant := "v6" + annotations := map[string]string{ + fmt.Sprintf(common.PlatformsAnnotation, "dummy"): options.PlatformKey(os, arch, variant) + ", " + options.PlatformKey(runtime.GOOS, runtime.GOARCH, ""), + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + opts := img.GetPlatformOptions(annotations, false) + assert.True(t, opts.WantsPlatform(os, arch, variant)) + assert.True(t, opts.WantsPlatform(runtime.GOOS, runtime.GOARCH, "")) + assert.False(t, opts.WantsPlatform(os, arch, "")) + assert.True(t, opts.WantsPlatform(runtime.GOOS, runtime.GOARCH, variant)) + }) + t.Run("Invalid platform requested", func(t *testing.T) { + os := "linux" + arch := "arm" + variant := "v6" + annotations := map[string]string{ + fmt.Sprintf(common.PlatformsAnnotation, "dummy"): "invalid", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + opts := img.GetPlatformOptions(annotations, false) + assert.False(t, opts.WantsPlatform(os, arch, variant)) + assert.False(t, opts.WantsPlatform(runtime.GOOS, runtime.GOARCH, "")) + assert.False(t, opts.WantsPlatform(os, arch, "")) + assert.False(t, opts.WantsPlatform(runtime.GOOS, runtime.GOARCH, variant)) + }) +} + +func Test_ContainerImage_ParseMatchfunc(t *testing.T) { + img := NewFromIdentifier("dummy=foo/bar:1.12") + matchFunc, pattern := img.ParseMatchfunc("any") + assert.True(t, matchFunc("MatchFuncAny any tag name", pattern)) + assert.Nil(t, pattern) + + matchFunc, pattern = img.ParseMatchfunc("ANY") + assert.True(t, matchFunc("MatchFuncAny any tag name", pattern)) + assert.Nil(t, pattern) + + matchFunc, pattern = img.ParseMatchfunc("other") + assert.False(t, matchFunc("MatchFuncNone any tag name", pattern)) + assert.Nil(t, pattern) + + matchFunc, pattern = img.ParseMatchfunc("not-regexp:a-z") + assert.False(t, matchFunc("MatchFuncNone any tag name", pattern)) + assert.Nil(t, pattern) + + matchFunc, pattern = img.ParseMatchfunc("regexp:[aA-zZ]") + assert.True(t, matchFunc("MatchFuncRegexp-tag-name", pattern)) + compiledRegexp, _ := regexp.Compile("[aA-zZ]") + assert.Equal(t, compiledRegexp, pattern) + + matchFunc, pattern = img.ParseMatchfunc("RegExp:[aA-zZ]") + assert.True(t, matchFunc("MatchFuncRegexp-tag-name", pattern)) + compiledRegexp, _ = regexp.Compile("[aA-zZ]") + assert.Equal(t, compiledRegexp, pattern) + + matchFunc, pattern = img.ParseMatchfunc("regexp:[aA-zZ") //invalid regexp: missing end ] + assert.False(t, matchFunc("MatchFuncNone-tag-name", pattern)) + assert.Nil(t, pattern) +} diff --git a/registry-scanner/pkg/image/version.go b/registry-scanner/pkg/image/version.go new file mode 100644 index 0000000..97437bd --- /dev/null +++ b/registry-scanner/pkg/image/version.go @@ -0,0 +1,220 @@ +package image + +import ( + "path/filepath" + + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/options" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/tag" + + "github.com/Masterminds/semver/v3" +) + +// VersionSortMode defines the method to sort a list of tags +type UpdateStrategy int + +const ( + // VersionSortSemVer sorts tags using semver sorting (the default) + StrategySemVer UpdateStrategy = 0 + // VersionSortLatest sorts tags after their creation date + StrategyNewestBuild UpdateStrategy = 1 + // VersionSortName sorts tags alphabetically by name + StrategyAlphabetical UpdateStrategy = 2 + // VersionSortDigest uses latest digest of an image + StrategyDigest UpdateStrategy = 3 +) + +func (us UpdateStrategy) String() string { + switch us { + case StrategySemVer: + return "semver" + case StrategyNewestBuild: + return "newest-build" + case StrategyAlphabetical: + return "alphabetical" + case StrategyDigest: + return "digest" + } + + return "unknown" +} + +// ConstraintMatchMode defines how the constraint should be matched +type ConstraintMatchMode int + +const ( + // ConstraintMatchSemVer uses semver to match a constraint + ConstraintMatchSemver ConstraintMatchMode = 0 + // ConstraintMatchRegExp uses regexp to match a constraint + ConstraintMatchRegExp ConstraintMatchMode = 1 + // ConstraintMatchNone does not enforce a constraint + ConstraintMatchNone ConstraintMatchMode = 2 +) + +// VersionConstraint defines a constraint for comparing versions +type VersionConstraint struct { + Constraint string + MatchFunc MatchFuncFn + MatchArgs interface{} + IgnoreList []string + Strategy UpdateStrategy + Options *options.ManifestOptions +} + +type MatchFuncFn func(tagName string, pattern interface{}) bool + +// String returns the string representation of VersionConstraint +func (vc *VersionConstraint) String() string { + return vc.Constraint +} + +func NewVersionConstraint() *VersionConstraint { + return &VersionConstraint{ + MatchFunc: MatchFuncNone, + Strategy: StrategySemVer, + Options: options.NewManifestOptions(), + } +} + +// GetNewestVersionFromTags returns the latest available version from a list of +// tags while optionally taking a semver constraint into account. Returns the +// original version if no new version could be found from the list of tags. +func (img *ContainerImage) GetNewestVersionFromTags(vc *VersionConstraint, tagList *tag.ImageTagList) (*tag.ImageTag, error) { + logCtx := log.NewContext() + logCtx.AddField("image", img.String()) + + var availableTags tag.SortableImageTagList + switch vc.Strategy { + case StrategySemVer: + availableTags = tagList.SortBySemVer() + case StrategyAlphabetical: + availableTags = tagList.SortAlphabetically() + case StrategyNewestBuild: + availableTags = tagList.SortByDate() + case StrategyDigest: + availableTags = tagList.SortAlphabetically() + } + + considerTags := tag.SortableImageTagList{} + + // It makes no sense to proceed if we have no available tags + if len(availableTags) == 0 { + return img.ImageTag, nil + } + + // The given constraint MUST match a semver constraint + var semverConstraint *semver.Constraints + var err error + if vc.Strategy == StrategySemVer { + // TODO: Shall we really ensure a valid semver on the current tag? + // This prevents updating from a non-semver tag currently. + if img.ImageTag != nil && img.ImageTag.TagName != "" { + _, err := semver.NewVersion(img.ImageTag.TagName) + if err != nil { + return nil, err + } + } + + if vc.Constraint != "" { + if vc.Strategy == StrategySemVer { + semverConstraint, err = semver.NewConstraint(vc.Constraint) + if err != nil { + logCtx.Errorf("invalid constraint '%s' given: '%v'", vc, err) + return nil, err + } + } + } + } + + // Loop through all tags to check whether it's an update candidate. + for _, tag := range availableTags { + logCtx.Tracef("Finding out whether to consider %s for being updateable", tag.TagName) + + if vc.Strategy == StrategySemVer { + // Non-parseable tag does not mean error - just skip it + ver, err := semver.NewVersion(tag.TagName) + if err != nil { + logCtx.Tracef("Not a valid version: %s", tag.TagName) + continue + } + + // If we have a version constraint, check image tag against it. If the + // constraint is not satisfied, skip tag. + if semverConstraint != nil { + if !semverConstraint.Check(ver) { + logCtx.Tracef("%s did not match constraint %s", ver.Original(), vc.Constraint) + continue + } + } + } else if vc.Strategy == StrategyDigest { + if tag.TagName != vc.Constraint { + logCtx.Tracef("%s did not match contraint %s", tag.TagName, vc.Constraint) + continue + } + } + + // Append tag as update candidate + considerTags = append(considerTags, tag) + } + + logCtx.Debugf("found %d from %d tags eligible for consideration", len(considerTags), len(availableTags)) + + // If we found tags to consider, return the most recent tag found according + // to the update strategy. + if len(considerTags) > 0 { + return considerTags[len(considerTags)-1], nil + } + + return nil, nil +} + +// IsTagIgnored matches tag against the patterns in IgnoreList and returns true if one of them matches +func (vc *VersionConstraint) IsTagIgnored(tag string) bool { + for _, t := range vc.IgnoreList { + if match, err := filepath.Match(t, tag); err == nil && match { + log.Tracef("tag %s is ignored by pattern %s", tag, t) + return true + } + } + return false +} + +// IsCacheable returns true if we can safely cache tags for strategy s +func (s UpdateStrategy) IsCacheable() bool { + switch s { + case StrategyDigest: + return false + default: + return true + } +} + +// NeedsMetadata returns true if strategy s requires image metadata to work correctly +func (s UpdateStrategy) NeedsMetadata() bool { + switch s { + case StrategyNewestBuild: + return true + default: + return false + } +} + +// NeedsVersionConstraint returns true if strategy s requires a version constraint to be defined +func (s UpdateStrategy) NeedsVersionConstraint() bool { + switch s { + case StrategyDigest: + return true + default: + return false + } +} + +// WantsOnlyConstraintTag returns true if strategy s only wants to inspect the tag specified by the constraint +func (s UpdateStrategy) WantsOnlyConstraintTag() bool { + switch s { + case StrategyDigest: + return true + default: + return false + } +} diff --git a/registry-scanner/pkg/image/version_test.go b/registry-scanner/pkg/image/version_test.go new file mode 100644 index 0000000..4c1fe2c --- /dev/null +++ b/registry-scanner/pkg/image/version_test.go @@ -0,0 +1,196 @@ +package image + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/options" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/tag" +) + +func newImageTagList(tagNames []string) *tag.ImageTagList { + tagList := tag.NewImageTagList() + for _, tagName := range tagNames { + tagList.Add(tag.NewImageTag(tagName, time.Unix(0, 0), "")) + } + return tagList +} + +func newImageTagListWithDate(tagNames []string) *tag.ImageTagList { + tagList := tag.NewImageTagList() + for i, t := range tagNames { + tagList.Add(tag.NewImageTag(t, time.Unix(int64(i*5), 0), "")) + } + return tagList +} + +func Test_LatestVersion(t *testing.T) { + t.Run("Find the latest version without any constraint", func(t *testing.T) { + tagList := newImageTagList([]string{"0.1", "0.5.1", "0.9", "1.0", "1.0.1", "1.1.2", "2.0.3"}) + img := NewFromIdentifier("jannfis/test:1.0") + vc := VersionConstraint{} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + require.NoError(t, err) + require.NotNil(t, newTag) + assert.Equal(t, "2.0.3", newTag.TagName) + }) + + t.Run("Find the latest version with a semver constraint on major", func(t *testing.T) { + tagList := newImageTagList([]string{"0.1", "0.5.1", "0.9", "1.0", "1.0.1", "1.1.2", "2.0.3"}) + img := NewFromIdentifier("jannfis/test:1.0") + vc := VersionConstraint{Constraint: "^1.0"} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + require.NoError(t, err) + require.NotNil(t, newTag) + assert.Equal(t, "1.1.2", newTag.TagName) + }) + + t.Run("Find the latest version with a semver constraint on patch", func(t *testing.T) { + tagList := newImageTagList([]string{"0.1", "0.5.1", "0.9", "1.0", "1.0.1", "1.1.2", "2.0.3"}) + img := NewFromIdentifier("jannfis/test:1.0") + vc := VersionConstraint{Constraint: "~1.0"} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + require.NoError(t, err) + require.NotNil(t, newTag) + assert.Equal(t, "1.0.1", newTag.TagName) + }) + + t.Run("Find the latest version with a semver constraint that has no match", func(t *testing.T) { + tagList := newImageTagList([]string{"0.1", "0.5.1", "0.9", "2.0.3"}) + img := NewFromIdentifier("jannfis/test:1.0") + vc := VersionConstraint{Constraint: "~1.0"} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + require.NoError(t, err) + require.Nil(t, newTag) + }) + + t.Run("Find the latest version with a semver constraint that is invalid", func(t *testing.T) { + tagList := newImageTagList([]string{"0.1", "0.5.1", "0.9", "2.0.3"}) + img := NewFromIdentifier("jannfis/test:1.0") + vc := VersionConstraint{Constraint: "latest"} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + assert.Error(t, err) + assert.Nil(t, newTag) + }) + + t.Run("Find the latest version with no tags", func(t *testing.T) { + tagList := newImageTagList([]string{}) + img := NewFromIdentifier("jannfis/test:1.0") + vc := VersionConstraint{Constraint: "~1.0"} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + require.NoError(t, err) + require.NotNil(t, newTag) + assert.Equal(t, "1.0", newTag.TagName) + }) + + t.Run("Find the latest version using latest sortmode", func(t *testing.T) { + tagList := newImageTagListWithDate([]string{"zz", "bb", "yy", "cc", "yy", "aa", "ll"}) + img := NewFromIdentifier("jannfis/test:bb") + vc := VersionConstraint{Strategy: StrategyNewestBuild} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + require.NoError(t, err) + require.NotNil(t, newTag) + assert.Equal(t, "ll", newTag.TagName) + }) + + t.Run("Find the latest version using latest sortmode, invalid tags", func(t *testing.T) { + tagList := newImageTagListWithDate([]string{"zz", "bb", "yy", "cc", "yy", "aa", "ll"}) + img := NewFromIdentifier("jannfis/test:bb") + vc := VersionConstraint{Strategy: StrategySemVer} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + require.NoError(t, err) + require.NotNil(t, newTag) + assert.Equal(t, "bb", newTag.TagName) + }) + + t.Run("Find the latest version using VersionConstraint StrategyAlphabetical", func(t *testing.T) { + tagList := newImageTagListWithDate([]string{"zz", "bb", "yy", "cc", "yy", "aa", "ll"}) + img := NewFromIdentifier("jannfis/test:bb") + vc := VersionConstraint{Strategy: StrategyAlphabetical} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + require.NoError(t, err) + require.NotNil(t, newTag) + assert.Equal(t, "zz", newTag.TagName) + }) + + t.Run("Find the latest version using VersionConstraint StrategyDigest", func(t *testing.T) { + tagList := tag.NewImageTagList() + newDigest := "latest@sha:abcdefg" + tagList.Add(tag.NewImageTag("latest", time.Unix(int64(6), 0), newDigest)) + img := NewFromIdentifier("jannfis/test:latest@sha:1234567") + vc := VersionConstraint{Strategy: StrategyDigest, Constraint: "latest"} + newTag, err := img.GetNewestVersionFromTags(&vc, tagList) + require.NoError(t, err) + assert.Equal(t, "latest", newTag.TagName) + assert.Equal(t, newDigest, newTag.TagDigest) + }) + +} + +func Test_UpdateStrategy_String(t *testing.T) { + tests := []struct { + name string + us UpdateStrategy + want string + }{ + {"StrategySemVer", StrategySemVer, "semver"}, + {"StrategyNewestBuild", StrategyNewestBuild, "newest-build"}, + {"StrategyAlphabetical", StrategyAlphabetical, "alphabetical"}, + {"StrategyDigest", StrategyDigest, "digest"}, + {"unknown", UpdateStrategy(-1), "unknown"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.us.String()) + }) + } +} + +func Test_NewVersionConstraint(t *testing.T) { + constraint := NewVersionConstraint() + assert.Equal(t, StrategySemVer, constraint.Strategy) + assert.Equal(t, options.NewManifestOptions(), constraint.Options) + assert.False(t, constraint.MatchFunc("", "")) +} + +func Test_VersionConstraint_IsTagIgnored(t *testing.T) { + versionConstraint := VersionConstraint{IgnoreList: []string{"tag1", "tag2"}} + assert.True(t, versionConstraint.IsTagIgnored("tag1")) + assert.True(t, versionConstraint.IsTagIgnored("tag2")) + assert.False(t, versionConstraint.IsTagIgnored("tag3")) + versionConstraint.IgnoreList = []string{"tag?", "foo"} + assert.True(t, versionConstraint.IsTagIgnored("tag1")) + assert.True(t, versionConstraint.IsTagIgnored("foo")) + assert.False(t, versionConstraint.IsTagIgnored("tag10")) +} + +func Test_UpdateStrategy_IsCacheable(t *testing.T) { + assert.True(t, StrategySemVer.IsCacheable()) + assert.True(t, StrategyNewestBuild.IsCacheable()) + assert.True(t, StrategyAlphabetical.IsCacheable()) + assert.False(t, StrategyDigest.IsCacheable()) +} + +func Test_UpdateStrategy_NeedsMetadata(t *testing.T) { + assert.False(t, StrategySemVer.NeedsMetadata()) + assert.True(t, StrategyNewestBuild.NeedsMetadata()) + assert.False(t, StrategyAlphabetical.NeedsMetadata()) + assert.False(t, StrategyDigest.NeedsMetadata()) +} + +func Test_UpdateStrategy_NeedsVersionConstraint(t *testing.T) { + assert.False(t, StrategySemVer.NeedsVersionConstraint()) + assert.False(t, StrategyNewestBuild.NeedsVersionConstraint()) + assert.False(t, StrategyAlphabetical.NeedsVersionConstraint()) + assert.True(t, StrategyDigest.NeedsVersionConstraint()) +} + +func Test_UpdateStrategy_WantsOnlyConstraintTag(t *testing.T) { + assert.False(t, StrategySemVer.WantsOnlyConstraintTag()) + assert.False(t, StrategyNewestBuild.WantsOnlyConstraintTag()) + assert.False(t, StrategyAlphabetical.WantsOnlyConstraintTag()) + assert.True(t, StrategyDigest.WantsOnlyConstraintTag()) +} diff --git a/registry-scanner/pkg/kube/kubernetes.go b/registry-scanner/pkg/kube/kubernetes.go index 6771440..0ac0946 100644 --- a/registry-scanner/pkg/kube/kubernetes.go +++ b/registry-scanner/pkg/kube/kubernetes.go @@ -54,12 +54,7 @@ func NewKubernetesClientFromConfig(ctx context.Context, namespace string, kubeco return nil, err } - applicationsClientset, err := versioned.NewForConfig(config) - if err != nil { - return nil, err - } - - return NewKubernetesClient(ctx, clientset, applicationsClientset, namespace), nil + return NewKubernetesClient(ctx, clientset, namespace), nil } // GetSecretData returns the raw data from named K8s secret in given namespace diff --git a/registry-scanner/pkg/registry/registry_test.go b/registry-scanner/pkg/registry/registry_test.go index ff525ce..eccc2b6 100644 --- a/registry-scanner/pkg/registry/registry_test.go +++ b/registry-scanner/pkg/registry/registry_test.go @@ -5,109 +5,115 @@ import ( "testing" "time" - //nolint:staticcheck + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/image" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/options" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/registry/mocks" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/tag" + + "github.com/distribution/distribution/v3/manifest/schema1" //nolint:staticcheck "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) // Test relies on image package which is not available yet. Will uncomment as soon as it is available. -// func Test_GetTags(t *testing.T) { - -// t.Run("Check for correctly returned tags with semver sort", func(t *testing.T) { -// regClient := mocks.RegistryClient{} -// regClient.On("NewRepository", mock.Anything).Return(nil) -// regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) - -// ep, err := GetRegistryEndpoint("") -// require.NoError(t, err) - -// img := image.NewFromIdentifier("foo/bar:1.2.0") - -// tl, err := ep.GetTags(img, ®Client, &image.VersionConstraint{Strategy: image.StrategySemVer, Options: options.NewManifestOptions()}) -// require.NoError(t, err) -// assert.NotEmpty(t, tl) - -// tag, err := ep.Cache.GetTag("foo/bar", "1.2.1") -// require.NoError(t, err) -// assert.Nil(t, tag) -// }) - -// t.Run("Check for correctly returned tags with filter function applied", func(t *testing.T) { -// regClient := mocks.RegistryClient{} -// regClient.On("NewRepository", mock.Anything).Return(nil) -// regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) - -// ep, err := GetRegistryEndpoint("") -// require.NoError(t, err) - -// img := image.NewFromIdentifier("foo/bar:1.2.0") - -// tl, err := ep.GetTags(img, ®Client, &image.VersionConstraint{ -// Strategy: image.StrategySemVer, -// MatchFunc: image.MatchFuncNone, -// Options: options.NewManifestOptions()}) -// require.NoError(t, err) -// assert.Empty(t, tl.Tags()) - -// tag, err := ep.Cache.GetTag("foo/bar", "1.2.1") -// require.NoError(t, err) -// assert.Nil(t, tag) -// }) - -// t.Run("Check for correctly returned tags with name sort", func(t *testing.T) { - -// regClient := mocks.RegistryClient{} -// regClient.On("NewRepository", mock.Anything).Return(nil) -// regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) - -// ep, err := GetRegistryEndpoint("") -// require.NoError(t, err) - -// img := image.NewFromIdentifier("foo/bar:1.2.0") - -// tl, err := ep.GetTags(img, ®Client, &image.VersionConstraint{Strategy: image.StrategyAlphabetical, Options: options.NewManifestOptions()}) -// require.NoError(t, err) -// assert.NotEmpty(t, tl) - -// tag, err := ep.Cache.GetTag("foo/bar", "1.2.1") -// require.NoError(t, err) -// assert.Nil(t, tag) -// }) - -// t.Run("Check for correctly returned tags with latest sort", func(t *testing.T) { -// ts := "2006-01-02T15:04:05.999999999Z" -// meta1 := &schema1.SignedManifest{ //nolint:staticcheck -// Manifest: schema1.Manifest{ //nolint:staticcheck -// History: []schema1.History{ //nolint:staticcheck -// { -// V1Compatibility: `{"created":"` + ts + `"}`, -// }, -// }, -// }, -// } - -// regClient := mocks.RegistryClient{} -// regClient.On("NewRepository", mock.Anything).Return(nil) -// regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) -// regClient.On("ManifestForTag", mock.Anything, mock.Anything).Return(meta1, nil) -// regClient.On("TagMetadata", mock.Anything, mock.Anything).Return(&tag.TagInfo{}, nil) - -// ep, err := GetRegistryEndpoint("") -// require.NoError(t, err) -// ep.Cache.ClearCache() - -// img := image.NewFromIdentifier("foo/bar:1.2.0") -// tl, err := ep.GetTags(img, ®Client, &image.VersionConstraint{Strategy: image.StrategyNewestBuild, Options: options.NewManifestOptions()}) -// require.NoError(t, err) -// assert.NotEmpty(t, tl) - -// tag, err := ep.Cache.GetTag("foo/bar", "1.2.1") -// require.NoError(t, err) -// require.NotNil(t, tag) -// require.Equal(t, "1.2.1", tag.TagName) -// }) - -// } +func Test_GetTags(t *testing.T) { + + t.Run("Check for correctly returned tags with semver sort", func(t *testing.T) { + regClient := mocks.RegistryClient{} + regClient.On("NewRepository", mock.Anything).Return(nil) + regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) + + ep, err := GetRegistryEndpoint("") + require.NoError(t, err) + + img := image.NewFromIdentifier("foo/bar:1.2.0") + + tl, err := ep.GetTags(img, ®Client, &image.VersionConstraint{Strategy: image.StrategySemVer, Options: options.NewManifestOptions()}) + require.NoError(t, err) + assert.NotEmpty(t, tl) + + tag, err := ep.Cache.GetTag("foo/bar", "1.2.1") + require.NoError(t, err) + assert.Nil(t, tag) + }) + + t.Run("Check for correctly returned tags with filter function applied", func(t *testing.T) { + regClient := mocks.RegistryClient{} + regClient.On("NewRepository", mock.Anything).Return(nil) + regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) + + ep, err := GetRegistryEndpoint("") + require.NoError(t, err) + + img := image.NewFromIdentifier("foo/bar:1.2.0") + + tl, err := ep.GetTags(img, ®Client, &image.VersionConstraint{ + Strategy: image.StrategySemVer, + MatchFunc: image.MatchFuncNone, + Options: options.NewManifestOptions()}) + require.NoError(t, err) + assert.Empty(t, tl.Tags()) + + tag, err := ep.Cache.GetTag("foo/bar", "1.2.1") + require.NoError(t, err) + assert.Nil(t, tag) + }) + + t.Run("Check for correctly returned tags with name sort", func(t *testing.T) { + + regClient := mocks.RegistryClient{} + regClient.On("NewRepository", mock.Anything).Return(nil) + regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) + + ep, err := GetRegistryEndpoint("") + require.NoError(t, err) + + img := image.NewFromIdentifier("foo/bar:1.2.0") + + tl, err := ep.GetTags(img, ®Client, &image.VersionConstraint{Strategy: image.StrategyAlphabetical, Options: options.NewManifestOptions()}) + require.NoError(t, err) + assert.NotEmpty(t, tl) + + tag, err := ep.Cache.GetTag("foo/bar", "1.2.1") + require.NoError(t, err) + assert.Nil(t, tag) + }) + + t.Run("Check for correctly returned tags with latest sort", func(t *testing.T) { + ts := "2006-01-02T15:04:05.999999999Z" + meta1 := &schema1.SignedManifest{ //nolint:staticcheck + Manifest: schema1.Manifest{ //nolint:staticcheck + History: []schema1.History{ //nolint:staticcheck + { + V1Compatibility: `{"created":"` + ts + `"}`, + }, + }, + }, + } + + regClient := mocks.RegistryClient{} + regClient.On("NewRepository", mock.Anything).Return(nil) + regClient.On("Tags", mock.Anything).Return([]string{"1.2.0", "1.2.1", "1.2.2"}, nil) + regClient.On("ManifestForTag", mock.Anything, mock.Anything).Return(meta1, nil) + regClient.On("TagMetadata", mock.Anything, mock.Anything).Return(&tag.TagInfo{}, nil) + + ep, err := GetRegistryEndpoint("") + require.NoError(t, err) + ep.Cache.ClearCache() + + img := image.NewFromIdentifier("foo/bar:1.2.0") + tl, err := ep.GetTags(img, ®Client, &image.VersionConstraint{Strategy: image.StrategyNewestBuild, Options: options.NewManifestOptions()}) + require.NoError(t, err) + assert.NotEmpty(t, tl) + + tag, err := ep.Cache.GetTag("foo/bar", "1.2.1") + require.NoError(t, err) + require.NotNil(t, tag) + require.Equal(t, "1.2.1", tag.TagName) + }) + +} func Test_ExpireCredentials(t *testing.T) { epYAML := ` |
