diff --git a/cmd/test.go b/cmd/test.go index afc9cfbe..e880a6d8 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -23,6 +23,7 @@ func newTestCommand() *cobra.Command { registriesConfPath string logLevel string allowTags string + semverTransformer string credentials string kubeConfig string disableKubernetes bool @@ -75,6 +76,7 @@ argocd-image-updater test nginx --allow-tags '^1.19.\d+(\-.*)*$' --update-strate } img := image.NewFromIdentifier(args[0]) + logCtx := img.LogContext() vc := &image.VersionConstraint{ Constraint: semverConstraint, @@ -84,12 +86,21 @@ argocd-image-updater test nginx --allow-tags '^1.19.\d+(\-.*)*$' --update-strate vc.Strategy = img.ParseUpdateStrategy(strategy) if allowTags != "" { - vc.MatchFunc, vc.MatchArgs = img.ParseMatchfunc(allowTags) + vc.MatchFunc, err = img.ParseMatchfunc(allowTags) + if err != nil { + logCtx.Fatalf("failed to parse match func: %v", err) + } + } + + if semverTransformer != "" { + vc.SemVerTransformFunc, err = img.ParseSemVerTransformFunc(semverTransformer) + if err != nil { + logCtx.Fatalf("failed to parse semver transformer: %v", err) + } } vc.IgnoreList = ignoreTags - logCtx := img.LogContext() logCtx.Infof("retrieving information about image") vc.Options = options.NewManifestOptions() @@ -176,6 +187,7 @@ argocd-image-updater test nginx --allow-tags '^1.19.\d+(\-.*)*$' --update-strate runCmd.Flags().StringVar(&semverConstraint, "semver-constraint", "", "only consider tags matching semantic version constraint") runCmd.Flags().StringVar(&allowTags, "allow-tags", "", "only consider tags in registry that satisfy the match function") runCmd.Flags().StringArrayVar(&ignoreTags, "ignore-tags", nil, "ignore tags in registry that match given glob pattern") + runCmd.Flags().StringVar(&semverTransformer, "semver-transformer", "", "transform tags before parsing semvers") runCmd.Flags().StringVar(&strategy, "update-strategy", "semver", "update strategy to use, one of: semver, latest)") runCmd.Flags().StringVar(®istriesConfPath, "registries-conf-path", "", "path to registries configuration") runCmd.Flags().StringVar(&logLevel, "loglevel", "debug", "log level to use (one of trace, debug, info, warn, error)") diff --git a/pkg/argocd/update.go b/pkg/argocd/update.go index c92730dc..72569809 100644 --- a/pkg/argocd/update.go +++ b/pkg/argocd/update.go @@ -196,7 +196,16 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat } vc.Strategy = applicationImage.GetParameterUpdateStrategy(updateConf.UpdateApp.Application.Annotations) - vc.MatchFunc, vc.MatchArgs = applicationImage.GetParameterMatch(updateConf.UpdateApp.Application.Annotations) + if vc.MatchFunc, err = applicationImage.GetParameterMatch(updateConf.UpdateApp.Application.Annotations); err != nil { + imgCtx.Errorf("Could not parse match parameter: %v", err) + result.NumErrors += 1 + continue + } + if vc.SemVerTransformFunc, err = applicationImage.GetParameterSemVerTransformer(updateConf.UpdateApp.Application.Annotations); err != nil { + imgCtx.Errorf("Could not parse transform parameter: %v", err) + result.NumErrors += 1 + continue + } vc.IgnoreList = applicationImage.GetParameterIgnoreTags(updateConf.UpdateApp.Application.Annotations) vc.Options = applicationImage. GetPlatformOptions(updateConf.UpdateApp.Application.Annotations, updateConf.IgnorePlatforms). diff --git a/pkg/common/constants.go b/pkg/common/constants.go index 527b2009..3227b0e2 100644 --- a/pkg/common/constants.go +++ b/pkg/common/constants.go @@ -35,6 +35,7 @@ const ( UpdateStrategyAnnotation = ImageUpdaterAnnotationPrefix + "/%s.update-strategy" PullSecretAnnotation = ImageUpdaterAnnotationPrefix + "/%s.pull-secret" PlatformsAnnotation = ImageUpdaterAnnotationPrefix + "/%s.platforms" + SemverTransformAnnotation = ImageUpdaterAnnotationPrefix + "/%s.semver-transformer" ) // Application-wide update strategy related annotations @@ -44,6 +45,7 @@ const ( ApplicationWideForceUpdateOptionAnnotation = ImageUpdaterAnnotationPrefix + "/force-update" ApplicationWideUpdateStrategyAnnotation = ImageUpdaterAnnotationPrefix + "/update-strategy" ApplicationWidePullSecretAnnotation = ImageUpdaterAnnotationPrefix + "/pull-secret" + ApplicationWideSemverTransformAnnotation = ImageUpdaterAnnotationPrefix + "/semver-transformer" ) // Application update configuration related annotations diff --git a/pkg/image/image.go b/pkg/image/image.go index f53207f4..948abcb8 100644 --- a/pkg/image/image.go +++ b/pkg/image/image.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "github.com/Masterminds/semver" "github.com/argoproj-labs/argocd-image-updater/pkg/log" "github.com/argoproj-labs/argocd-image-updater/pkg/tag" @@ -54,6 +55,11 @@ func NewFromIdentifier(identifier string) *ContainerImage { TagName: tagged.Tag(), } } + + if img.ImageTag != nil && img.ImageTag.TagName != "" { + img.ImageTag.TagVersion, _ = semver.NewVersion(img.ImageTag.TagName) + } + img.original = identifier return &img } diff --git a/pkg/image/matchfunc.go b/pkg/image/matchfunc.go index bb5f183a..7dd10151 100644 --- a/pkg/image/matchfunc.go +++ b/pkg/image/matchfunc.go @@ -2,26 +2,21 @@ package image import ( "regexp" - - "github.com/argoproj-labs/argocd-image-updater/pkg/log" ) // MatchFuncAny matches any pattern, i.e. always returns true -func MatchFuncAny(tagName string, args interface{}) bool { +func MatchFuncAny(tagName string) bool { return true } // MatchFuncNone matches no pattern, i.e. always returns false -func MatchFuncNone(tagName string, args interface{}) bool { +func MatchFuncNone(tagName string) 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 +func MatchFuncRegexpFactory(pattern *regexp.Regexp) MatchFuncFn { + return func(tagName string) bool { + return pattern.Match([]byte(tagName)) } - return pattern.Match([]byte(tagName)) } diff --git a/pkg/image/matchfunc_test.go b/pkg/image/matchfunc_test.go index 11929b1d..4a833705 100644 --- a/pkg/image/matchfunc_test.go +++ b/pkg/image/matchfunc_test.go @@ -8,20 +8,17 @@ import ( ) func Test_MatchFuncAny(t *testing.T) { - assert.True(t, MatchFuncAny("whatever", nil)) + assert.True(t, MatchFuncAny("whatever")) } func Test_MatchFuncNone(t *testing.T) { - assert.False(t, MatchFuncNone("whatever", nil)) + assert.False(t, MatchFuncNone("whatever")) } 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]+")) + assert.True(t, MatchFuncRegexpFactory(re)("lemon")) + assert.False(t, MatchFuncRegexpFactory(re)("31337")) }) } diff --git a/pkg/image/options.go b/pkg/image/options.go index df357c38..169523b3 100644 --- a/pkg/image/options.go +++ b/pkg/image/options.go @@ -8,6 +8,7 @@ import ( "github.com/argoproj-labs/argocd-image-updater/pkg/common" "github.com/argoproj-labs/argocd-image-updater/pkg/options" + "sigs.k8s.io/kustomize/kyaml/errors" ) // GetParameterHelmImageName gets the value for image-name option for the image @@ -112,10 +113,33 @@ func (img *ContainerImage) ParseUpdateStrategy(val string) UpdateStrategy { } } +// GetParameterSemVerTransformer returns the transform function and pattern to use for transforming +// tagnames before matching or sorting. +func (img *ContainerImage) GetParameterSemVerTransformer(annotations map[string]string) (SemVerTransformFuncFn, error) { + allowTagsAnnotations := []string{ + fmt.Sprintf(common.SemverTransformAnnotation, img.normalizedSymbolicName()), + common.ApplicationWideSemverTransformAnnotation, + } + var allowTagsVal = "" + for _, key := range allowTagsAnnotations { + if val, ok := annotations[key]; ok { + allowTagsVal = val + break + } + } + logCtx := img.LogContext() + if allowTagsVal == "" { + logCtx.Tracef("No match annotation found") + return SemVerTransformFuncNone, nil + } + return img.ParseSemVerTransformFunc(allowTagsVal) + +} + // 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{}) { +func (img *ContainerImage) GetParameterMatch(annotations map[string]string) (MatchFuncFn, error) { allowTagsAnnotations := []string{ fmt.Sprintf(common.AllowTagsOptionAnnotation, img.normalizedSymbolicName()), common.ApplicationWideAllowTagsOptionAnnotation, @@ -140,15 +164,13 @@ func (img *ContainerImage) GetParameterMatch(annotations map[string]string) (Mat } if allowTagsVal == "" { logCtx.Tracef("No match annotation found") - return MatchFuncAny, "" + return MatchFuncAny, nil } 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() - +func (img *ContainerImage) ParseMatchfunc(val string) (MatchFuncFn, error) { // The special value "any" doesn't take any parameter if strings.ToLower(val) == "any" { return MatchFuncAny, nil @@ -156,20 +178,34 @@ func (img *ContainerImage) ParseMatchfunc(val string) (MatchFuncFn, interface{}) opt := strings.SplitN(val, ":", 2) if len(opt) != 2 { - logCtx.Warnf("Invalid match option syntax '%s', ignoring", val) - return MatchFuncNone, nil + return nil, fmt.Errorf("invalid match option syntax %q", val) + } + switch strings.ToLower(opt[0]) { + case "regexp": + re, err := regexp.Compile(opt[1]) + if err != nil { + return nil, fmt.Errorf("could not compile regexp %q: %w", opt[1], err) + } + return MatchFuncRegexpFactory(re), nil + default: + return nil, fmt.Errorf("unknown match function: %q", opt[0]) + } +} + +func (img *ContainerImage) ParseSemVerTransformFunc(val string) (SemVerTransformFuncFn, error) { + opt := strings.SplitN(val, ":", 2) + if len(opt) != 2 { + return nil, fmt.Errorf("invalid match option syntax %q", val) } 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 nil, errors.Errorf("could not compile regexp %q: %w", opt[1], err) } - return MatchFuncRegexp, re + return SemVerTransformerFuncRegexpFactory(re), nil default: - logCtx.Warnf("Unknown match function: %s", opt[0]) - return MatchFuncNone, nil + return nil, fmt.Errorf("unknown match function: %q", opt[0]) } } diff --git a/pkg/image/options_test.go b/pkg/image/options_test.go index a563eec3..e9dbaad0 100644 --- a/pkg/image/options_test.go +++ b/pkg/image/options_test.go @@ -2,7 +2,6 @@ package image import ( "fmt" - "regexp" "runtime" "testing" @@ -145,10 +144,9 @@ func Test_GetMatchOption(t *testing.T) { fmt.Sprintf(common.AllowTagsOptionAnnotation, "dummy"): "regexp:a-z", } img := NewFromIdentifier("dummy=foo/bar:1.12") - matchFunc, matchArgs := img.GetParameterMatch(annotations) + matchFunc, err := img.GetParameterMatch(annotations) require.NotNil(t, matchFunc) - require.NotNil(t, matchArgs) - assert.IsType(t, ®exp.Regexp{}, matchArgs) + require.NoError(t, err) }) t.Run("Get regexp match option for configured application with invalid expression", func(t *testing.T) { @@ -156,9 +154,9 @@ func Test_GetMatchOption(t *testing.T) { 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) + matchFunc, err := img.GetParameterMatch(annotations) + require.Nil(t, matchFunc) + require.Error(t, err) }) t.Run("Get invalid match option for configured application", func(t *testing.T) { @@ -166,10 +164,9 @@ func Test_GetMatchOption(t *testing.T) { 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) + matchFunc, err := img.GetParameterMatch(annotations) + require.Nil(t, matchFunc) + require.Error(t, err) }) t.Run("Prefer match option from image-specific annotation", func(t *testing.T) { @@ -178,12 +175,11 @@ func Test_GetMatchOption(t *testing.T) { common.ApplicationWideAllowTagsOptionAnnotation: "regexp:^v", } img := NewFromIdentifier("dummy=foo/bar:1.12") - matchFunc, matchArgs := img.GetParameterMatch(annotations) + matchFunc, err := img.GetParameterMatch(annotations) + require.NoError(t, err) require.NotNil(t, matchFunc) - require.NotNil(t, matchArgs) - assert.IsType(t, ®exp.Regexp{}, matchArgs) - assert.True(t, matchFunc("0.0.1", matchArgs)) - assert.False(t, matchFunc("v0.0.1", matchArgs)) + assert.True(t, matchFunc("0.0.1")) + assert.False(t, matchFunc("v0.0.1")) }) t.Run("Get match option from application-wide annotation", func(t *testing.T) { @@ -191,12 +187,81 @@ func Test_GetMatchOption(t *testing.T) { common.ApplicationWideAllowTagsOptionAnnotation: "regexp:^v", } img := NewFromIdentifier("dummy=foo/bar:1.12") - matchFunc, matchArgs := img.GetParameterMatch(annotations) + matchFunc, err := img.GetParameterMatch(annotations) + require.NoError(t, err) require.NotNil(t, matchFunc) - require.NotNil(t, matchArgs) - assert.IsType(t, ®exp.Regexp{}, matchArgs) - assert.False(t, matchFunc("0.0.1", matchArgs)) - assert.True(t, matchFunc("v0.0.1", matchArgs)) + assert.False(t, matchFunc("0.0.1")) + assert.True(t, matchFunc("v0.0.1")) + }) +} + +func Test_GetTransformOption(t *testing.T) { + t.Run("Get regexp transform option for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.SemverTransformAnnotation, "dummy"): "regexp:a-z", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + transformFunc, err := img.GetParameterSemVerTransformer(annotations) + require.NoError(t, err) + require.NotNil(t, transformFunc) + }) + + t.Run("Get regexp transform option for configured application with invalid expression", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.SemverTransformAnnotation, "dummy"): `regexp:/foo\`, + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + transformFunc, err := img.GetParameterSemVerTransformer(annotations) + require.Error(t, err) + require.Nil(t, transformFunc) + }) + + t.Run("Get invalid transform option for configured application", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.SemverTransformAnnotation, "dummy"): "invalid", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + transformFunc, err := img.GetParameterSemVerTransformer(annotations) + require.Error(t, err) + require.Nil(t, transformFunc) + }) + + t.Run("Prefer transform option from image-specific annotation", func(t *testing.T) { + annotations := map[string]string{ + fmt.Sprintf(common.SemverTransformAnnotation, "dummy"): "regexp:^[0-9]", + common.ApplicationWideSemverTransformAnnotation: "regexp:^v", + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + transformFunc, err := img.GetParameterSemVerTransformer(annotations) + require.NoError(t, err) + require.NotNil(t, transformFunc) + + result, err := transformFunc("0.0.1") + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "0", result.Original()) + + result, err = transformFunc("v0.0.1") + require.Error(t, err) + require.Nil(t, result) + }) + + t.Run("Get transform option from application-wide annotation", func(t *testing.T) { + annotations := map[string]string{ + common.ApplicationWideSemverTransformAnnotation: `regexp:^v\d+`, + } + img := NewFromIdentifier("dummy=foo/bar:1.12") + transformFunc, err := img.GetParameterSemVerTransformer(annotations) + require.NoError(t, err) + require.NotNil(t, transformFunc) + + result, err := transformFunc("0.0.1") + assert.Nil(t, result) + assert.Error(t, err) + + result, err = transformFunc("v0.0.1") + assert.NoError(t, err) + assert.Equal(t, "v0", result.Original()) }) } diff --git a/pkg/image/transformfunc.go b/pkg/image/transformfunc.go new file mode 100644 index 00000000..a8435d5b --- /dev/null +++ b/pkg/image/transformfunc.go @@ -0,0 +1,26 @@ +package image + +import ( + "fmt" + "regexp" + + "github.com/Masterminds/semver" +) + +// SemVerTransformFuncNone doesn't perform any transformation, i.e. always returns the tagName +func SemVerTransformFuncNone(tagName string) (*semver.Version, error) { + return semver.NewVersion(tagName) +} + +// SemVerTransformerFuncRegexpFactory builds a transformer that uses a regular expression to +// parse before transforming. +func SemVerTransformerFuncRegexpFactory(pattern *regexp.Regexp) SemVerTransformFuncFn { + return func(tagName string) (*semver.Version, error) { + tagName = pattern.FindString(tagName) + if len(tagName) == 0 { + return nil, fmt.Errorf("failed to match %q", tagName) + } + + return semver.NewVersion(tagName) + } +} diff --git a/pkg/image/transformfunc_test.go b/pkg/image/transformfunc_test.go new file mode 100644 index 00000000..13896562 --- /dev/null +++ b/pkg/image/transformfunc_test.go @@ -0,0 +1,41 @@ +package image + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransformFuncNone(t *testing.T) { + t.Run("always returns the string passed in", func(t *testing.T) { + result, err := SemVerTransformFuncNone("1.2.3") + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "1.2.3", result.String()) + }) + + t.Run("fail on malformed semver", func(t *testing.T) { + result, err := SemVerTransformFuncNone("blah") + require.Error(t, err) + require.Nil(t, result) + }) +} + +func TestTransformFuncRegexpFactory(t *testing.T) { + pattern := regexp.MustCompile(`\d+\.\d+`) + match := SemVerTransformerFuncRegexpFactory(pattern) + + t.Run("returns part of tag that matches", func(t *testing.T) { + result, err := match("version-1.22+abcdef") + require.NoError(t, err) + assert.Equal(t, "1.22", result.Original()) + }) + + t.Run("returns empty string if nothing matches", func(t *testing.T) { + result, err := match("abc123blah") + require.Error(t, err) + require.Nil(t, result) + }) +} diff --git a/pkg/image/version.go b/pkg/image/version.go index 6312d23f..1adcc1e0 100644 --- a/pkg/image/version.go +++ b/pkg/image/version.go @@ -1,6 +1,7 @@ package image import ( + "fmt" "path/filepath" "github.com/argoproj-labs/argocd-image-updater/pkg/log" @@ -10,17 +11,17 @@ import ( "github.com/Masterminds/semver" ) -// VersionSortMode defines the method to sort a list of tags +// UpdateStrategy defines the method to sort a list of tags type UpdateStrategy int const ( - // VersionSortSemVer sorts tags using semver sorting (the default) + // StrategySemVer sorts tags using semver sorting (the default) StrategySemVer UpdateStrategy = 0 - // VersionSortLatest sorts tags after their creation date + // StrategyLatest sorts tags after their creation date StrategyLatest UpdateStrategy = 1 - // VersionSortName sorts tags alphabetically by name + // StrategyName sorts tags alphabetically by name StrategyName UpdateStrategy = 2 - // VersionSortDigest uses latest digest of an image + // StrategyDigest uses latest digest of an image StrategyDigest UpdateStrategy = 3 ) @@ -39,29 +40,19 @@ func (us UpdateStrategy) String() string { 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 + + SemVerTransformFunc SemVerTransformFuncFn } -type MatchFuncFn func(tagName string, pattern interface{}) bool +type MatchFuncFn func(tagName string) bool +type SemVerTransformFuncFn func(tagName string) (*semver.Version, error) // String returns the string representation of VersionConstraint func (vc *VersionConstraint) String() string { @@ -108,11 +99,8 @@ func (img *ContainerImage) GetNewestVersionFromTags(vc *VersionConstraint, tagLi 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 img.ImageTag != nil && img.ImageTag.TagVersion == nil { + return nil, fmt.Errorf("tag %q is not a valid semver", img.ImageTag.TagName) } if vc.Constraint != "" { @@ -131,10 +119,9 @@ func (img *ContainerImage) GetNewestVersionFromTags(vc *VersionConstraint, tagLi 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) + ver := tag.TagVersion + if ver == nil { + // only check tags that have a valid semver continue } diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 11c061be..96a158aa 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/Masterminds/semver" "github.com/distribution/distribution/v3" "golang.org/x/sync/semaphore" @@ -51,8 +52,12 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R return nil, err } - tags := []string{} + type tuple struct { + original string + semver *semver.Version + } + tags := []tuple{} // For digest strategy, we do require a version constraint if vc.Strategy.NeedsVersionConstraint() && vc.Constraint == "" { return nil, fmt.Errorf("cannot use update strategy 'digest' for image '%s' without a version constraint", img.Original()) @@ -60,16 +65,33 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R // Loop through tags, removing those we do not want. If update strategy is // digest, all but the constraint tag are ignored. - if vc.MatchFunc != nil || len(vc.IgnoreList) > 0 || vc.Strategy.WantsOnlyConstraintTag() { - for _, t := range tTags { - if (vc.MatchFunc != nil && !vc.MatchFunc(t, vc.MatchArgs)) || vc.IsTagIgnored(t) || (vc.Strategy.WantsOnlyConstraintTag() && t != vc.Constraint) { - logCtx.Tracef("Removing tag %s because it either didn't match defined pattern or is ignored", t) - } else { - tags = append(tags, t) + for _, t := range tTags { + if vc.MatchFunc != nil && !vc.MatchFunc(t) { + logCtx.Tracef("Removing tag %q because it didn't match defined pattern", t) + continue + } + + if vc.IsTagIgnored(t) { + logCtx.Tracef("Removing tag %q because it is in the ignored list", t) + continue + } + + if vc.Strategy.WantsOnlyConstraintTag() && t != vc.Constraint { + logCtx.Tracef("Removing tag %q because it doesn't match the 'wants only' constraint", t) + continue + } + + tagInfo := tuple{t, nil} + if vc.SemVerTransformFunc != nil { + transformed, err := vc.SemVerTransformFunc(t) + if err != nil { + logCtx.Warnf("tag %q is not a valid semver, skipping: %v", t, err) + continue } + + tagInfo.semver = transformed } - } else { - tags = tTags + tags = append(tags, tagInfo) } // In some cases, we don't need to fetch the metadata to get the creation time @@ -81,14 +103,15 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R // We just create a dummy time stamp according to the registry's sort mode, if // set. if (vc.Strategy != image.StrategyLatest && vc.Strategy != image.StrategyDigest) || endpoint.TagListSort.IsTimeSorted() { - for i, tagStr := range tags { + for i, tagInfo := range tags { var ts int if endpoint.TagListSort == TagListSortLatestFirst { ts = len(tags) - i } else if endpoint.TagListSort == TagListSortLatestLast { ts = i } - imgTag := tag.NewImageTag(tagStr, time.Unix(int64(ts), 0), "") + imgTag := tag.NewImageTag(tagInfo.original, time.Unix(int64(ts), 0), "") + imgTag.TagVersion = tagInfo.semver tagList.Add(imgTag) } return tagList, nil @@ -103,13 +126,13 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R // Fetch the manifest for the tag -- we need v1, because it contains history // information that we require. i := 0 - for _, tagStr := range tags { + for _, tagInfo := range tags { i += 1 // Look into the cache first and re-use any found item. If GetTag() returns // an error, we treat it as a cache miss and just go ahead to invalidate // the entry. if vc.Strategy.IsCacheable() { - imgTag, err := endpoint.Cache.GetTag(nameInRegistry, tagStr) + imgTag, err := endpoint.Cache.GetTag(nameInRegistry, tagInfo.original) if err != nil { log.Warnf("invalid entry for %s:%s in cache, invalidating.", nameInRegistry, imgTag.TagName) } else if imgTag != nil { @@ -122,7 +145,7 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R } } - logCtx.Tracef("Getting manifest for image %s:%s (operation %d/%d)", nameInRegistry, tagStr, i, len(tags)) + logCtx.Tracef("Getting manifest for image %s:%s (operation %d/%d)", nameInRegistry, tagInfo.original, i, len(tags)) lockErr := sem.Acquire(context.TODO(), 1) if lockErr != nil { @@ -132,7 +155,7 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R } logCtx.Tracef("acquired metadata semaphore") - go func(tagStr string) { + go func(tagInfo tuple) { defer func() { sem.Release(1) wg.Done() @@ -144,8 +167,8 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R // We first try to fetch a V2 manifest, and if that's not available we fall // back to fetching V1 manifest. If that fails also, we just skip this tag. - if ml, err = regClient.ManifestForTag(tagStr); err != nil { - logCtx.Errorf("Error fetching metadata for %s:%s - neither V1 or V2 or OCI manifest returned by registry: %v", nameInRegistry, tagStr, err) + if ml, err = regClient.ManifestForTag(tagInfo.original); err != nil { + logCtx.Errorf("Error fetching metadata for %s:%s - neither V1 or V2 or OCI manifest returned by registry: %v", nameInRegistry, tagInfo.original, err) return } @@ -153,26 +176,30 @@ func (endpoint *RegistryEndpoint) GetTags(img *image.ContainerImage, regClient R // information needed to decide whether to consider this tag or not. ti, err := regClient.TagMetadata(ml, vc.Options) if err != nil { - logCtx.Errorf("error fetching metadata for %s:%s: %v", nameInRegistry, tagStr, err) + logCtx.Errorf("error fetching metadata for %s:%s: %v", nameInRegistry, tagInfo.original, err) return } if ti == nil { - logCtx.Debugf("No metadata found for %s:%s", nameInRegistry, tagStr) + logCtx.Debugf("No metadata found for %s:%s", nameInRegistry, tagInfo.original) return } logCtx.Tracef("Found date %s", ti.CreatedAt.String()) var imgTag *tag.ImageTag if vc.Strategy == image.StrategyDigest { - imgTag = tag.NewImageTag(tagStr, ti.CreatedAt, fmt.Sprintf("sha256:%x", ti.Digest)) + imgTag = tag.NewImageTag(tagInfo.original, ti.CreatedAt, fmt.Sprintf("sha256:%x", ti.Digest)) } else { - imgTag = tag.NewImageTag(tagStr, ti.CreatedAt, "") + imgTag = tag.NewImageTag(tagInfo.original, ti.CreatedAt, "") } + if tagInfo.semver != nil { + imgTag.TagVersion = tagInfo.semver + } + tagListLock.Lock() tagList.Add(imgTag) tagListLock.Unlock() endpoint.Cache.SetTag(nameInRegistry, imgTag) - }(tagStr) + }(tagInfo) } wg.Wait() diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go index 31164f7a..21149984 100644 --- a/pkg/registry/registry_test.go +++ b/pkg/registry/registry_test.go @@ -2,6 +2,7 @@ package registry import ( "os" + "regexp" "testing" "time" @@ -112,6 +113,55 @@ func Test_GetTags(t *testing.T) { require.Equal(t, "1.2.1", tag.TagName) }) + t.Run("Check for correctly returned tags with transform", func(t *testing.T) { + ts := "2006-01-02T15:04:05.999999999Z" + meta1 := &schema1.SignedManifest{ + Manifest: schema1.Manifest{ + History: []schema1.History{ + { + V1Compatibility: `{"created":"` + ts + `"}`, + }, + }, + }, + } + + img := image.NewFromIdentifier("foo/bar:1.24.0.4930-ab6e1a058") + + ep, err := GetRegistryEndpoint("") + require.NoError(t, err) + + pattern := regexp.MustCompile(`\d+\.\d+\.\d+`) + + vc := image.VersionConstraint{ + Strategy: image.StrategyLatest, + Options: options.NewManifestOptions(), + SemVerTransformFunc: image.SemVerTransformerFuncRegexpFactory(pattern), + } + + regClient := mocks.RegistryClient{} + regClient.On("NewRepository", mock.Anything).Return(nil) + regClient.On("Tags", mock.Anything).Return([]string{"latest", "1.24.2.4973-2b1b51db9", "1.25.0.5282-2edd3c44d", "1.25.3.5409-f11334058"}, nil) + regClient.On("ManifestForTag", mock.Anything, mock.Anything).Return(meta1, nil) + regClient.On("TagMetadata", mock.Anything, mock.Anything).Return(&tag.TagInfo{}, nil) + + tl, err := ep.GetTags(img, ®Client, &vc) + require.NotEmpty(t, tl) + require.Len(t, tl.Tags(), 3) + sorted := tl.SortBySemVer() + require.Equal(t, sorted.Len(), 3) + assert.Equal(t, "1.24.2", sorted[0].TagVersion.String()) + assert.Equal(t, "1.24.2.4973-2b1b51db9", sorted[0].TagName) + assert.Equal(t, "1.25.0", sorted[1].TagVersion.String()) + assert.Equal(t, "1.25.0.5282-2edd3c44d", sorted[1].TagName) + assert.Equal(t, "1.25.3", sorted[2].TagVersion.String()) + assert.Equal(t, "1.25.3.5409-f11334058", sorted[2].TagName) + + tag, err := ep.Cache.GetTag("foo/bar", "1.25.3.5409-f11334058") + require.NoError(t, err) + require.NotNil(t, tag) + assert.Equal(t, "1.25.3.5409-f11334058", tag.TagName) + }) + } func Test_ExpireCredentials(t *testing.T) { diff --git a/pkg/tag/tag.go b/pkg/tag/tag.go index 2a89aecd..eb57c1b8 100644 --- a/pkg/tag/tag.go +++ b/pkg/tag/tag.go @@ -6,8 +6,6 @@ import ( "sync" "time" - "github.com/argoproj-labs/argocd-image-updater/pkg/log" - "github.com/Masterminds/semver" ) @@ -17,6 +15,8 @@ type ImageTag struct { TagName string TagDate *time.Time TagDigest string + + TagVersion *semver.Version } // ImageTagList is a collection of ImageTag objects. @@ -51,6 +51,7 @@ func NewImageTag(tagName string, tagDate time.Time, tagDigest string) *ImageTag tag.TagName = tagName tag.TagDate = &tagDate tag.TagDigest = tagDigest + tag.TagVersion, _ = semver.NewVersion(tagName) return tag } @@ -157,17 +158,17 @@ func (il ImageTagList) SortBySemVer() SortableImageTagList { sil := SortableImageTagList{} svl := make([]*semver.Version, 0) + tagMap := make(map[string]*ImageTag) for _, v := range il.items { - svi, err := semver.NewVersion(v.TagName) - if err != nil { - log.Debugf("could not parse input tag %s as semver: %v", v.TagName, err) + if v.TagVersion == nil { continue } - svl = append(svl, svi) + tagMap[v.TagVersion.Original()] = v + svl = append(svl, v.TagVersion) } sort.Sort(semver.Collection(svl)) for _, svi := range svl { - sil = append(sil, NewImageTag(svi.Original(), *il.items[svi.Original()].TagDate, il.items[svi.Original()].TagDigest)) + sil = append(sil, tagMap[svi.Original()]) } return sil }