diff --git a/pkg/client/client.go b/pkg/client/client.go index 219802d..b4b84ef 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -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" @@ -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 @@ -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 } @@ -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 { @@ -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), diff --git a/pkg/client/ecrpublic/ecrpublic.go b/pkg/client/ecrpublic/ecrpublic.go new file mode 100644 index 0000000..5bd0f44 --- /dev/null +++ b/pkg/client/ecrpublic/ecrpublic.go @@ -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 +} diff --git a/pkg/client/ecrpublic/path.go b/pkg/client/ecrpublic/path.go new file mode 100644 index 0000000..d43db19 --- /dev/null +++ b/pkg/client/ecrpublic/path.go @@ -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:], "/") +} diff --git a/pkg/client/ecrpublic/path_test.go b/pkg/client/ecrpublic/path_test.go new file mode 100644 index 0000000..3cdd687 --- /dev/null +++ b/pkg/client/ecrpublic/path_test.go @@ -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) + } + }) + } +} diff --git a/pkg/client/ecrpublic/types.go b/pkg/client/ecrpublic/types.go new file mode 100644 index 0000000..a44a299 --- /dev/null +++ b/pkg/client/ecrpublic/types.go @@ -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"` +}