diff --git a/docs/basics/update-strategies.md b/docs/basics/update-strategies.md
index d549c0d8..1856130c 100644
--- a/docs/basics/update-strategies.md
+++ b/docs/basics/update-strategies.md
@@ -18,6 +18,7 @@ The following update strategies are currently supported:
* [latest/newest-build](#strategy-latest) - Update to the most recently built image found in a registry
* [digest](#strategy-digest) - Update to the latest version of a given version (tag), using the tag's SHA digest
* [name/alphabetical](#strategy-name) - Sorts tags alphabetically and update to the one with the highest cardinality
+* [calver](#strategy-calver) - Update to the latest version of an image considering calendar versioning constraints
!!!warning "Renamed image update strategies"
The `latest` strategy has been renamed to `newest-build`, and `name` strategy has been renamed to `alphabetical`.
@@ -292,3 +293,60 @@ argocd-image-updater.argoproj.io/myimage.allow-tags: regexp:^[0-9]{4}-[0-9]{2}-[
would only consider tags that match a given regular expression for update. In
this case, only tags matching a date specification of `YYYY-MM-DD` would be
considered for update.
+
+### calver - Update to calendar versions
+
+Strategy name: `calver`
+
+Basic configuration:
+
+```yaml
+argocd-image-updater.argoproj.io/image-list: some/image[:]
+argocd-image-updater.argoproj.io/.update-strategy: calver
+argocd-image-updater.argoproj.io/.allow-tags: calver:YYYY.0M.MICRO
+```
+
+!!! note "CalVer Format Specification"
+ The `calver` strategy requires defining the version format using the [calver layout syntax](https://github.com/k1LoW/calver). Common patterns include:
+ - `YYYY.0M.MICRO` for year.month.counter (e.g. 2023.08.1)
+ - `YY.MM.MICRO` for 2-digit year.month.counter (e.g. 23.8.5)
+ - `YYYY.MM.DD` for date-based versions (e.g. 2023.08.15)
+
+The `calver` strategy allows you to track & update images which use tags that
+follow the
+[calendar versioning scheme](https://calver.org). Tag names must contain calver
+compatible identifiers in the format `YYYY.MM.DD`, where `YYYY`, `MM`, and `DD` must be
+whole numbers.
+
+This will allow you to update to the latest version of an image within a given
+year, month, or day, or just to the latest version that is tagged with a valid
+calendar version identifier.
+
+To tell Argo CD Image Updater which versions are allowed, simply give a calver
+version as a constraint in the `image-list` annotation. For example, to allow
+updates to the latest version within the `2023.08` month, use
+
+```
+argocd-image-updater.argoproj.io/image-list: some/image:2023.08.x
+```
+
+The above example would update to any new tag pushed to the registry matching
+this constraint, e.g. `2023.08.15`, `2023.08.30` etc, but not to a new month
+(e.g. `2023.09`).
+
+Likewise, to allow updates to any month within the year `2023`,
+use
+
+```yaml
+argocd-image-updater.argoproj.io/image-list: some/image:2023.x
+```
+
+The above example would update to any new tag pushed to the registry matching
+this constraint, e.g. `2023.08.15`, `2023.09.01`, `2023.12.31` etc, but not to a new year
+(e.g. `2024`).
+
+If no version constraint is specified in the list of allowed images, Argo CD
+Image Updater will pick the highest version number found in the registry.
+
+Argo CD Image Updater will omit any tags from your registry that do not match
+a calendar version when using the `calver` update strategy.
diff --git a/registry-scanner/go.mod b/registry-scanner/go.mod
index 862c9326..7cac3325 100644
--- a/registry-scanner/go.mod
+++ b/registry-scanner/go.mod
@@ -6,6 +6,7 @@ require (
github.com/Masterminds/semver/v3 v3.3.1
github.com/argoproj/pkg v0.13.7-0.20230627120311-a4dd357b057e
github.com/distribution/distribution/v3 v3.0.0-20230722181636-7b502560cad4
+ github.com/k1LoW/calver v0.7.3
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.0
github.com/patrickmn/go-cache v2.1.0+incompatible
@@ -55,6 +56,7 @@ require (
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
+ github.com/snabb/isoweek v1.0.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
diff --git a/registry-scanner/go.sum b/registry-scanner/go.sum
index 2c211589..a6cb101c 100644
--- a/registry-scanner/go.sum
+++ b/registry-scanner/go.sum
@@ -156,6 +156,8 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/k1LoW/calver v0.7.3 h1:i05crMZqiIgkswcv0esE7DBi+QBpIr1BP/3PgV5HAmg=
+github.com/k1LoW/calver v0.7.3/go.mod h1:Djp3yuoeRnIxwWjLOwY14lgcha5YnWYggABhNhSxHl4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
@@ -284,6 +286,8 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/snabb/isoweek v1.0.3 h1:BwEULUhj7UToLLa7FivDTLzA4y1epTYkLhnn31huBRs=
+github.com/snabb/isoweek v1.0.3/go.mod h1:J5hJfY1CG56xmKCC/4XfoaWZcOiB+qntmyKEDATSnlw=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
diff --git a/registry-scanner/pkg/image/matchfunc.go b/registry-scanner/pkg/image/matchfunc.go
index 036e6fb0..32eb93b5 100644
--- a/registry-scanner/pkg/image/matchfunc.go
+++ b/registry-scanner/pkg/image/matchfunc.go
@@ -4,6 +4,7 @@ import (
"regexp"
"github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log"
+ "github.com/k1LoW/calver"
)
// MatchFuncAny matches any pattern, i.e. always returns true
@@ -25,3 +26,13 @@ func MatchFuncRegexp(tagName string, args interface{}) bool {
}
return pattern.Match([]byte(tagName))
}
+
+// MatchFuncCalVer checks if a tag matches the specified CalVer layout
+func MatchFuncCalVer(tagName string, args interface{}) bool {
+ layoutStr, ok := args.(string)
+ if !ok {
+ return false
+ }
+ _, err := calver.Parse(layoutStr, tagName)
+ return err == nil
+}
diff --git a/registry-scanner/pkg/image/options.go b/registry-scanner/pkg/image/options.go
index 308569c4..76dcd0f5 100644
--- a/registry-scanner/pkg/image/options.go
+++ b/registry-scanner/pkg/image/options.go
@@ -112,6 +112,8 @@ func (img *ContainerImage) ParseUpdateStrategy(val string) UpdateStrategy {
return StrategyAlphabetical
case "digest":
return StrategyDigest
+ case "calver":
+ return StrategyCalVer
default:
logCtx.Warnf("Unknown sort option %s -- using semver", val)
return StrategySemVer
@@ -173,6 +175,8 @@ func (img *ContainerImage) ParseMatchfunc(val string) (MatchFuncFn, interface{})
return MatchFuncNone, nil
}
return MatchFuncRegexp, re
+ case "calver":
+ return MatchFuncCalVer, opt[1]
default:
logCtx.Warnf("Unknown match function: %s", opt[0])
return MatchFuncNone, nil
diff --git a/registry-scanner/pkg/image/version.go b/registry-scanner/pkg/image/version.go
index 97437bd5..80720003 100644
--- a/registry-scanner/pkg/image/version.go
+++ b/registry-scanner/pkg/image/version.go
@@ -1,6 +1,7 @@
package image
import (
+ "fmt"
"path/filepath"
"github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log"
@@ -22,6 +23,8 @@ const (
StrategyAlphabetical UpdateStrategy = 2
// VersionSortDigest uses latest digest of an image
StrategyDigest UpdateStrategy = 3
+ // VersionSortCalVer sorts tags using calendar versioning
+ StrategyCalVer UpdateStrategy = 4
)
func (us UpdateStrategy) String() string {
@@ -34,6 +37,8 @@ func (us UpdateStrategy) String() string {
return "alphabetical"
case StrategyDigest:
return "digest"
+ case StrategyCalVer:
+ return "calver"
}
return "unknown"
@@ -93,6 +98,13 @@ func (img *ContainerImage) GetNewestVersionFromTags(vc *VersionConstraint, tagLi
availableTags = tagList.SortByDate()
case StrategyDigest:
availableTags = tagList.SortAlphabetically()
+ case StrategyCalVer:
+ layout, ok := vc.MatchArgs.(string)
+ if !ok {
+ logCtx.Errorf("calver layout not specified in allow-tags annotation")
+ return nil, fmt.Errorf("calver layout not specified in allow-tags annotation")
+ }
+ availableTags = tagList.SortByCalVer(layout)
}
considerTags := tag.SortableImageTagList{}
diff --git a/registry-scanner/pkg/image/version_test.go b/registry-scanner/pkg/image/version_test.go
index 4c1fe2cd..25352e9f 100644
--- a/registry-scanner/pkg/image/version_test.go
+++ b/registry-scanner/pkg/image/version_test.go
@@ -76,6 +76,74 @@ func Test_LatestVersion(t *testing.T) {
assert.Nil(t, newTag)
})
+ t.Run("Find the latest version with a calver constraint that is valid", func(t *testing.T) {
+ tagList := newImageTagList([]string{"2021.01.01", "2022.02.02", "2023.05.01", "2025.01.25"})
+ img := NewFromIdentifier("jannfis/test:2021.01.01")
+ vc := VersionConstraint{Constraint: "2022.01.01", Strategy: StrategyCalVer, MatchArgs: "YYYY.MM.DD"}
+ newTag, err := img.GetNewestVersionFromTags(&vc, tagList)
+ assert.NoError(t, err)
+ assert.NotNil(t, newTag)
+ assert.Equal(t, "2025.01.25", newTag.TagName)
+ })
+
+ t.Run("Find latest version with YYYY.MM calver format", func(t *testing.T) {
+ tagList := newImageTagList([]string{"2021.01", "2022.02", "2023.05", "2025.01"})
+ img := NewFromIdentifier("jannfis/test:2021.01")
+ vc := VersionConstraint{Constraint: "2022.01", Strategy: StrategyCalVer, MatchArgs: "YYYY.MM"}
+ newTag, err := img.GetNewestVersionFromTags(&vc, tagList)
+ assert.NoError(t, err)
+ assert.NotNil(t, newTag)
+ assert.Equal(t, "2025.01", newTag.TagName)
+ })
+
+ t.Run("Find latest version with YY.MM.DD calver format", func(t *testing.T) {
+ tagList := newImageTagList([]string{"21.01.01", "22.02.02", "23.05.01", "25.01.25"})
+ img := NewFromIdentifier("jannfis/test:21.01.01")
+ vc := VersionConstraint{Constraint: "22.01.01", Strategy: StrategyCalVer, MatchArgs: "YY.MM.DD"}
+ newTag, err := img.GetNewestVersionFromTags(&vc, tagList)
+ assert.NoError(t, err)
+ assert.NotNil(t, newTag)
+ assert.Equal(t, "25.01.25", newTag.TagName)
+ })
+
+ t.Run("Invalid calver format should return error", func(t *testing.T) {
+ tagList := newImageTagList([]string{"2021.01.01", "2022.02.02"})
+ img := NewFromIdentifier("jannfis/test:2021.01.01")
+ vc := VersionConstraint{Constraint: "2022.01.01", Strategy: StrategyCalVer, MatchArgs: "invalid-format"}
+ newTag, err := img.GetNewestVersionFromTags(&vc, tagList)
+ assert.Error(t, err)
+ assert.Nil(t, newTag)
+ })
+
+ t.Run("Tags not matching calver format should be ignored", func(t *testing.T) {
+ tagList := newImageTagList([]string{"2021.01.01", "invalid", "2023.05.01", "not-a-date"})
+ img := NewFromIdentifier("jannfis/test:2021.01.01")
+ vc := VersionConstraint{Constraint: "2022.01.01", Strategy: StrategyCalVer, MatchArgs: "YYYY.MM.DD"}
+ newTag, err := img.GetNewestVersionFromTags(&vc, tagList)
+ assert.NoError(t, err)
+ assert.NotNil(t, newTag)
+ assert.Equal(t, "2023.05.01", newTag.TagName)
+ })
+
+ t.Run("Empty tag list with calver should return nil", func(t *testing.T) {
+ tagList := newImageTagList([]string{})
+ img := NewFromIdentifier("jannfis/test:2021.01.01")
+ vc := VersionConstraint{Constraint: "2022.01.01", Strategy: StrategyCalVer, MatchArgs: "YYYY.MM.DD"}
+ newTag, err := img.GetNewestVersionFromTags(&vc, tagList)
+ assert.NoError(t, err)
+ assert.Nil(t, newTag)
+ })
+
+ t.Run("Missing constraint with calver should use current date", func(t *testing.T) {
+ tagList := newImageTagList([]string{"2021.01.01", "2022.02.02", "2023.05.01"})
+ img := NewFromIdentifier("jannfis/test:2021.01.01")
+ vc := VersionConstraint{Strategy: StrategyCalVer, MatchArgs: "YYYY.MM.DD"}
+ newTag, err := img.GetNewestVersionFromTags(&vc, tagList)
+ assert.NoError(t, err)
+ assert.NotNil(t, newTag)
+ assert.Equal(t, "2023.05.01", newTag.TagName)
+ })
+
t.Run("Find the latest version with no tags", func(t *testing.T) {
tagList := newImageTagList([]string{})
img := NewFromIdentifier("jannfis/test:1.0")
@@ -140,6 +208,7 @@ func Test_UpdateStrategy_String(t *testing.T) {
{"StrategyNewestBuild", StrategyNewestBuild, "newest-build"},
{"StrategyAlphabetical", StrategyAlphabetical, "alphabetical"},
{"StrategyDigest", StrategyDigest, "digest"},
+ {"StrategyCalVer", StrategyCalVer, "calver"},
{"unknown", UpdateStrategy(-1), "unknown"},
}
for _, tt := range tests {
@@ -171,6 +240,7 @@ func Test_UpdateStrategy_IsCacheable(t *testing.T) {
assert.True(t, StrategySemVer.IsCacheable())
assert.True(t, StrategyNewestBuild.IsCacheable())
assert.True(t, StrategyAlphabetical.IsCacheable())
+ assert.True(t, StrategyCalVer.IsCacheable())
assert.False(t, StrategyDigest.IsCacheable())
}
@@ -178,6 +248,7 @@ 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, StrategyCalVer.NeedsMetadata())
assert.False(t, StrategyDigest.NeedsMetadata())
}
@@ -185,6 +256,7 @@ 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, StrategyCalVer.NeedsVersionConstraint())
assert.True(t, StrategyDigest.NeedsVersionConstraint())
}
@@ -192,5 +264,6 @@ func Test_UpdateStrategy_WantsOnlyConstraintTag(t *testing.T) {
assert.False(t, StrategySemVer.WantsOnlyConstraintTag())
assert.False(t, StrategyNewestBuild.WantsOnlyConstraintTag())
assert.False(t, StrategyAlphabetical.WantsOnlyConstraintTag())
+ assert.False(t, StrategyCalVer.WantsOnlyConstraintTag())
assert.True(t, StrategyDigest.WantsOnlyConstraintTag())
}
diff --git a/registry-scanner/pkg/tag/tag.go b/registry-scanner/pkg/tag/tag.go
index 26cc2157..0c64dd4f 100644
--- a/registry-scanner/pkg/tag/tag.go
+++ b/registry-scanner/pkg/tag/tag.go
@@ -9,6 +9,7 @@ import (
"github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log"
"github.com/Masterminds/semver/v3"
+ "github.com/k1LoW/calver"
)
// ImageTag is a representation of an image tag with metadata
@@ -172,6 +173,28 @@ func (il ImageTagList) SortBySemVer() SortableImageTagList {
return sil
}
+func (il ImageTagList) SortByCalVer(layout string) SortableImageTagList {
+ il.lock.RLock()
+ defer il.lock.RUnlock()
+ sil := make(SortableImageTagList, 0, len(il.items))
+ calvers := make(calver.Calvers, 0, len(il.items))
+
+ for _, v := range il.items {
+ cv, err := calver.Parse(layout, v.TagName)
+ if err != nil {
+ // Fallback to alphabetical order if parsing fails
+ sil = append(sil, v)
+ } else {
+ calvers = append(calvers, cv)
+ }
+ }
+ calvers.Sort()
+ for _, cv := range calvers {
+ sil = append(sil, il.items[cv.String()])
+ }
+ return sil
+}
+
// Should only be used in a method that holds a lock on the ImageTagList
func (il ImageTagList) unlockedContains(tag *ImageTag) bool {
if _, ok := il.items[tag.TagName]; ok {