github.com/quay/claircore@v1.5.28/test/fetch/layer.go (about)

     1  // Package fetch implements just enough of a client for the OCI Distribution
     2  // specification for use in tests.
     3  package fetch
     4  
     5  import (
     6  	"compress/gzip"
     7  	"context"
     8  	"encoding/hex"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"net/http"
    14  	"net/url"
    15  	"os"
    16  	"path"
    17  	"path/filepath"
    18  	"strings"
    19  	"sync"
    20  	"testing"
    21  
    22  	"github.com/quay/claircore"
    23  	"github.com/quay/claircore/test/integration"
    24  )
    25  
    26  const (
    27  	ua = `claircore/test/fetch`
    28  )
    29  
    30  var registry = map[string]*client{
    31  	"docker.io":                  {Root: "https://registry-1.docker.io/"},
    32  	"gcr.io":                     {Root: "https://gcr.io/"},
    33  	"ghcr.io":                    {Root: "https://ghcr.io/"},
    34  	"quay.io":                    {Root: "https://quay.io/"},
    35  	"registry.access.redhat.com": {Root: "https://registry.access.redhat.com/"},
    36  }
    37  
    38  var pkgClient = &http.Client{
    39  	Transport: &http.Transport{},
    40  }
    41  
    42  // Layer returns the specified layer contents, cached in the global layer cache.
    43  func Layer(ctx context.Context, t testing.TB, from, repo string, blob claircore.Digest, opt ...Option) (*os.File, error) {
    44  	t.Helper()
    45  	opts := make(map[Option]bool)
    46  	for _, o := range opt {
    47  		opts[o] = true
    48  	}
    49  	// Splitting the digest like this future-proofs things against some weirdo
    50  	// running this on Windows.
    51  	cachefile := filepath.Join(integration.CacheDir(t), "layer", blob.Algorithm(), hex.EncodeToString(blob.Checksum()))
    52  	switch _, err := os.Stat(cachefile); {
    53  	case err == nil:
    54  		t.Logf("layer cached: %s", cachefile)
    55  		return os.Open(cachefile)
    56  	case errors.Is(err, os.ErrNotExist) && opts[IgnoreIntegration]:
    57  	case errors.Is(err, os.ErrNotExist):
    58  		// need to do work
    59  		integration.Skip(t)
    60  	default:
    61  		return nil, err
    62  	}
    63  	checkpath.Do(func() {
    64  		if err := os.MkdirAll(filepath.Dir(cachefile), 0o755); err != nil {
    65  			t.Errorf("unable to create needed directories: %v", err)
    66  		}
    67  	})
    68  	t.Logf("fetching layer into: %s", cachefile)
    69  
    70  	client, ok := registry[from]
    71  	if !ok {
    72  		return nil, fmt.Errorf("unknown registry: %q", from)
    73  	}
    74  	rc, err := client.Blob(ctx, pkgClient, repo, blob)
    75  	if err != nil {
    76  		return nil, err
    77  	}
    78  	defer rc.Close()
    79  	// BUG(hank) Any compression scheme that isn't gzip isn't handled correctly.
    80  	if !opts[NoDecompression] {
    81  		var gr *gzip.Reader
    82  		gr, err = gzip.NewReader(rc)
    83  		if err != nil {
    84  			return nil, err
    85  		}
    86  		defer gr.Close()
    87  		rc = gr
    88  	}
    89  	cf := copyTo(t, cachefile, rc)
    90  	if t.Failed() {
    91  		os.Remove(cachefile)
    92  		return nil, errors.New("unable to open cachefile")
    93  	}
    94  	return cf, nil
    95  }
    96  
    97  // Option is options for [Fetch]
    98  type Option uint
    99  
   100  const (
   101  	_ Option = iota
   102  	// IgnoreIntegration causes [Fetch] to use the network unconditionally.
   103  	IgnoreIntegration
   104  	// NoDecompression does no compression.
   105  	NoDecompression
   106  )
   107  
   108  // Checkpath guards against making the same directory over and over.
   109  var checkpath sync.Once
   110  
   111  func copyTo(t testing.TB, name string, rc io.Reader) *os.File {
   112  	cf, err := os.Create(name)
   113  	if err != nil {
   114  		t.Error(err)
   115  		return nil
   116  	}
   117  	t.Cleanup(func() {
   118  		if err := cf.Close(); err != nil {
   119  			t.Log(err)
   120  		}
   121  	})
   122  
   123  	if _, err = io.Copy(cf, rc); err != nil {
   124  		t.Error(err)
   125  		return nil
   126  	}
   127  	if err = cf.Sync(); err != nil {
   128  		t.Error(err)
   129  		return nil
   130  	}
   131  	if _, err = cf.Seek(0, io.SeekStart); err != nil {
   132  		t.Error(err)
   133  		return nil
   134  	}
   135  
   136  	return cf
   137  }
   138  
   139  type tokenResponse struct {
   140  	Token string `json:"token"`
   141  }
   142  
   143  // Client is a more generic registry client.
   144  type client struct {
   145  	tokCache sync.Map
   146  	Root     string
   147  }
   148  
   149  func (d *client) getToken(repo string) string {
   150  	if v, ok := d.tokCache.Load(repo); ok {
   151  		return v.(string)
   152  	}
   153  	return ""
   154  }
   155  
   156  func (d *client) putToken(repo, tok string) {
   157  	d.tokCache.Store(repo, tok)
   158  }
   159  
   160  func (d *client) doAuth(ctx context.Context, c *http.Client, name, h string) error {
   161  	if !strings.HasPrefix(h, `Bearer `) {
   162  		return errors.New("weird header")
   163  	}
   164  	attrs := map[string]string{}
   165  	fs := strings.Split(strings.TrimPrefix(h, `Bearer `), ",")
   166  	for _, f := range fs {
   167  		i := strings.IndexByte(f, '=')
   168  		if i == -1 {
   169  			return errors.New("even weirder header")
   170  		}
   171  		k := f[:i]
   172  		v := strings.Trim(f[i+1:], `"`)
   173  		attrs[k] = v
   174  	}
   175  
   176  	// Request a token
   177  	u, err := url.Parse(attrs["realm"])
   178  	if err != nil {
   179  		return err
   180  	}
   181  	v := url.Values{
   182  		"service": {attrs["service"]},
   183  		"scope":   {attrs["scope"]},
   184  	}
   185  	u.RawQuery = v.Encode()
   186  	req := &http.Request{
   187  		ProtoMajor: 1,
   188  		ProtoMinor: 1,
   189  		Proto:      "HTTP/1.1",
   190  		URL:        u,
   191  		Host:       u.Host,
   192  		Method:     http.MethodGet,
   193  		Header:     http.Header{"User-Agent": {ua}},
   194  	}
   195  	res, err := c.Do(req.WithContext(ctx))
   196  	if err != nil {
   197  		return err
   198  	}
   199  	switch res.StatusCode {
   200  	case http.StatusOK:
   201  	default:
   202  		return fmt.Errorf("%s %v: %v", req.Method, req.URL, res.Status)
   203  	}
   204  	defer res.Body.Close()
   205  	var tok tokenResponse
   206  	if err := json.NewDecoder(res.Body).Decode(&tok); err != nil {
   207  		return err
   208  	}
   209  	d.putToken(name, "Bearer "+tok.Token)
   210  	return nil
   211  }
   212  
   213  func (d *client) Blob(ctx context.Context, c *http.Client, name string, blob claircore.Digest) (io.ReadCloser, error) {
   214  	u, err := url.Parse(d.Root)
   215  	if err != nil {
   216  		return nil, err
   217  	}
   218  	u, err = u.Parse(path.Join("v2", name, "blobs", blob.String()))
   219  	if err != nil {
   220  		return nil, err
   221  	}
   222  	req := &http.Request{
   223  		ProtoMajor: 1,
   224  		ProtoMinor: 1,
   225  		URL:        u,
   226  		Host:       u.Host,
   227  		Method:     http.MethodGet,
   228  		Header:     http.Header{"User-Agent": {ua}},
   229  	}
   230  	if h := d.getToken(name); h != "" {
   231  		req.Header.Set(`authorization`, h)
   232  	}
   233  	res, err := c.Do(req.WithContext(ctx))
   234  	if err != nil {
   235  		return nil, err
   236  	}
   237  
   238  	switch res.StatusCode {
   239  	case http.StatusOK:
   240  	case http.StatusUnauthorized:
   241  		auth := res.Header.Get(`www-authenticate`)
   242  		if err := d.doAuth(ctx, c, name, auth); err != nil {
   243  			return nil, err
   244  		}
   245  		return d.Blob(ctx, c, name, blob)
   246  	default:
   247  		return nil, fmt.Errorf("%s %v: %v", req.Method, req.URL, res.Status)
   248  	}
   249  	return res.Body, nil
   250  }