Skip to content

Add support for public ecr #391

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
8 changes: 8 additions & 0 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/jetstack/version-checker/pkg/client/acr"
"github.com/jetstack/version-checker/pkg/client/docker"
"github.com/jetstack/version-checker/pkg/client/ecr"
"github.com/jetstack/version-checker/pkg/client/ecrpublic"
"github.com/jetstack/version-checker/pkg/client/fallback"
"github.com/jetstack/version-checker/pkg/client/gcr"
"github.com/jetstack/version-checker/pkg/client/ghcr"
Expand Down Expand Up @@ -39,6 +40,7 @@ type Options struct {
ACR acr.Options
Docker docker.Options
ECR ecr.Options
ECRPublic ecrpublic.Options // Add ECRPublic options
GCR gcr.Options
GHCR ghcr.Options
OCI oci.Options
Expand All @@ -54,6 +56,7 @@ func New(ctx context.Context, log *logrus.Entry, opts Options) (*Client, error)
if opts.Transport != nil {
opts.Quay.Transporter = opts.Transport
opts.ECR.Transporter = opts.Transport
opts.ECRPublic.Transporter = opts.Transport // Add ECR public transporter
opts.GHCR.Transporter = opts.Transport
opts.GCR.Transporter = opts.Transport
}
Expand All @@ -66,6 +69,10 @@ func New(ctx context.Context, log *logrus.Entry, opts Options) (*Client, error)
if err != nil {
return nil, fmt.Errorf("failed to create docker client: %w", err)
}
ecrPublicClient, err := ecrpublic.New(opts.ECRPublic, log)
if err != nil {
return nil, fmt.Errorf("failed to create ecr public client: %w", err)
}

var selfhostedClients []api.ImageClient
for _, sOpts := range opts.Selfhosted {
Expand Down Expand Up @@ -106,6 +113,7 @@ func New(ctx context.Context, log *logrus.Entry, opts Options) (*Client, error)
selfhostedClients,
acrClient,
ecr.New(opts.ECR),
ecrPublicClient,
dockerClient,
gcr.New(opts.GCR),
ghcr.New(opts.GHCR),
Expand Down
152 changes: 152 additions & 0 deletions pkg/client/ecrpublic/ecrpublic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package ecrpublic

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"

"github.com/sirupsen/logrus"

retryablehttp "github.com/hashicorp/go-retryablehttp"
"github.com/jetstack/version-checker/pkg/api"
"github.com/jetstack/version-checker/pkg/client/util"
)

const (
ecrPublicLookupURL = "https://public.ecr.aws/v2/%s/%s/tags/list"
loginURL = "https://public.ecr.aws/token/?service=ecr-public"
)

type Options struct {
Username string
Password string
Transporter http.RoundTripper
}

type Client struct {
*http.Client
Options
}

func New(opts Options, log *logrus.Entry) (*Client, error) {
retryclient := retryablehttp.NewClient()
if opts.Transporter != nil {
retryclient.HTTPClient.Transport = opts.Transporter
}
retryclient.HTTPClient.Timeout = 10 * time.Second
retryclient.RetryMax = 10
retryclient.RetryWaitMax = 10 * time.Minute
retryclient.RetryWaitMin = 1 * time.Second
// This custom backoff will fail requests that have a max wait of the RetryWaitMax
retryclient.Backoff = util.HTTPBackOff
retryclient.Logger = log.WithField("client", "ecrpublic")
client := retryclient.StandardClient()

return &Client{
Options: opts,
Client: client,
}, nil
}

func (c *Client) Name() string {
return "ecrpublic"
}

func (c *Client) Tags(ctx context.Context, _, repo, image string) ([]api.ImageTag, error) {
url := fmt.Sprintf(ecrPublicLookupURL, repo, image)

var tags []api.ImageTag
for url != "" {
response, err := c.doRequest(ctx, url)
if err != nil {
return nil, err
}

for _, tag := range response.Tags {
// No images in this result, so continue early
if len(tag) == 0 {
continue
}

tags = append(tags, api.ImageTag{
Tag: tag,
})
}

url = response.Next
}

return tags, nil
}

func (c *Client) doRequest(ctx context.Context, url string) (*TagResponse, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}

// Always get a token for ECR Public
token, err := getAnonymousToken(ctx, c.Client)
if err != nil {
return nil, fmt.Errorf("failed to get anonymous token: %s", err)
}

req.URL.Scheme = "https"
req = req.WithContext(ctx)
if len(token) > 0 {
req.Header.Add("Authorization", "Bearer "+token)
}

resp, err := c.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get %q image: %s", c.Name(), err)
}
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

