Skip to content
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
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)
}
}

return client.Tags(ctx, host, repo, image)
Comment on lines 130 to 138
}

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