Skip to content
Merged
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
9 changes: 2 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/variantdev/mod

go 1.17
go 1.25.3

require (
github.com/Masterminds/semver v1.5.0
Expand All @@ -15,7 +15,6 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.1 // indirect
github.com/hashicorp/go-getter v1.4.1
github.com/hashicorp/hcl/v2 v2.3.0
github.com/heroku/docker-registry-client v0.0.0-20190909225348-afc9e1acc3d5
github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365 // indirect
github.com/imdario/mergo v0.3.8 // indirect
github.com/k-kinzal/aliases v0.5.1
Expand All @@ -42,8 +41,6 @@ require (
github.com/apparentlymart/go-textseg v1.0.0 // indirect
github.com/aws/aws-sdk-go v1.15.78 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/docker/distribution v0.0.0-20171011171712-7484e51bf6af // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/fatih/color v1.7.0 // indirect
github.com/go-playground/locales v0.12.1 // indirect
github.com/go-playground/universal-translator v0.16.0 // indirect
Expand All @@ -58,7 +55,6 @@ require (
github.com/huandu/xstrings v1.3.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
github.com/leodido/go-urn v1.1.0 // indirect
github.com/mattn/go-colorable v0.0.9 // indirect
github.com/mattn/go-isatty v0.0.4 // indirect
Expand All @@ -67,9 +63,8 @@ require (
github.com/mitchellh/go-testing-interface v1.0.0 // indirect
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
github.com/pkg/errors v0.8.1 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/stretchr/testify v1.4.0 // indirect
github.com/ulikunitz/xz v0.5.5 // indirect
go.opencensus.io v0.22.0 // indirect
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 // indirect
Expand Down
129 changes: 0 additions & 129 deletions go.sum

Large diffs are not rendered by default.

187 changes: 187 additions & 0 deletions pkg/dockerregistry/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Package dockerregistry provides a minimal Docker Registry API v2 client.
//
// This package exists because the heroku/docker-registry-client library is
// broken when interacting with Docker Hub. Docker Hub now returns relative URL
// paths in pagination Link headers instead of full URLs. This causes the
// heroku library to fail with "unsupported protocol schema """ when fetching
// tags that span multiple pages, because the request URL for the second page
// lacks the protocol and host part (since the next link is relative).
//
// This client properly handles relative URLs by resolving them against the
// base registry URL.
//
// Some code in this package is derived from heroku/docker-registry-client:
// https://github.com/heroku/docker-registry-client
// See transport.go for specific attributions.
package dockerregistry

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
)

var (
// ErrNoMorePages is returned when there are no more pages to fetch.
ErrNoMorePages = errors.New("no more pages")
)

// Client handles Docker Registry API v2 requests.
type Client struct {
baseURL *url.URL
username string
password string
client *http.Client
}

// Option is a functional option for configuring the Client.
type Option func(*Client)

// WithHTTPClient sets a custom HTTP client for the registry client.
// This is useful for testing or when custom transport settings are needed.
func WithHTTPClient(client *http.Client) Option {
return func(c *Client) {
c.client = client
}
}

// New creates a new registry client for the given base URL.
// If username and password are non-empty, they will be used for token authentication.
func New(baseURL, username, password string, opts ...Option) (*Client, error) {
u, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid base URL: %w", err)
}
c := &Client{
baseURL: u,
username: username,
password: password,
client: nil, // will be set below or by options
}
for _, opt := range opts {
opt(c)
}

// Wrap the transport with token auth support
if c.client == nil {
c.client = &http.Client{
Transport: WrapTransport(http.DefaultTransport, username, password),
}
} else {
// Wrap the custom client's transport with token auth
transport := c.client.Transport
if transport == nil {
transport = http.DefaultTransport
}
c.client = &http.Client{
Transport: WrapTransport(transport, username, password),
}
}
return c, nil
}

type tagsResponse struct {
Tags []string `json:"tags"`
}

// Tags fetches all tags for a repository, handling pagination.
func (c *Client) Tags(repository string) ([]string, error) {
u := c.url("/v2/%s/tags/list", repository)

var tags []string
for {
var response tagsResponse
nextURL, err := c.getPaginatedJSON(u, &response)
switch err {
case ErrNoMorePages:
tags = append(tags, response.Tags...)
return tags, nil
case nil:
tags = append(tags, response.Tags...)
u = nextURL
continue
default:
return nil, err
}
}
}

// url constructs a full URL from the base URL and the given path format.
func (c *Client) url(pathFormat string, args ...interface{}) string {
path := fmt.Sprintf(pathFormat, args...)
u := *c.baseURL
u.Path = path
return u.String()
}

// getPaginatedJSON fetches a URL and decodes the JSON response into the given
// interface. It returns the next page URL if present in the Link header,
// or ErrNoMorePages if there are no more pages.
func (c *Client) getPaginatedJSON(urlStr string, response interface{}) (string, error) {
req, err := http.NewRequest(http.MethodGet, urlStr, nil)
if err != nil {
return "", err
}

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

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(response); err != nil {
return "", err
}

return c.getNextLink(resp, urlStr)
}

// nextLinkRE matches an RFC 5988 (https://tools.ietf.org/html/rfc5988#section-5)
// Link header. For example,
//
// <http://registry.example.com/v2/_catalog?n=5&last=tag5>; type="application/json"; rel="next"
//
// The URL is _supposed_ to be wrapped by angle brackets `< ... >`,
// but e.g., quay.io does not include them. Similarly, params like
// `rel="next"` may not have quoted values in the wild.
//
// Derived from: https://github.com/heroku/docker-registry-client/blob/master/registry/json.go
var nextLinkRE = regexp.MustCompile(`^ *<?([^;>]+)>? *(?:;[^;]*)*; *rel="?next"?(?:;.*)?`)

// getNextLink extracts the next page URL from the Link header.
// It handles both absolute and relative URLs by resolving against the current request URL.
func (c *Client) getNextLink(resp *http.Response, currentURL string) (string, error) {
for _, link := range resp.Header[http.CanonicalHeaderKey("Link")] {
parts := nextLinkRE.FindStringSubmatch(link)
if parts != nil {
nextURL := parts[1]

// Parse the next URL to check if it's relative or absolute
parsedNext, err := url.Parse(nextURL)
if err != nil {
return "", fmt.Errorf("invalid next link URL: %w", err)
}

// If the URL is relative (no scheme), resolve it against the current URL
if parsedNext.Scheme == "" {
parsedCurrent, err := url.Parse(currentURL)
if err != nil {
return "", fmt.Errorf("invalid current URL: %w", err)
}
resolved := parsedCurrent.ResolveReference(parsedNext)
return resolved.String(), nil
}

return nextURL, nil
}
}
return "", ErrNoMorePages
}
Loading
Loading