Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions known-configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"` |
4 changes: 4 additions & 0 deletions pkg/api/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Comment on lines +137 to +146

return client.Tags(ctx, host, repo, image)
Comment thread
davidcollom marked this conversation as resolved.
}

Expand Down
33 changes: 33 additions & 0 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
59 changes: 59 additions & 0 deletions pkg/client/ghcr/ghcr.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strings"
"time"

"github.com/jetstack/version-checker/pkg/api"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{}
}
}
43 changes: 43 additions & 0 deletions pkg/client/ghcr/ghcr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
9 changes: 9 additions & 0 deletions pkg/controller/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions pkg/controller/options/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down
14 changes: 11 additions & 3 deletions pkg/version/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Comment thread
davidcollom marked this conversation as resolved.
Outdated
}
Comment on lines +115 to +121
8 changes: 4 additions & 4 deletions pkg/version/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
Loading