Skip to content

CalVer as an additional update strategy #1019

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/basics/update-strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.

### <a name="strategy-calver"></a>calver - Update to calendar versions

Strategy name: `calver`

Basic configuration:

```yaml
argocd-image-updater.argoproj.io/image-list: some/image[:<version_constraint>]
argocd-image-updater.argoproj.io/<image>.update-strategy: calver
argocd-image-updater.argoproj.io/<image>.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.
2 changes: 2 additions & 0 deletions registry-scanner/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions registry-scanner/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
11 changes: 11 additions & 0 deletions registry-scanner/pkg/image/matchfunc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
4 changes: 4 additions & 0 deletions registry-scanner/pkg/image/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions registry-scanner/pkg/image/version.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package image

import (
"fmt"
"path/filepath"

"github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log"
Expand All @@ -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 {
Expand All @@ -34,6 +37,8 @@ func (us UpdateStrategy) String() string {
return "alphabetical"
case StrategyDigest:
return "digest"
case StrategyCalVer:
return "calver"
}

return "unknown"
Expand Down Expand Up @@ -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{}
Expand Down
73 changes: 73 additions & 0 deletions registry-scanner/pkg/image/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -171,26 +240,30 @@ 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())
}

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())
}

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())
}

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())
}
23 changes: 23 additions & 0 deletions registry-scanner/pkg/tag/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading