diff --git a/go.mod b/go.mod index 14584f84..6bbb9016 100644 --- a/go.mod +++ b/go.mod @@ -108,6 +108,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/procfs v0.16.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/vbatts/tar-split v0.12.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect diff --git a/pkg/client/client.go b/pkg/client/client.go index 3b36edef..fd54cd08 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -20,6 +20,11 @@ import ( "github.com/jetstack/version-checker/pkg/client/selfhosted" ) +// Used for testing/mocking purposes +type ClientHandler interface { + Tags(ctx context.Context, imageURL string) ([]api.ImageTag, error) +} + // Client is a container image registry client to list tags of given image // URLs. type Client struct { diff --git a/pkg/client/ghcr/ghcr.go b/pkg/client/ghcr/ghcr.go index 617e22d5..0b8a3d0f 100644 --- a/pkg/client/ghcr/ghcr.go +++ b/pkg/client/ghcr/ghcr.go @@ -119,10 +119,6 @@ func (c *Client) extractImageTags(versions []*github.PackageVersion) []api.Image } for _, tag := range meta.Container.Tags { - if c.shouldSkipTag(tag) { - continue - } - tags = append(tags, api.ImageTag{ Tag: tag, SHA: sha, @@ -134,12 +130,6 @@ func (c *Client) extractImageTags(versions []*github.PackageVersion) []api.Image return tags } -func (c *Client) shouldSkipTag(tag string) bool { - return strings.HasSuffix(tag, ".att") || - strings.HasSuffix(tag, ".sig") || - strings.HasSuffix(tag, ".sbom") -} - func (c *Client) ownerType(ctx context.Context, owner string) (string, error) { if ownerType, ok := c.ownerTypes[owner]; ok { return ownerType, nil diff --git a/pkg/version/errors/errors.go b/pkg/version/errors/errors.go index 2dd36bfb..4153c9cf 100644 --- a/pkg/version/errors/errors.go +++ b/pkg/version/errors/errors.go @@ -17,6 +17,8 @@ func NewVersionErrorNotFound(format string, a ...interface{}) *ErrorVersionNotFo return &ErrorVersionNotFound{fmt.Errorf(format, a...)} } +// The function IsNoVersionFound checks if the error is of type +// ErrorVersionNotFound. func IsNoVersionFound(err error) bool { var notFound *ErrorVersionNotFound return errors.As(err, ¬Found) diff --git a/pkg/version/errors/errors_test.go b/pkg/version/errors/errors_test.go new file mode 100644 index 00000000..7b06dff6 --- /dev/null +++ b/pkg/version/errors/errors_test.go @@ -0,0 +1,39 @@ +package errors + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsNoVersionFound(t *testing.T) { + tests := []struct { + name string + err error + expectRes bool + }{ + { + name: "error is of type ErrorVersionNotFound", + err: NewVersionErrorNotFound("version not found"), + expectRes: true, + }, + { + name: "error is not of type ErrorVersionNotFound", + err: errors.New("some other error"), + expectRes: false, + }, + { + name: "error is nil", + err: nil, + expectRes: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := IsNoVersionFound(test.err) + assert.Equal(t, result, test.expectRes) + }) + } +} diff --git a/pkg/version/filters.go b/pkg/version/filters.go new file mode 100644 index 00000000..f8360581 --- /dev/null +++ b/pkg/version/filters.go @@ -0,0 +1,64 @@ +package version + +import ( + "strings" + + "github.com/jetstack/version-checker/pkg/api" + "github.com/jetstack/version-checker/pkg/version/semver" +) + +func isSBOMAttestationOrSig(tag string) bool { + return strings.HasSuffix(tag, ".att") || + strings.HasSuffix(tag, ".sig") || + strings.HasSuffix(tag, ".sbom") +} + +// Used when filtering Tags as a SemVer +func shouldSkipTag(opts *api.Options, v *semver.SemVer) bool { + // Handle Regex matching + if opts.RegexMatcher != nil { + return !opts.RegexMatcher.MatchString(v.String()) + } + + // Handle metadata and version pinning + return (!opts.UseMetaData && v.HasMetaData()) || + (opts.PinMajor != nil && *opts.PinMajor != v.Major()) || + (opts.PinMinor != nil && *opts.PinMinor != v.Minor()) || + (opts.PinPatch != nil && *opts.PinPatch != v.Patch()) +} + +// Used when filtering SHA Tags +func shouldSkipSHA(opts *api.Options, sha string) bool { + // Filter out Sbom and Attestation/Signatures + if isSBOMAttestationOrSig(sha) { + return true + } + + // Allow for Regex Filtering + if opts != nil && opts.RegexMatcher != nil { + return !opts.RegexMatcher.MatchString(sha) + } + + return false +} + +// isBetterSemVer compares two semantic version numbers and +// associated image tags to determine if one is considered better than the other. +func isBetterSemVer(_ *api.Options, latestV, v *semver.SemVer, latestImageTag, currentImageTag *api.ImageTag) bool { + // No latest version set yet + if latestV == nil { + return true + } + + // If the current version is greater than the latest + if latestV.LessThan(v) { + return true + } + + // If the versions are equal, prefer the one with a later timestamp + if latestV.Equal(v) && currentImageTag.Timestamp.After(latestImageTag.Timestamp) { + return true + } + + return false +} diff --git a/pkg/version/version.go b/pkg/version/version.go index e2a2a266..dd3043ec 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -19,11 +19,11 @@ import ( type Version struct { log *logrus.Entry - client *client.Client + client client.ClientHandler imageCache *cache.Cache } -func New(log *logrus.Entry, client *client.Client, cacheTimeout time.Duration) *Version { +func New(log *logrus.Entry, client client.ClientHandler, cacheTimeout time.Duration) *Version { log = log.WithField("module", "version_getter") v := &Version{ @@ -49,7 +49,7 @@ func (v *Version) LatestTagFromImage(ctx context.Context, imageURL string, opts // If UseSHA then return early if opts.UseSHA { - tag, err = latestSHA(tags) + tag, err = latestSHA(opts, tags) if err != nil { return nil, err } @@ -88,6 +88,7 @@ func (v *Version) Fetch(ctx context.Context, imageURL string, _ *api.Options) (i if len(tags) == 0 { return nil, versionerrors.NewVersionErrorNotFound("no tags found for given image URL: %q", imageURL) } + v.log.WithField("image", imageURL).Debugf("fetched %v tags", len(tags)) return tags, nil } @@ -102,13 +103,18 @@ func latestSemver(opts *api.Options, tags []api.ImageTag) (*api.ImageTag, error) ) for i := range tags { + // Filter out SBOM and Attestation/Sig's + if isSBOMAttestationOrSig(tags[i].Tag) || isSBOMAttestationOrSig(tags[i].SHA) { + continue + } + v := semver.Parse(tags[i].Tag) if shouldSkipTag(opts, v) { continue } - if isBetterTag(opts, latestV, v, latestImageTag, &tags[i]) { + if isBetterSemVer(opts, latestV, v, latestImageTag, &tags[i]) { latestV = v latestImageTag = &tags[i] } @@ -121,43 +127,16 @@ func latestSemver(opts *api.Options, tags []api.ImageTag) (*api.ImageTag, error) return latestImageTag, nil } -func shouldSkipTag(opts *api.Options, v *semver.SemVer) bool { - // Handle Regex matching - if opts.RegexMatcher != nil { - return !opts.RegexMatcher.MatchString(v.String()) - } - - // Handle metadata and version pinning - return (!opts.UseMetaData && v.HasMetaData()) || - (opts.PinMajor != nil && *opts.PinMajor != v.Major()) || - (opts.PinMinor != nil && *opts.PinMinor != v.Minor()) || - (opts.PinPatch != nil && *opts.PinPatch != v.Patch()) -} - -func isBetterTag(_ *api.Options, latestV, v *semver.SemVer, latestImageTag, currentImageTag *api.ImageTag) bool { - // No latest version set yet - if latestV == nil { - return true - } - - // If the current version is greater than the latest - if latestV.LessThan(v) { - return true - } - - // If the versions are equal, prefer the one with a later timestamp - if latestV.Equal(v) && currentImageTag.Timestamp.After(latestImageTag.Timestamp) { - return true - } - - return false -} - // latestSHA will return the latest ImageTag based on image timestamps. -func latestSHA(tags []api.ImageTag) (*api.ImageTag, error) { +func latestSHA(opts *api.Options, tags []api.ImageTag) (*api.ImageTag, error) { var latestTag *api.ImageTag for i := range tags { + // Filter out SBOM and Attestation/Sig's... + if shouldSkipSHA(opts, tags[i].Tag) { + continue + } + if latestTag == nil || tags[i].Timestamp.After(latestTag.Timestamp) { latestTag = &tags[i] } diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go index 22aa6b70..f10e88cc 100644 --- a/pkg/version/version_test.go +++ b/pkg/version/version_test.go @@ -1,13 +1,20 @@ package version import ( + "context" + "fmt" "regexp" "testing" "time" "github.com/jetstack/version-checker/pkg/api" + "github.com/jetstack/version-checker/pkg/cache" + "github.com/jetstack/version-checker/pkg/client" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) // Helper function to parse time. @@ -25,6 +32,16 @@ func TestLatestSemver(t *testing.T) { {Tag: "v1.1.1", Timestamp: parseTime("2023-06-04T00:00:00Z")}, {Tag: "v2.0.0", Timestamp: parseTime("2023-06-05T00:00:00Z")}, } + sBomtags := []api.ImageTag{ + {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: "sha256-ea5b51fc3bd6d014355e56de6bda7f8f42acf261a0a4645a2107ccbc438e12c3.sig", Timestamp: parseTime("2023-06-04T10:00:00Z")}, + {Tag: "v2.0.0", Timestamp: parseTime("2023-06-05T00:00:00Z")}, + {Tag: "sha256-b019b2a5c384570201ba592be195769e1848d3106c8c56c4bdad7d2ee34748e0.sig", Timestamp: parseTime("2023-06-07T00:00:00Z")}, + {Tag: "sha256-b019b2a5c384570201ba592be195769e1848d3106c8c56c4bdad7d2ee34748e0.att", Timestamp: parseTime("2023-06-07T10:00:00Z")}, + {Tag: "sha256-b019b2a5c384570201ba592be195769e1848d3106c8c56c4bdad7d2ee34748e0.sbom", Timestamp: parseTime("2023-06-07T221: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")}, @@ -100,6 +117,12 @@ func TestLatestSemver(t *testing.T) { }, expected: "v1.1.1", }, + { + name: "Strip Sig/SBOM/Attestations", + opts: &api.Options{}, + expected: "v2.0.0", + tags: sBomtags, + }, { name: "Pin major version 1", opts: &api.Options{ @@ -205,6 +228,7 @@ func TestLatestSHA(t *testing.T) { tests := []struct { name string tags []api.ImageTag + options *api.Options expectedSHA *string }{ { @@ -232,6 +256,45 @@ func TestLatestSHA(t *testing.T) { }, expectedSHA: strPtr("sha3"), }, + { + name: "Multiple tags, including sig", + 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)}, + {Tag: "sha3.sig", SHA: "sha3", Timestamp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + }, + expectedSHA: strPtr("sha2"), + }, + { + name: "Multiple tags, including att", + 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)}, + {Tag: "sha3.att", SHA: "sha3", Timestamp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {Tag: "sha3.att", SHA: "sha3", Timestamp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + }, + expectedSHA: strPtr("sha2"), + }, + { + name: "Multiple tags, including sbom", + 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)}, + {Tag: "sha3.sbom", SHA: "sha3", Timestamp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + }, + expectedSHA: strPtr("sha2"), + }, + { + name: "Multiple tags, with Regex", + tags: []api.ImageTag{ + {Tag: "1", SHA: "sha1", Timestamp: time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {Tag: "2", SHA: "sha2", Timestamp: time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {Tag: "sha3.sbom", SHA: "sha3", Timestamp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {Tag: "sha3.jsbfjsabfjs", SHA: "sha3", Timestamp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + }, + options: &api.Options{RegexMatcher: regexp.MustCompile("^([0-9]+)$")}, + expectedSHA: strPtr("sha2"), + }, { name: "No tags", tags: []api.ImageTag{}, @@ -250,18 +313,206 @@ func TestLatestSHA(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := latestSHA(tt.tags) + got, err := latestSHA(tt.options, 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 tt.expectedSHA == nil { + assert.Nil(t, got) + } else { + require.NotNil(t, got, "Should have received a version %s got nil", *tt.expectedSHA) + assert.Equal(t, *tt.expectedSHA, got.SHA) + } + + }) + } +} + +func TestFetch(t *testing.T) { + tests := []struct { + name string + imageURL string + clientTags []api.ImageTag + clientError error + expected []api.ImageTag + expectError bool + }{ + { + name: "Successful fetch with tags", + imageURL: "example.com/image", + clientTags: []api.ImageTag{ + {Tag: "v1.0.0", Timestamp: parseTime("2023-06-01T00:00:00Z")}, + {Tag: "v1.1.0", Timestamp: parseTime("2023-06-02T00:00:00Z")}, + }, + expected: []api.ImageTag{{Tag: "v1.0.0", Timestamp: parseTime("2023-06-01T00:00:00Z")}, {Tag: "v1.1.0", Timestamp: parseTime("2023-06-02T00:00:00Z")}}, + expectError: false, + }, + { + name: "No tags found", + imageURL: "example.com/empty-image", + clientTags: []api.ImageTag{}, + expected: nil, + expectError: true, + }, + { + name: "Client error", + imageURL: "example.com/error-image", + clientError: fmt.Errorf("failed to fetch tags"), + expected: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &MockClient{} + mockClient.On("Tags", mock.Anything, tt.imageURL).Return(tt.clientTags, tt.clientError) + + v := &Version{ + log: logrus.NewEntry(logrus.New()), + client: mockClient, } - if got != nil && got.SHA != *tt.expectedSHA { - t.Errorf("latestSHA() = %v, want %v", got.SHA, *tt.expectedSHA) + + result, err := v.Fetch(context.Background(), tt.imageURL, nil) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestLatestTagFromImage(t *testing.T) { + tests := []struct { + name string + imageURL string + clientTags []api.ImageTag + clientError error + options *api.Options + expectedTag *api.ImageTag + expectError bool + }{ + { + name: "Latest SemVer tag", + imageURL: "example.com/image", + clientTags: []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: "v2.0.0", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + }, + options: &api.Options{}, + expectedTag: &api.ImageTag{Tag: "v2.0.0", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + expectError: false, + }, + { + name: "Latest SHA tag", + imageURL: "example.com/image", + clientTags: []api.ImageTag{ + {SHA: "sha1", Timestamp: parseTime("2023-06-01T00:00:00Z")}, + {SHA: "sha2", Timestamp: parseTime("2023-06-02T00:00:00Z")}, + {SHA: "sha3", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + }, + options: &api.Options{UseSHA: true}, + expectedTag: &api.ImageTag{SHA: "sha3", Timestamp: parseTime("2023-06-03T00:00:00Z")}, + expectError: false, + }, + { + name: "No matching tags", + imageURL: "example.com/image", + clientTags: []api.ImageTag{ + {Tag: "v1.0.0", Timestamp: parseTime("2023-06-01T00:00:00Z")}, + }, + options: &api.Options{RegexMatcher: regexp.MustCompile("^v2.*")}, + expectedTag: nil, + expectError: true, + }, + { + name: "Client error", + imageURL: "example.com/error-image", + clientError: fmt.Errorf("failed to fetch tags"), + options: &api.Options{}, + expectedTag: nil, + expectError: true, + }, + { + name: "No tags returned", + imageURL: "example.com/empty-image", + clientTags: []api.ImageTag{}, + options: &api.Options{}, + expectedTag: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &MockClient{} + mockClient.On("Tags", mock.Anything, tt.imageURL).Return(tt.clientTags, tt.clientError) + + log := logrus.NewEntry(logrus.New()) + v := &Version{ + log: log, + client: mockClient, + } + v.imageCache = cache.New(log, time.Minute, v) + + tag, err := v.LatestTagFromImage(context.Background(), tt.imageURL, tt.options) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, tag) + } else { + assert.NoError(t, err) + require.NotNil(t, tag) + assert.Equal(t, tt.expectedTag.Tag, tag.Tag) + assert.Equal(t, tt.expectedTag.Timestamp, tag.Timestamp) + } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestNew(t *testing.T) { + tests := []struct { + name string + log *logrus.Entry + client client.ClientHandler + cacheTimeout time.Duration + expectedCache time.Duration + }{ + { + name: "Valid inputs", + log: logrus.NewEntry(logrus.New()), + client: &MockClient{}, + cacheTimeout: time.Minute, + expectedCache: time.Minute, + }, + { + name: "Zero cache timeout", + log: logrus.NewEntry(logrus.New()), + client: &MockClient{}, + cacheTimeout: 0, + expectedCache: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version := New(tt.log, tt.client, tt.cacheTimeout) + + assert.NotNil(t, version) + assert.Equal(t, tt.log.WithField("module", "version_getter"), version.log) + assert.Equal(t, tt.client, version.client) + assert.NotNil(t, version.imageCache) }) } } @@ -273,3 +524,12 @@ func intPtr(i int64) *int64 { func strPtr(s string) *string { return &s } + +type MockClient struct { + mock.Mock +} + +func (m *MockClient) Tags(ctx context.Context, img string) ([]api.ImageTag, error) { + args := m.Called(ctx, img) + return args.Get(0).([]api.ImageTag), args.Error(1) +}