github.com/quay/claircore@v1.5.28/libindex/fetcher.go (about)

     1  package libindex
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"context"
     7  	"encoding/hex"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"net/url"
    13  	"os"
    14  	"runtime"
    15  	"strings"
    16  	"sync"
    17  
    18  	"github.com/quay/zlog"
    19  	"go.opentelemetry.io/otel/attribute"
    20  	"go.opentelemetry.io/otel/codes"
    21  	"go.opentelemetry.io/otel/trace"
    22  	"golang.org/x/sync/errgroup"
    23  	"golang.org/x/sync/singleflight"
    24  
    25  	"github.com/quay/claircore"
    26  	"github.com/quay/claircore/indexer"
    27  	"github.com/quay/claircore/internal/wart"
    28  	"github.com/quay/claircore/internal/zreader"
    29  )
    30  
    31  var (
    32  	_ indexer.FetchArena          = (*RemoteFetchArena)(nil)
    33  	_ indexer.Realizer            = (*FetchProxy)(nil)
    34  	_ indexer.DescriptionRealizer = (*FetchProxy)(nil)
    35  )
    36  
    37  // RemoteFetchArena uses disk space to track fetched layers, removing them once
    38  // all users are done with the layers.
    39  type RemoteFetchArena struct {
    40  	wc *http.Client
    41  	sf *singleflight.Group
    42  
    43  	// Rc holds (string, *rc).
    44  	//
    45  	// The string is a layer digest.
    46  	rc   sync.Map
    47  	root string
    48  }
    49  
    50  // NewRemoteFetchArena returns an initialized RemoteFetchArena.
    51  //
    52  // If the "root" parameter is "", the advice in [file-hierarchy(7)] and ["Using
    53  // /tmp/ and /var/tmp/ Safely"] is followed. Specifically, "/var/tmp" is used
    54  // unless "TMPDIR" is set in the environment, in which case the contents of that
    55  // variable are interpreted as a path and used.
    56  //
    57  // The RemoteFetchArena attempts to use [O_TMPFILE] and falls back to
    58  // [os.CreateTemp] if that seems to not work. If the filesystem backing "root"
    59  // does not support O_TMPFILE, files may linger in the event of a process
    60  // crashing or an unclean shutdown. Operators should either use a different
    61  // filesystem or arrange for periodic cleanup via [systemd-tmpfiles(8)] or
    62  // similar.
    63  //
    64  // In a containerized environment, operators may need to mount a directory or
    65  // filesystem on "/var/tmp".
    66  //
    67  // On OSX, temporary files are not unlinked from the filesystem upon creation,
    68  // because an equivalent to Linux's "/proc/self/fd" doesn't seem to exist.
    69  //
    70  // On UNIX-unlike systems, none of the above logic takes place.
    71  //
    72  // [file-hierarchy(7)]: https://www.freedesktop.org/software/systemd/man/latest/file-hierarchy.html
    73  // ["Using /tmp/ and /var/tmp/ Safely"]: https://systemd.io/TEMPORARY_DIRECTORIES/
    74  // [O_TMPFILE]: https://man7.org/linux/man-pages/man2/open.2.html
    75  // [systemd-tmpfiles(8)]: https://www.freedesktop.org/software/systemd/man/latest/systemd-tmpfiles.html
    76  func NewRemoteFetchArena(wc *http.Client, root string) *RemoteFetchArena {
    77  	return &RemoteFetchArena{
    78  		wc:   wc,
    79  		sf:   &singleflight.Group{},
    80  		root: fixTemp(root),
    81  	}
    82  }
    83  
    84  // Rc is a reference counter.
    85  type rc struct {
    86  	sync.Mutex
    87  	val   *tempFile
    88  	count int
    89  	done  func()
    90  }
    91  
    92  // NewRc makes an rc.
    93  func newRc(v *tempFile, done func()) *rc {
    94  	return &rc{
    95  		val:  v,
    96  		done: done,
    97  	}
    98  }
    99  
   100  // Dec decrements the reference count, closing the inner file and calling the
   101  // cleanup hook if necessary.
   102  func (r *rc) dec() (err error) {
   103  	r.Lock()
   104  	defer r.Unlock()
   105  	if r.count == 0 {
   106  		return errors.New("close botch: count already 0")
   107  	}
   108  	r.count--
   109  	if r.count == 0 {
   110  		r.done()
   111  		err = r.val.Close()
   112  	}
   113  	return err
   114  }
   115  
   116  // Ref increments the reference count.
   117  func (r *rc) Ref() *ref {
   118  	r.Lock()
   119  	r.count++
   120  	r.Unlock()
   121  	n := &ref{rc: r}
   122  	runtime.SetFinalizer(n, (*ref).Close)
   123  	return n
   124  }
   125  
   126  // Ref is a reference handle, RAII-style.
   127  type ref struct {
   128  	once sync.Once
   129  	rc   *rc
   130  }
   131  
   132  // Val clones the inner File.
   133  func (r *ref) Val() (*os.File, error) {
   134  	r.rc.Lock()
   135  	defer r.rc.Unlock()
   136  	return r.rc.val.Reopen()
   137  }
   138  
   139  // Close decrements the refcount.
   140  func (r *ref) Close() (err error) {
   141  	did := false
   142  	r.once.Do(func() {
   143  		err = r.rc.dec()
   144  		did = true
   145  	})
   146  	if !did {
   147  		return errClosed
   148  	}
   149  	return err
   150  }
   151  
   152  // Errors out of the rc/ref types.
   153  var (
   154  	errClosed = errors.New("Ref already Closed")
   155  	errStale  = errors.New("stale file reference")
   156  )
   157  
   158  // FetchInto populates "l" and "cl" via a [singleflight.Group].
   159  //
   160  // It returns a closure to be used with an [errgroup.Group]
   161  func (a *RemoteFetchArena) fetchInto(ctx context.Context, l *claircore.Layer, cl *io.Closer, desc *claircore.LayerDescription) (do func() error) {
   162  	key := desc.Digest
   163  	// All the refcounting needs to happen _outside_ the singleflight, because
   164  	// the result of a singleflight call can be shared. Without doing it this
   165  	// way, the refcount would be incorrect.
   166  	do = func() error {
   167  		ctx, span := tracer.Start(ctx, "RemoteFetchArena.fetchInto", trace.WithAttributes(attribute.String("key", key)))
   168  		defer span.End()
   169  		var c *rc
   170  		var err error
   171  		defer func() {
   172  			span.RecordError(err)
   173  			if err == nil {
   174  				span.SetStatus(codes.Ok, "")
   175  			} else {
   176  				span.SetStatus(codes.Error, "fetchInto error")
   177  			}
   178  			return
   179  		}()
   180  
   181  		try := func() (any, error) {
   182  			return a.fetchUnlinkedFile(ctx, key, desc)
   183  		}
   184  		select {
   185  		case res := <-a.sf.DoChan(key, try):
   186  			if e := res.Err; e != nil {
   187  				err = fmt.Errorf("error realizing layer %s: %w", key, e)
   188  				return err
   189  			}
   190  			c = res.Val.(*rc)
   191  			span.AddEvent("got value from singleflight")
   192  			span.SetAttributes(attribute.Bool("shared", res.Shared))
   193  		case <-ctx.Done():
   194  			err = context.Cause(ctx)
   195  			return err
   196  		}
   197  
   198  		r := c.Ref()
   199  		f, err := r.Val()
   200  		switch {
   201  		case errors.Is(err, nil):
   202  		case errors.Is(err, errStale):
   203  			zlog.Debug(ctx).Str("key", key).Msg("managed to get stale ref, retrying")
   204  			return do()
   205  		default:
   206  			r.Close()
   207  			return err
   208  		}
   209  		if err := l.Init(ctx, desc, f); err != nil {
   210  			return errors.Join(err, f.Close(), r.Close())
   211  		}
   212  		*cl = closeFunc(func() error {
   213  			return errors.Join(l.Close(), f.Close(), r.Close())
   214  		})
   215  		return nil
   216  	}
   217  	return do
   218  }
   219  
   220  // CloseFunc is an adapter in the vein of [http.HandlerFunc].
   221  type closeFunc func() error
   222  
   223  // Close implements [io.Closer].
   224  func (f closeFunc) Close() error {
   225  	return f()
   226  }
   227  
   228  // FetchUnlinkedFile is the inner function used inside the singleflight.
   229  //
   230  // Because we know we're the only concurrent call that's dealing with this key,
   231  // we can be a bit more lax.
   232  func (a *RemoteFetchArena) fetchUnlinkedFile(ctx context.Context, key string, desc *claircore.LayerDescription) (*rc, error) {
   233  	ctx = zlog.ContextWithValues(ctx,
   234  		"component", "libindex/fetchArena.fetchUnlinkedFile",
   235  		"arena", a.root,
   236  		"layer", desc.Digest,
   237  		"uri", desc.URI)
   238  	ctx, span := tracer.Start(ctx, "RemoteFetchArena.fetchUnlinkedFile")
   239  	defer span.End()
   240  	span.SetStatus(codes.Error, "")
   241  	zlog.Debug(ctx).Msg("layer fetch start")
   242  
   243  	// Validate the layer input.
   244  	if desc.URI == "" {
   245  		return nil, fmt.Errorf("empty uri for layer %v", desc.Digest)
   246  	}
   247  	digest, err := claircore.ParseDigest(desc.Digest)
   248  	if err != nil {
   249  		return nil, err
   250  	}
   251  	url, err := url.ParseRequestURI(desc.URI)
   252  	if err != nil {
   253  		return nil, fmt.Errorf("failed to parse remote path uri: %v", err)
   254  	}
   255  	v, ok := a.rc.Load(key)
   256  	if ok {
   257  		span.SetStatus(codes.Ok, "")
   258  		return v.(*rc), nil
   259  	}
   260  	// Otherwise, it needs to be populated.
   261  	f, err := openTemp(a.root)
   262  	if err != nil {
   263  		return nil, err
   264  	}
   265  	vh, want := digest.Hash(), digest.Checksum()
   266  
   267  	// It'd be nice to be able to pre-allocate our file on disk, but we can't
   268  	// because of decompression.
   269  
   270  	req := (&http.Request{
   271  		ProtoMajor: 1,
   272  		ProtoMinor: 1,
   273  		Proto:      "HTTP/1.1",
   274  		Host:       url.Host,
   275  		Method:     http.MethodGet,
   276  		URL:        url,
   277  		Header:     http.Header(desc.Headers).Clone(),
   278  	}).WithContext(ctx)
   279  	resp, err := a.wc.Do(req)
   280  	if err != nil {
   281  		return nil, fmt.Errorf("fetcher: request failed: %w", err)
   282  	}
   283  	defer resp.Body.Close()
   284  	span.SetAttributes(attribute.Int("http.code", resp.StatusCode))
   285  	switch resp.StatusCode {
   286  	case http.StatusOK:
   287  	default:
   288  		// Especially for 4xx errors, the response body may indicate what's going
   289  		// on, so include some of it in the error message. Capped at 256 bytes in
   290  		// order to not flood the log.
   291  		bodyStart, err := io.ReadAll(io.LimitReader(resp.Body, 256))
   292  		if err == nil {
   293  			return nil, fmt.Errorf("fetcher: unexpected status code: %s (body starts: %q)",
   294  				resp.Status, bodyStart)
   295  		}
   296  		return nil, fmt.Errorf("fetcher: unexpected status code: %s", resp.Status)
   297  	}
   298  	tr := io.TeeReader(resp.Body, vh)
   299  
   300  	// TODO(hank) All this decompression code could go away, but that would mean
   301  	// that a buffer file would have to be allocated later, adding additional
   302  	// disk usage.
   303  	//
   304  	// The ultimate solution is to move to a fetcher that proxies to HTTP range
   305  	// requests.
   306  	zr, kind, err := zreader.Detect(tr)
   307  	if err != nil {
   308  		return nil, fmt.Errorf("fetcher: error determining compression: %w", err)
   309  	}
   310  	defer zr.Close()
   311  	// Look at the content-type and optionally fix it up.
   312  	ct := resp.Header.Get("content-type")
   313  	zlog.Debug(ctx).
   314  		Str("content-type", ct).
   315  		Msg("reported content-type")
   316  	span.SetAttributes(attribute.String("payload.content-type", ct), attribute.Stringer("payload.compression.detected", kind))
   317  	if ct == "" || ct == "text/plain" || ct == "binary/octet-stream" || ct == "application/octet-stream" {
   318  		switch kind {
   319  		case zreader.KindGzip:
   320  			ct = "application/gzip"
   321  		case zreader.KindZstd:
   322  			ct = "application/zstd"
   323  		case zreader.KindNone:
   324  			ct = "application/x-tar"
   325  		default:
   326  			return nil, fmt.Errorf("fetcher: disallowed compression kind: %q", kind.String())
   327  		}
   328  		zlog.Debug(ctx).
   329  			Str("content-type", ct).
   330  			Msg("fixed content-type")
   331  		span.SetAttributes(attribute.String("payload.content-type.detected", ct))
   332  	}
   333  
   334  	var wantZ zreader.Compression
   335  	switch {
   336  	case ct == "application/vnd.docker.image.rootfs.diff.tar.gzip":
   337  		// Catch the old docker media type.
   338  		fallthrough
   339  	case ct == "application/gzip" || ct == "application/x-gzip":
   340  		// GHCR reports gzipped layers as the latter.
   341  		fallthrough
   342  	case strings.HasSuffix(ct, ".tar+gzip"):
   343  		wantZ = zreader.KindGzip
   344  	case ct == "application/zstd":
   345  		fallthrough
   346  	case strings.HasSuffix(ct, ".tar+zstd"):
   347  		wantZ = zreader.KindZstd
   348  	case ct == "application/x-tar":
   349  		fallthrough
   350  	case strings.HasSuffix(ct, ".tar"):
   351  		wantZ = zreader.KindNone
   352  	default:
   353  		return nil, fmt.Errorf("fetcher: unknown content-type %q", ct)
   354  	}
   355  	if kind != wantZ {
   356  		return nil, fmt.Errorf("fetcher: mismatched compression (%q) and content-type (%q)", kind.String(), ct)
   357  	}
   358  
   359  	buf := bufio.NewWriter(f)
   360  	n, err := io.Copy(buf, zr)
   361  	zlog.Debug(ctx).Int64("size", n).Msg("wrote file")
   362  	if err != nil {
   363  		return nil, err
   364  	}
   365  	if err := buf.Flush(); err != nil {
   366  		return nil, err
   367  	}
   368  	if got := vh.Sum(nil); !bytes.Equal(got, want) {
   369  		err := fmt.Errorf("fetcher: validation failed: got %q, expected %q",
   370  			hex.EncodeToString(got),
   371  			hex.EncodeToString(want))
   372  		return nil, err
   373  	}
   374  
   375  	rc := newRc(f, func() {
   376  		a.rc.Delete(key)
   377  	})
   378  	if _, ok := a.rc.Swap(key, rc); ok {
   379  		rc.Ref().Close()
   380  		return nil, fmt.Errorf("fetcher: double-store for key %q", key)
   381  	}
   382  
   383  	zlog.Debug(ctx).Msg("layer fetch ok")
   384  	span.SetStatus(codes.Ok, "")
   385  	return rc, nil
   386  }
   387  
   388  // Close forgets all references in the arena.
   389  //
   390  // Any outstanding Layers may cause keys to be forgotten at unpredictable times.
   391  func (a *RemoteFetchArena) Close(ctx context.Context) error {
   392  	ctx = zlog.ContextWithValues(ctx,
   393  		"component", "libindex/fetchArena.Close",
   394  		"arena", a.root)
   395  	a.rc.Range(func(k, _ any) bool {
   396  		a.rc.Delete(k)
   397  		return true
   398  	})
   399  	return nil
   400  }
   401  
   402  // Realizer returns an indexer.Realizer.
   403  //
   404  // The concrete return type is [*FetchProxy].
   405  func (a *RemoteFetchArena) Realizer(_ context.Context) indexer.Realizer {
   406  	return &FetchProxy{a: a}
   407  }
   408  
   409  // FetchProxy tracks the files fetched for layers.
   410  type FetchProxy struct {
   411  	a       *RemoteFetchArena
   412  	cleanup []io.Closer
   413  }
   414  
   415  // Realize populates all the layers locally.
   416  //
   417  // Deprecated: This method proxies to [FetchProxy.RealizeDescriptions] via
   418  // copies and a (potentially expensive) comparison operation. Callers should use
   419  // [FetchProxy.RealizeDescriptions] if they already have the
   420  // [claircore.LayerDescription] constructed.
   421  func (p *FetchProxy) Realize(ctx context.Context, ls []*claircore.Layer) error {
   422  	ds := wart.LayersToDescriptions(ls)
   423  	ret, err := p.RealizeDescriptions(ctx, ds)
   424  	if err != nil {
   425  		return err
   426  	}
   427  	wart.CopyLayerPointers(ls, ret)
   428  	return nil
   429  }
   430  
   431  // RealizeDesciptions returns [claircore.Layer] structs populated according to
   432  // the passed slice of [claircore.LayerDescription].
   433  func (p *FetchProxy) RealizeDescriptions(ctx context.Context, descs []claircore.LayerDescription) ([]claircore.Layer, error) {
   434  	ctx = zlog.ContextWithValues(ctx,
   435  		"component", "libindex/FetchProxy.RealizeDescriptions")
   436  	ctx, span := tracer.Start(ctx, "RealizeDescriptions")
   437  	defer span.End()
   438  	g, ctx := errgroup.WithContext(ctx)
   439  	g.SetLimit(runtime.GOMAXPROCS(0))
   440  	ls := make([]claircore.Layer, len(descs))
   441  	cleanup := make([]io.Closer, len(descs))
   442  
   443  	for i := range descs {
   444  		g.Go(p.a.fetchInto(ctx, &ls[i], &cleanup[i], &descs[i]))
   445  	}
   446  
   447  	if e := g.Wait(); e != nil {
   448  		err := fmt.Errorf("fetcher: encountered errors: %w", e)
   449  		cl := make([]error, 0, len(p.cleanup))
   450  		for _, c := range cleanup {
   451  			if c != nil {
   452  				cl = append(cl, c.Close())
   453  			}
   454  		}
   455  		if cl := errors.Join(cl...); cl != nil {
   456  			err = fmt.Errorf("%w; while cleaning up: %w", err, cl)
   457  		}
   458  		span.RecordError(err)
   459  		span.SetStatus(codes.Error, "RealizeDescriptions errored")
   460  		return nil, err
   461  	}
   462  	p.cleanup = cleanup
   463  	span.SetStatus(codes.Ok, "")
   464  	return ls, nil
   465  }
   466  
   467  // Close marks all the files backing any returned [claircore.Layer] as unused.
   468  //
   469  // This method may delete the backing files, necessitating them being fetched by
   470  // a subsequent call to [FetchProxy.RealizeDescriptions].
   471  func (p *FetchProxy) Close() error {
   472  	errs := make([]error, len(p.cleanup))
   473  	for i, c := range p.cleanup {
   474  		if c != nil {
   475  			errs[i] = c.Close()
   476  		}
   477  	}
   478  	return errors.Join(errs...)
   479  }