From dbc9b1fb41fc48f6992da3c067ae24fbe6eb7ac4 Mon Sep 17 00:00:00 2001 From: David Collom Date: Fri, 7 Jun 2024 14:59:45 +0100 Subject: [PATCH 1/3] Discard Tags older than that of what we believe to be latest --- pkg/version/version.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/version/version.go b/pkg/version/version.go index a78ef138..9e603440 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -115,6 +115,12 @@ func latestSemver(opts *api.Options, tags []api.ImageTag) (*api.ImageTag, error) for i := range tags { v := semver.Parse(tags[i].Tag) + // If the image in question is "older" than the one we believe to be Latest + // Then we can ignore this image. + if latestV != nil && tags[i].Timestamp.After(latestImageTag.Timestamp) { + continue + } + // If regex enabled continue here. // If we match, and is less than, update latest. if opts.RegexMatcher != nil { From 0bf32417c377a46494c5c36b600e36acd92d0929 Mon Sep 17 00:00:00 2001 From: David Collom Date: Mon, 17 Jun 2024 14:42:24 +0100 Subject: [PATCH 2/3] Attempt to make latestSemver more accurate --- go.mod | 3 + go.sum | 3 + pkg/version/semver/semver.go | 3 +- pkg/version/version.go | 100 ++++++++++-------- pkg/version/version_test.go | 197 +++++++++++++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 43 deletions(-) create mode 100644 pkg/version/version_test.go diff --git a/go.mod b/go.mod index d6abec89..d5b37457 100644 --- a/go.mod +++ b/go.mod @@ -26,11 +26,13 @@ require ( ) require ( + github.com/Masterminds/semver/v3 v3.2.1 github.com/aws/aws-sdk-go-v2/config v1.27.11 github.com/aws/aws-sdk-go-v2/credentials v1.17.11 github.com/aws/aws-sdk-go-v2/service/ecr v1.27.4 github.com/gofri/go-github-ratelimit v1.1.0 github.com/google/go-github/v58 v58.0.0 + github.com/stretchr/testify v1.8.4 ) require ( @@ -84,6 +86,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect diff --git a/go.sum b/go.sum index 359c4960..b4d34161 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= @@ -183,6 +185,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/pkg/version/semver/semver.go b/pkg/version/semver/semver.go index 24ae687e..23a4c131 100644 --- a/pkg/version/semver/semver.go +++ b/pkg/version/semver/semver.go @@ -7,7 +7,8 @@ import ( ) var ( - versionRegex = regexp.MustCompile(`^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(.*)$`) + // versionRegex = regexp.MustCompile(`^v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?(.*)$`) + versionRegex = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)([\w\.\-]+)?$`) ) // SemVer is a struct to contain a SemVer of an image tag. diff --git a/pkg/version/version.go b/pkg/version/version.go index 9e603440..3dbac36c 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "time" "github.com/sirupsen/logrus" @@ -105,61 +106,62 @@ func (v *Version) Fetch(ctx context.Context, imageURL string, _ *api.Options) (i // latestSemver will return the latest ImageTag based on the given options // restriction, using semver. This should not be used is UseSHA has been // enabled. -// TODO: add tests.. +// Function to find the latest SemVer tag based on options func latestSemver(opts *api.Options, tags []api.ImageTag) (*api.ImageTag, error) { - var ( - latestImageTag *api.ImageTag - latestV *semver.SemVer - ) + var filteredTags []api.ImageTag - for i := range tags { - v := semver.Parse(tags[i].Tag) - - // If the image in question is "older" than the one we believe to be Latest - // Then we can ignore this image. - if latestV != nil && tags[i].Timestamp.After(latestImageTag.Timestamp) { - continue + // Filter out non-SemVer tags if required + if !opts.UseMetaData { + for _, tag := range tags { + if isSemVer(tag.Tag) { + filteredTags = append(filteredTags, tag) + } } + } else { + filteredTags = tags + } - // If regex enabled continue here. - // If we match, and is less than, update latest. - if opts.RegexMatcher != nil { - if opts.RegexMatcher.MatchString(tags[i].Tag) && - (latestV == nil || latestV.LessThan(v)) { - latestV = v - latestImageTag = &tags[i] + // Apply regex matching if provided + if opts.RegexMatcher != nil { + var matchedTags []api.ImageTag + for _, tag := range filteredTags { + if opts.RegexMatcher.MatchString(tag.Tag) { + matchedTags = append(matchedTags, tag) } - - continue } + filteredTags = matchedTags + } - // If we have declared we wont use metadata but version has it, continue. - if !opts.UseMetaData && v.HasMetaData() { - continue + // Convert tags to semver.Version instances for sorting + var versions []*semver.Version + for _, tag := range filteredTags { + v, err := semver.NewVersion(tag.Tag) + if err == nil { + versions = append(versions, v) } + } - if opts.PinMajor != nil && *opts.PinMajor != v.Major() { - continue - } - if opts.PinMinor != nil && *opts.PinMinor != v.Minor() { - continue - } - if opts.PinPatch != nil && *opts.PinPatch != v.Patch() { - continue - } + // If no valid SemVer tags are found, return an error + if len(versions) == 0 { + return nil, fmt.Errorf("no matching SemVer tags found") + } + + // Sort versions by descending order + sort.Slice(versions, func(i, j int) bool { + return versions[i].GreaterThan(versions[j]) + }) - // If no latest yet set - if latestV == nil || - // If the latest set is less than - latestV.LessThan(v) || - // If the latest is the same tag, but smaller timestamp - (latestV.Equal(v) && tags[i].Timestamp.After(latestImageTag.Timestamp)) { - latestV = v - latestImageTag = &tags[i] + // Apply version pinning if provided + if opts.PinMajor != nil || opts.PinMinor != nil || opts.PinPatch != nil { + for _, v := range versions { + if versionMatches(v, opts.PinMajor, opts.PinMinor, opts.PinPatch) { + return &api.ImageTag{Tag: v.Original()}, nil + } } } - return latestImageTag, nil + // Return the latest SemVer tag + return &api.ImageTag{Tag: versions[0].Original()}, nil } // latestSHA will return the latest ImageTag based on image timestamps. @@ -174,3 +176,17 @@ func latestSHA(tags []api.ImageTag) (*api.ImageTag, error) { return latestTag, nil } + +// Helper function to check if a version matches the pinned version +func versionMatches(v *semver.Version, pinMajor, pinMinor, pinPatch *int64) bool { + if pinMajor != nil && *pinMajor != int64(v.Major()) { + return false + } + if pinMinor != nil && *pinMinor != int64(v.Minor()) { + return false + } + if pinPatch != nil && *pinPatch != int64(v.Patch()) { + return false + } + return true +} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 00000000..7def659a --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1,197 @@ +package version + +import ( + "regexp" + "testing" + "time" + + "github.com/jetstack/version-checker/pkg/api" + + "github.com/stretchr/testify/assert" +) + +// Helper function to parse time +func parseTime(t string) time.Time { + parsedTime, _ := time.Parse(time.RFC3339, t) + return parsedTime +} + +func TestLatestSemver(t *testing.T) { + // Ideal Set of Tags + tags := []api.ImageTag{ + {Tag: "v1.0.0", Timestamp: parseTime("2023-06-01T00:00:00Z")}, + {Tag: "v1.1.0", Timestamp: parseTime("2023-06-02T00:00:00Z")}, + {Tag: "v1.1.1-alpha", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "v1.1.1", Timestamp: parseTime("2023-06-04T00:00:00Z")}, + {Tag: "v2.0.0", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + } + tagsNoPrefix := []api.ImageTag{ + {Tag: "1.0.0", Timestamp: parseTime("2023-06-01T00:00:00Z")}, + {Tag: "1.1.0", Timestamp: parseTime("2023-06-02T00:00:00Z")}, + {Tag: "1.1.1-alpha", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "1.1.1", Timestamp: parseTime("2023-06-04T00:00:00Z")}, + {Tag: "2.0.0", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + } + // Include More Alpha/Beta/RC + alphaBetaTags := []api.ImageTag{ + {Tag: "v1.0.0", Timestamp: parseTime("2023-06-01T00:00:00Z")}, + {Tag: "v1.1.0", Timestamp: parseTime("2023-06-02T00:00:00Z")}, + {Tag: "v1.1.1-alpha", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "v1.1.1", Timestamp: parseTime("2023-06-04T00:00:00Z")}, + {Tag: "v2.0.0-alpha", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + {Tag: "v2.0.0-beta", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + {Tag: "v2.0.0-rc1", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + {Tag: "v2.0.0-rc2", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + {Tag: "v2.0.0", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + } + // Images that are all numerical + nonSemVer := []api.ImageTag{ + {Tag: "20230601", Timestamp: parseTime("2023-06-01T00:00:00Z")}, + {Tag: "20230602", Timestamp: parseTime("2023-06-02T00:00:00Z")}, + {Tag: "20230603", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "20230604", Timestamp: parseTime("2023-06-04T00:00:00Z")}, + {Tag: "20230605", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + } + // This is to simulate an image that USED to SemVer but stopped + stoppedSemVer := []api.ImageTag{ + {Tag: "v1.0.0", Timestamp: parseTime("2023-06-01T00:00:00Z")}, + {Tag: "v1.1.0", Timestamp: parseTime("2023-06-02T00:00:00Z")}, + {Tag: "20230603", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "202306031", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "20230604", Timestamp: parseTime("2023-06-04T00:00:00Z")}, + {Tag: "20230605", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + {Tag: "20230606", Timestamp: parseTime("2023-06-06T00:00:00Z")}, + } + // This is to simulate an image that USED to SemVer but stopped + startedSemVer := []api.ImageTag{ + {Tag: "20230603", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "202306031", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "20230604", Timestamp: parseTime("2023-06-04T00:00:00Z")}, + {Tag: "20230605", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + {Tag: "20230606", Timestamp: parseTime("2023-06-06T00:00:00Z")}, + {Tag: "v1.0.0", Timestamp: parseTime("2023-06-09T00:00:00Z")}, + {Tag: "v1.1.0", Timestamp: parseTime("2023-06-10T00:00:00Z")}, + } + // Mixed Numerical and SemVer along with Older images pushed more recently + badTags := []api.ImageTag{ + {Tag: "v1.0.0", Timestamp: parseTime("2023-06-01T00:00:00Z")}, + {Tag: "v1.1.0", Timestamp: parseTime("2023-06-02T00:00:00Z")}, + {Tag: "9999999", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "v1.1.1-alpha", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + {Tag: "v1.1.1", Timestamp: parseTime("2023-06-04T00:00:00Z")}, + {Tag: "v2.0.0", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + {Tag: "v1.1.1", Timestamp: parseTime("2023-06-06T00:00:00Z")}, + } + + tests := []struct { + name string + opts *api.Options + expected string + tags []api.ImageTag + }{ + { + name: "No constraints", + opts: &api.Options{}, + expected: "v2.0.0", + }, + { + name: "Regex match v1.*", + opts: &api.Options{ + RegexMatcher: regexp.MustCompile("v1.*"), + }, + expected: "v1.1.1", + }, + { + name: "Pin major version 1", + opts: &api.Options{ + PinMajor: intPtr(1), + }, + expected: "v1.1.1", + }, + { + name: "Pin minor version 1.1", + opts: &api.Options{ + PinMajor: intPtr(1), + PinMinor: intPtr(1), + }, + expected: "v1.1.1", + }, + { + name: "Pin patch version 1.1.1", + opts: &api.Options{ + PinMajor: intPtr(1), + PinMinor: intPtr(1), + PinPatch: intPtr(1), + }, + expected: "v1.1.1", + }, + { + name: "Exclude metadata", + opts: &api.Options{ + UseMetaData: false, + }, + expected: "v2.0.0", + }, + { + name: "Include metadata", + opts: &api.Options{ + UseMetaData: true, + }, + expected: "v2.0.0", + }, + { + name: "NoPrefixed Tags", + opts: &api.Options{}, + tags: tagsNoPrefix, + expected: "2.0.0", + }, + // Some Bad/Miss-behaving tags + { + name: "Bad Tags", + opts: &api.Options{}, + tags: badTags, + expected: "v2.0.0", + }, + // None SemVer tags + { + name: "Non SemVer", + opts: &api.Options{}, + tags: nonSemVer, + expected: "20230605", + }, + { + name: "Stopped SemVer", + opts: &api.Options{}, + tags: stoppedSemVer, + expected: "20230606", + }, + { + name: "Started SemVer", + opts: &api.Options{}, + tags: startedSemVer, + expected: "v1.1.0", + }, + { + name: "Alpha/Beta SemVer", + opts: &api.Options{}, + tags: alphaBetaTags, + expected: "v1.1.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if len(tt.tags) > 0 { + tags = tt.tags + } + tag, err := latestSemver(tt.opts, tags) + assert.NoError(t, err) + assert.NotNil(t, tag) + assert.Equal(t, tt.expected, tag.Tag) + }) + } +} + +func intPtr(i uint64) *uint64 { + return &i +} From 14f2fb930fbec31c128c419029fe08a6aa4ee96f Mon Sep 17 00:00:00 2001 From: David Collom Date: Mon, 17 Jun 2024 14:48:03 +0100 Subject: [PATCH 3/3] Adding tests for latestSHA and latestSemVer --- pkg/version/version.go | 10 +++++- pkg/version/version_test.go | 71 ++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/pkg/version/version.go b/pkg/version/version.go index 3dbac36c..d9353ee4 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "regexp" "sort" "time" @@ -14,7 +15,9 @@ import ( "github.com/jetstack/version-checker/pkg/cache" versionerrors "github.com/jetstack/version-checker/pkg/version/errors" - "github.com/jetstack/version-checker/pkg/version/semver" + + // "github.com/jetstack/version-checker/pkg/version/semver" + "github.com/Masterminds/semver/v3" ) type Version struct { @@ -190,3 +193,8 @@ func versionMatches(v *semver.Version, pinMajor, pinMinor, pinPatch *int64) bool } return true } + +func isSemVer(tag string) bool { + // Simple check for "vX.Y.Z" format + return regexp.MustCompile(`^v?\d+\.\d+\.\d+`).MatchString(tag) +} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go index 7def659a..57569bf7 100644 --- a/pkg/version/version_test.go +++ b/pkg/version/version_test.go @@ -192,6 +192,75 @@ func TestLatestSemver(t *testing.T) { } } -func intPtr(i uint64) *uint64 { +func TestLatestSHA(t *testing.T) { + tests := []struct { + name string + tags []api.ImageTag + expectedSHA *string + }{ + { + name: "Single tag", + tags: []api.ImageTag{ + {SHA: "sha1", Timestamp: time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)}, + }, + expectedSHA: strPtr("sha1"), + }, + { + name: "Multiple tags, latest in the middle", + tags: []api.ImageTag{ + {SHA: "sha1", Timestamp: time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {SHA: "sha2", Timestamp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {SHA: "sha3", Timestamp: time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC)}, + }, + expectedSHA: strPtr("sha2"), + }, + { + name: "Multiple tags, latest at the end", + tags: []api.ImageTag{ + {SHA: "sha1", Timestamp: time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {SHA: "sha2", Timestamp: time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {SHA: "sha3", Timestamp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + }, + expectedSHA: strPtr("sha3"), + }, + { + name: "No tags", + tags: []api.ImageTag{}, + expectedSHA: nil, + }, + { + name: "All tags with the same timestamp", + tags: []api.ImageTag{ + {SHA: "sha1", Timestamp: time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {SHA: "sha2", Timestamp: time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {SHA: "sha3", Timestamp: time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)}, + }, + expectedSHA: strPtr("sha1"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := latestSHA(tt.tags) + if err != nil { + t.Errorf("latestSHA() error = %v", err) + return + } + if (got == nil && tt.expectedSHA != nil) || (got != nil && tt.expectedSHA == nil) { + t.Errorf("latestSHA() = %v, want %v", got, tt.expectedSHA) + return + } + if got != nil && got.SHA != *tt.expectedSHA { + t.Errorf("latestSHA() = %v, want %v", got.SHA, *tt.expectedSHA) + } + }) + } +} + +func intPtr(i int64) *int64 { return &i } + +func strPtr(s string) *string { + return &s +}