response := new(TagResponse)
if err := json.Unmarshal(body, response); err != nil {
return nil, fmt.Errorf("unexpected image tags response: %s", body)
}

return response, nil
}

func getAnonymousToken(ctx context.Context, client *http.Client) (string, error) {
req, err := http.NewRequest(http.MethodGet, loginURL, nil)
if err != nil {
return "", err
}

req = req.WithContext(ctx)

resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}

if resp.StatusCode != http.StatusOK {
return "", errors.New(string(body))
}

response := new(AuthResponse)
if err := json.Unmarshal(body, response); err != nil {
return "", err
}

return response.Token, nil
}
20 changes: 20 additions & 0 deletions pkg/client/ecrpublic/path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package ecrpublic

import (
"regexp"
"strings"
)

var (
ecrPublicPattern = regexp.MustCompile(`^public\.ecr\.aws$`)
)

func (c *Client) IsHost(host string) bool {
return ecrPublicPattern.MatchString(host)
}

func (c *Client) RepoImageFromPath(path string) (string, string) {
parts := strings.Split(path, "/")

return parts[0], strings.Join(parts[1:], "/")
}
95 changes: 95 additions & 0 deletions pkg/client/ecrpublic/path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package ecrpublic

import "testing"

func TestIsHost(t *testing.T) {
tests := map[string]struct {
host string
expIs bool
}{
"an empty host should be false": {
host: "",
expIs: false,
},
"random string should be false": {
host: "foobar",
expIs: false,
},
"path with two segments should be false": {
host: "joshvanl/version-checker",
expIs: false,
},
"path with three segments should be false": {
host: "jetstack/joshvanl/version-checker",
expIs: false,
},
"random string with dots should be false": {
host: "foobar.foo",
expIs: false,
},
"docker.io should be false": {
host: "docker.io",
expIs: false,
},
"docker.com should be false": {
host: "docker.com",
expIs: false,
},
"just public.ecr.aws should be true": {
host: "public.ecr.aws",
expIs: true,
},
"public.ecr.aws.foo should be false": {
host: "public.ecr.aws.foo",
expIs: false,
},
"foo.public.ecr.aws should be false": {
host: "foo.public.ecr.aws",
expIs: false,
},
}

handler := new(Client)
for name, test := range tests {
t.Run(name, func(t *testing.T) {
if isHost := handler.IsHost(test.host); isHost != test.expIs {
t.Errorf("%s: unexpected IsHost, exp=%t got=%t",
test.host, test.expIs, isHost)
}
})
}
}

func TestRepoImageFromPath(t *testing.T) {
tests := map[string]struct {
path string
expRepo, expImage string
}{
"single image should return registry and image": {
path: "nginx",
expRepo: "nginx",
expImage: "",
},
"two segments to path should return registry and repo": {
path: "eks-distro/kubernetes",
expRepo: "eks-distro",
expImage: "kubernetes",
},
"three segments to path should return registry and combined repo": {
path: "eks-distro/kubernetes/kube-proxy",
expRepo: "eks-distro",
expImage: "kubernetes/kube-proxy",
},
}

handler := new(Client)
for name, test := range tests {
t.Run(name, func(t *testing.T) {
repo, image := handler.RepoImageFromPath(test.path)
if repo != test.expRepo || image != test.expImage {
t.Errorf("%s: unexpected repo/image, exp=%s/%s got=%s/%s",
test.path, test.expRepo, test.expImage, repo, image)
}
})
}
}
11 changes: 11 additions & 0 deletions pkg/client/ecrpublic/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package ecrpublic

type AuthResponse struct {
Token string `json:"token"`
}

type TagResponse struct {
Next string `json:"next"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
Loading