diff --git a/docs/installation.md b/docs/installation.md index 42b91e81..abb663fa 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -75,6 +75,10 @@ However, by passing the following flag,`-a, --test-all-containers` version-check `use-metadata.version-checker.io` is not required when this is set. All other options, apart from URL overrides, are ignored when this is set. +- `use-github-release.version-checker.io/my-container: "true"`: is used to + source the latest version for `ghcr.io` images from the backing GitHub + repository releases instead of GHCR package tags. + - `override-url.version-checker.io/my-container: docker.io/bitnami/etcd`: is used to change the URL for where to lookup where the latest image version is. In this example, the current version of `my-container` will be compared diff --git a/known-configurations.md b/known-configurations.md index 7da1e4dd..5de9d312 100644 --- a/known-configurations.md +++ b/known-configurations.md @@ -42,3 +42,12 @@ Velero contains an image `1220` which is always the latest. | Annotation | |-| | `match-regex.version-checker.io/velero: 'v(\d+)\.(\d+)\.(\d+)'` | + +### n8n: ghcr.io/n8n-io/n8n + +n8n publishes the versions to compare against via GitHub Releases rather than +using GHCR package tags with enough specificity. + +| Annotation | +|-| +| `use-github-release.version-checker.io/n8n: "true"` | diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 57769fca..33bfedae 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -22,6 +22,10 @@ const ( // set. All other options are ignored when this is set. MatchRegexAnnotationKey = "match-regex.version-checker.io" + // UseGitHubReleaseAnnotationKey will use GitHub releases as the source for + // latest version checks against GHCR-backed images. + UseGitHubReleaseAnnotationKey = "use-github-release.version-checker.io" + // UseMetaDataAnnotationKey is defined as a tag containing anything after the // patch digit. // e.g. v1.0.1-gke.3 v1.0.1-alpha.0, v1.2.3.4... diff --git a/pkg/api/options.go b/pkg/api/options.go index 6b66d162..209fc84b 100644 --- a/pkg/api/options.go +++ b/pkg/api/options.go @@ -19,6 +19,8 @@ type Options struct { UseSHA bool `json:"use-sha,omitempty"` // Resolve SHA to a TAG ResolveSHAToTags bool `json:"resolve-sha-to-tags,omitempty"` + // Use GitHub releases as the source for latest GHCR versions. + UseGitHubRelease bool `json:"use-github-release,omitempty"` // UseMetaData defines whether tags with '-alpha', '-debian.0' etc. is // permissible. diff --git a/pkg/client/client.go b/pkg/client/client.go index 219802d6..fb3c4f3e 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -22,7 +22,7 @@ import ( // Used for testing/mocking purposes type ClientHandler interface { - Tags(ctx context.Context, imageURL string) ([]api.ImageTag, error) + Tags(ctx context.Context, imageURL string, opts *api.Options) ([]api.ImageTag, error) } // Client is a container image registry client to list tags of given image @@ -123,12 +123,18 @@ func New(ctx context.Context, log *logrus.Entry, opts Options) (*Client, error) } // Tags returns the full list of image tags available, for a given image URL. -func (c *Client) Tags(ctx context.Context, imageURL string) ([]api.ImageTag, error) { +func (c *Client) Tags(ctx context.Context, imageURL string, opts *api.Options) ([]api.ImageTag, error) { client, host, path := c.fromImageURL(imageURL) c.log.Debugf("using client %q for image URL %q", client.Name(), imageURL) repo, image := client.RepoImageFromPath(path) + if opts != nil && opts.UseGitHubRelease { + if ghcrClient, ok := client.(*ghcr.Client); ok { + return ghcrClient.ReleaseTags(ctx, repo, image) + } + } + return client.Tags(ctx, host, repo, image) } diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 61a6081b..d0ed7674 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -2,11 +2,14 @@ package client import ( "context" + "net/http" "testing" + "time" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/jarcoal/httpmock" "github.com/jetstack/version-checker/pkg/api" "github.com/jetstack/version-checker/pkg/client/acr" "github.com/jetstack/version-checker/pkg/client/docker" @@ -188,3 +191,33 @@ func TestFromImageURL(t *testing.T) { }) } } + +func TestTagsUsesGitHubReleasesForGHCR(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", "https://api.github.com/repos/test-user-owner/test-repo/releases", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(200, `[ + { + "tag_name": "v1.2.3", + "published_at": "2023-07-08T12:34:56Z" + } + ]`), nil + }) + + handler, err := New(context.TODO(), logrus.NewEntry(logrus.New()), Options{ + GHCR: ghcr.Options{ + Token: "test-token", + }, + }) + assert.NoError(t, err) + + tags, err := handler.Tags(context.Background(), "ghcr.io/test-user-owner/test-repo", &api.Options{ + UseGitHubRelease: true, + }) + assert.NoError(t, err) + assert.Equal(t, []api.ImageTag{ + {Tag: "v1.2.3", Timestamp: time.Date(2023, time.July, 8, 12, 34, 56, 0, time.UTC)}, + }, tags) +} diff --git a/pkg/client/ghcr/ghcr.go b/pkg/client/ghcr/ghcr.go index 80a2e5e2..da98549d 100644 --- a/pkg/client/ghcr/ghcr.go +++ b/pkg/client/ghcr/ghcr.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/jetstack/version-checker/pkg/api" @@ -85,6 +86,32 @@ func (c *Client) Tags(ctx context.Context, _, owner, repo string) ([]api.ImageTa return tags, nil } +func (c *Client) ReleaseTags(ctx context.Context, owner, pkg string) ([]api.ImageTag, error) { + repo := releaseRepoFromPackage(pkg) + if repo == "" { + return nil, fmt.Errorf("unable to determine GitHub repository from package %q", pkg) + } + + opts := &github.ListOptions{PerPage: 100} + var tags []api.ImageTag + for { + releases, resp, err := c.client.Repositories.ListReleases(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("getting releases: %w", err) + } + + tags = append(tags, extractReleaseTags(releases)...) + + if resp.NextPage == 0 { + break + } + + opts.Page = resp.NextPage + } + + return tags, nil +} + func (c *Client) determineGetAllVersionsFunc(ctx context.Context, owner, repo string) (func(ctx context.Context, owner, pkgType, repo string, opts *github.PackageListOptions) ([]*github.PackageVersion, *github.Response, error), string, error) { getAllVersions := c.client.Organizations.PackageGetAllVersions ownerType, err := c.ownerType(ctx, owner) @@ -162,3 +189,35 @@ func (c *Client) ownerType(ctx context.Context, owner string) (string, error) { return ownerType, nil } + +func releaseRepoFromPackage(pkg string) string { + repo, _, _ := strings.Cut(pkg, "/") + return repo +} + +func extractReleaseTags(releases []*github.RepositoryRelease) []api.ImageTag { + tags := make([]api.ImageTag, 0, len(releases)) + for _, release := range releases { + if release.GetDraft() || release.GetTagName() == "" { + continue + } + + tags = append(tags, api.ImageTag{ + Tag: release.GetTagName(), + Timestamp: releaseTimestamp(release), + }) + } + + return tags +} + +func releaseTimestamp(release *github.RepositoryRelease) time.Time { + switch { + case release.PublishedAt != nil: + return release.PublishedAt.Time + case release.CreatedAt != nil: + return release.CreatedAt.Time + default: + return time.Time{} + } +} diff --git a/pkg/client/ghcr/ghcr_test.go b/pkg/client/ghcr/ghcr_test.go index ff9568f3..fe0553a4 100644 --- a/pkg/client/ghcr/ghcr_test.go +++ b/pkg/client/ghcr/ghcr_test.go @@ -4,9 +4,11 @@ import ( "context" "net/http" "testing" + "time" "github.com/google/go-github/v70/github" "github.com/jarcoal/httpmock" + "github.com/jetstack/version-checker/pkg/api" "github.com/stretchr/testify/assert" ) @@ -60,6 +62,27 @@ func registerTagResponders() { }) } +func registerReleaseResponders() { + httpmock.RegisterResponder("GET", "https://api.github.com/repos/test-user-owner/test-repo/releases", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(200, `[ + { + "tag_name": "v1.0.0", + "published_at": "2023-07-08T12:34:56Z" + }, + { + "tag_name": "v1.1.0", + "created_at": "2023-08-08T12:34:56Z" + }, + { + "tag_name": "v9.9.9", + "draft": true, + "published_at": "2023-09-08T12:34:56Z" + } + ]`), nil + }) +} + func TestClient_Tags(t *testing.T) { setup() defer teardown() @@ -162,3 +185,23 @@ func TestClient_Tags(t *testing.T) { assert.ElementsMatch(t, []string{"tag1", "tag2"}, []string{tags[0].Tag, tags[1].Tag}) }) } + +func TestClient_ReleaseTags(t *testing.T) { + setup() + defer teardown() + + ctx := context.Background() + + httpmock.Reset() + registerReleaseResponders() + + client := New(Options{}) + client.client = github.NewClient(nil) + + tags, err := client.ReleaseTags(ctx, "test-user-owner", "test-repo/subpath") + assert.NoError(t, err) + assert.Equal(t, []api.ImageTag{ + {Tag: "v1.0.0", Timestamp: time.Date(2023, time.July, 8, 12, 34, 56, 0, time.UTC)}, + {Tag: "v1.1.0", Timestamp: time.Date(2023, time.August, 8, 12, 34, 56, 0, time.UTC)}, + }, tags) +} diff --git a/pkg/controller/options/options.go b/pkg/controller/options/options.go index 20ae0798..379aa033 100644 --- a/pkg/controller/options/options.go +++ b/pkg/controller/options/options.go @@ -38,6 +38,7 @@ func (b *Builder) Options(name string) (*api.Options, error) { b.handleSHAOption, b.handleSHAToTagOption, b.handleMetadataOption, + b.handleGitHubReleaseOption, b.handleRegexOption, b.handlePinMajorOption, b.handlePinMinorOption, @@ -88,6 +89,14 @@ func (b *Builder) handleMetadataOption(name string, opts *api.Options, setNonSha return nil } +func (b *Builder) handleGitHubReleaseOption(name string, opts *api.Options, setNonSha *bool, errs *[]string) error { + if useGitHubRelease, ok := b.ans[b.index(name, api.UseGitHubReleaseAnnotationKey)]; ok && useGitHubRelease == "true" { + *setNonSha = true + opts.UseGitHubRelease = true + } + return nil +} + func (b *Builder) handleRegexOption(name string, opts *api.Options, setNonSha *bool, errs *[]string) error { if matchRegex, ok := b.ans[b.index(name, api.MatchRegexAnnotationKey)]; ok { *setNonSha = true diff --git a/pkg/controller/options/options_test.go b/pkg/controller/options/options_test.go index d5d1aa21..8d353e34 100644 --- a/pkg/controller/options/options_test.go +++ b/pkg/controller/options/options_test.go @@ -75,6 +75,15 @@ func TestBuild(t *testing.T) { expOptions: nil, expErr: `cannot define "use-sha.version-checker.io/test-name" with any semver options`, }, + "cannot use sha with github releases": { + containerName: "test-name", + annotations: map[string]string{ + api.UseGitHubReleaseAnnotationKey + "/test-name": "true", + api.UseSHAAnnotationKey + "/test-name": "true", + }, + expOptions: nil, + expErr: `cannot define "use-sha.version-checker.io/test-name" with any semver options`, + }, "output options for pins and add metadata": { containerName: "test-name", annotations: map[string]string{ @@ -114,6 +123,16 @@ func TestBuild(t *testing.T) { }, expErr: "", }, + "output options for github releases": { + containerName: "test-name", + annotations: map[string]string{ + api.UseGitHubReleaseAnnotationKey + "/test-name": "true", + }, + expOptions: &api.Options{ + UseGitHubRelease: true, + }, + expErr: "", + }, "output options for resolve sha": { containerName: "test-name", annotations: map[string]string{ diff --git a/pkg/version/version.go b/pkg/version/version.go index b0fb5ebd..7ae247c8 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -40,7 +40,7 @@ func New(log *logrus.Entry, client client.ClientHandler, cacheTimeout time.Durat // LatestTagFromImage will return the latest tag given an imageURL, according // to the given options. func (v *Version) LatestTagFromImage(ctx context.Context, imageURL string, opts *api.Options) (*api.ImageTag, error) { - tagsI, err := v.imageCache.Get(ctx, imageURL, imageURL, nil) + tagsI, err := v.imageCache.Get(ctx, imageCacheIndex(imageURL, opts), imageURL, opts) if err != nil { return nil, err } @@ -94,9 +94,9 @@ func (v *Version) ResolveSHAToTag(ctx context.Context, imageURL string, imageSHA } // Fetch returns the given image tags for a given image URL. -func (v *Version) Fetch(ctx context.Context, imageURL string, _ *api.Options) (interface{}, error) { +func (v *Version) Fetch(ctx context.Context, imageURL string, opts *api.Options) (interface{}, error) { // fetch tags from image URL - tags, err := v.client.Tags(ctx, imageURL) + tags, err := v.client.Tags(ctx, imageURL, opts) if err != nil { return nil, fmt.Errorf("failed to get tags from remote registry for %q: %s", imageURL, err) @@ -111,3 +111,11 @@ func (v *Version) Fetch(ctx context.Context, imageURL string, _ *api.Options) (i return tags, nil } + +func imageCacheIndex(imageURL string, opts *api.Options) string { + if opts != nil && opts.UseGitHubRelease { + return "github-release:" + imageURL + } + + return imageURL +} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go index f10e88cc..83ee4a39 100644 --- a/pkg/version/version_test.go +++ b/pkg/version/version_test.go @@ -368,7 +368,7 @@ func TestFetch(t *testing.T) { 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) + mockClient.On("Tags", mock.Anything, tt.imageURL, (*api.Options)(nil)).Return(tt.clientTags, tt.clientError) v := &Version{ log: logrus.NewEntry(logrus.New()), @@ -455,7 +455,7 @@ func TestLatestTagFromImage(t *testing.T) { 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) + mockClient.On("Tags", mock.Anything, tt.imageURL, tt.options).Return(tt.clientTags, tt.clientError) log := logrus.NewEntry(logrus.New()) v := &Version{ @@ -529,7 +529,7 @@ type MockClient struct { mock.Mock } -func (m *MockClient) Tags(ctx context.Context, img string) ([]api.ImageTag, error) { - args := m.Called(ctx, img) +func (m *MockClient) Tags(ctx context.Context, img string, opts *api.Options) ([]api.ImageTag, error) { + args := m.Called(ctx, img, opts) return args.Get(0).([]api.ImageTag), args.Error(1) }