summaryrefslogtreecommitdiff
path: root/registry-scanner/pkg
diff options
context:
space:
mode:
authorIshita Sequeira <46771830+ishitasequeira@users.noreply.github.com>2024-12-04 21:34:58 +0530
committerGitHub <noreply@github.com>2024-12-04 11:04:58 -0500
commit8076d2005ea625c73604073fca43df38eb675751 (patch)
tree1570ba5969882a26e021875da86bee6850a9cfc6 /registry-scanner/pkg
parentc3f0eff54daf871fa1c274462b17f5149c11d368 (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.go2
-rw-r--r--registry-scanner/pkg/common/constants.go65
-rw-r--r--registry-scanner/pkg/env/env.go2
-rw-r--r--registry-scanner/pkg/image/credentials.go261
-rw-r--r--registry-scanner/pkg/image/credentials_test.go410
-rw-r--r--registry-scanner/pkg/image/image.go275
-rw-r--r--registry-scanner/pkg/image/image_test.go226
-rw-r--r--registry-scanner/pkg/image/kustomize.go39
-rw-r--r--registry-scanner/pkg/image/kustomize_test.go26
-rw-r--r--registry-scanner/pkg/image/matchfunc.go27
-rw-r--r--registry-scanner/pkg/image/matchfunc_test.go27
-rw-r--r--registry-scanner/pkg/image/options.go296
-rw-r--r--registry-scanner/pkg/image/options_test.go493
-rw-r--r--registry-scanner/pkg/image/version.go220
-rw-r--r--registry-scanner/pkg/image/version_test.go196
-rw-r--r--registry-scanner/pkg/kube/kubernetes.go7
-rw-r--r--registry-scanner/pkg/registry/registry_test.go202
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, &regexp.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, &regexp.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, &regexp.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, &regClient, &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, &regClient, &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, &regClient, &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, &regClient, &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, &regClient, &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, &regClient, &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, &regClient, &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, &regClient, &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 := `