Skip to content

Commit ba37694

Browse files
committed
libindex: wire in httpreader.Reader
This handles the "easy" case of simply proxying reads for uncompressed tar archives to range requests. Future improvements would move the "spooling" out of this package and into the `fs.FS` implementation. Signed-off-by: Hank Donnay <hdonnay@redhat.com> Change-Id: I9d200dd841954054df0b187b9fd160a56a6a6964
1 parent 4c7ba57 commit ba37694

2 files changed

Lines changed: 375 additions & 152 deletions

File tree

libindex/fetcher.go

Lines changed: 190 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,31 @@ package libindex
33
import (
44
"bufio"
55
"bytes"
6+
"cmp"
67
"context"
78
"encoding/hex"
89
"errors"
910
"fmt"
1011
"io"
1112
"log/slog"
13+
"mime"
1214
"net/http"
1315
"net/url"
1416
"os"
17+
"regexp"
1518
"runtime"
1619
"strings"
1720

1821
"go.opentelemetry.io/otel/attribute"
1922
"go.opentelemetry.io/otel/codes"
23+
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
2024
"go.opentelemetry.io/otel/trace"
2125
"golang.org/x/sync/errgroup"
2226

2327
"github.com/quay/claircore"
2428
"github.com/quay/claircore/indexer"
2529
"github.com/quay/claircore/internal/cache"
30+
"github.com/quay/claircore/internal/httpreader"
2631
"github.com/quay/claircore/internal/httputil"
2732
"github.com/quay/claircore/internal/wart"
2833
"github.com/quay/claircore/internal/zreader"
@@ -131,40 +136,85 @@ func (a *RemoteFetchArena) fetchInto(ctx context.Context, l *claircore.Layer, cl
131136
// cleanup logic associated with the pointer.
132137
//
133138
// Every new [*claircore.Layer] gets its own file descriptor via the
134-
// [reopen] helper.
139+
// [reopen] helper, or eschews using a file at all.
135140
var spool *os.File
141+
var forceFetch bool
142+
var d details
143+
Fetch:
136144
spool, err = a.files.Get(ctx, key, func(ctx context.Context, _ string) (*os.File, error) {
137145
cacheHit = false
138-
return a.fetchFileForCache(ctx, desc)
146+
d, err = a.inspect(ctx, desc)
147+
if err != nil {
148+
return nil, err
149+
}
150+
if !forceFetch && d.Uncompressed && d.RangeOK {
151+
return nil, errUseHTTPReader
152+
}
153+
return a.fetchFileForCache(ctx, desc, d)
139154
})
140-
if err != nil {
141-
return err
142-
}
143-
// This is an owned, independent descriptor for the passed [*os.File].
144-
f, err := reopen(a.root, spool)
145-
if err != nil {
146-
return err
147-
}
155+
switch err {
156+
case nil: // Reopen the returned [*os.File].
157+
var f *os.File
158+
// This is an owned, independent descriptor for the passed [*os.File].
159+
f, err = reopen(a.root, spool)
160+
if err != nil {
161+
return err
162+
}
148163

149-
// If this succeeds, "f" is now owned by "l"
150-
if err := l.Init(ctx, desc, f); err != nil {
151-
return errors.Join(err, f.Close())
152-
}
153-
*cl = closeFunc(func() (err error) {
154-
err = errors.Join(l.Close(), f.Close())
155-
// Using this KeepAlive keeps the cached file descriptor live until
156-
// all users of the blob have cleaned up. This should be after "f"
157-
// is closed so that the cached-owned file descriptor outlives any
158-
// reopened copies. There's no explicit association of these file
159-
// descriptors, it's all kernel-side book-keeping.
160-
runtime.KeepAlive(spool)
164+
// If this succeeds, "f" is now owned by "l"
165+
if err = l.Init(ctx, desc, f); err != nil {
166+
return errors.Join(err, f.Close())
167+
}
168+
*cl = closeFunc(func() (err error) {
169+
err = errors.Join(l.Close(), f.Close())
170+
// Using this KeepAlive keeps the cached file descriptor live until
171+
// all users of the blob have cleaned up. This should be after "f"
172+
// is closed so that the cached-owned file descriptor outlives any
173+
// reopened copies. There's no explicit association of these file
174+
// descriptors, it's all kernel-side book-keeping.
175+
runtime.KeepAlive(spool)
176+
return err
177+
})
178+
case errUseHTTPReader: // Try to construct an [httpreader.Reader].
179+
var opts []httpreader.Option
180+
if d.ContentLength > 0 {
181+
opts = append(opts, httpreader.WithSize(d.ContentLength))
182+
}
183+
if len(desc.Headers) != 0 {
184+
opts = append(opts, httpreader.WithHeaders(desc.Headers))
185+
}
186+
var rd *httpreader.Reader
187+
rd, err = httpreader.New(ctx, a.wc, desc.URI, opts...)
188+
switch {
189+
case err == nil:
190+
if err = l.Init(ctx, desc, rd); err != nil {
191+
return errors.Join(err, rd.Close())
192+
}
193+
*cl = closeFunc(func() (err error) {
194+
err = errors.Join(l.Close(), rd.Close())
195+
return err
196+
})
197+
a.logger(desc).DebugContext(ctx, "using httpreader")
198+
return nil
199+
case errors.Is(err, errors.ErrUnsupported):
200+
err = nil
201+
forceFetch = true
202+
goto Fetch
203+
default:
204+
return err
205+
}
206+
default:
161207
return err
162-
})
208+
}
163209

164210
return nil
165211
}
166212
}
167213

214+
// ErrUseHTTPReader is a sentinel value to signal that the calling code should
215+
// construct an [httpreader.Reader] rather than reopen the [os.File].
216+
var errUseHTTPReader = errors.New("use htttpreader.Reader")
217+
168218
// CloseFunc is an adapter in the vein of [http.HandlerFunc].
169219
type closeFunc func() error
170220

@@ -173,12 +223,110 @@ func (f closeFunc) Close() error {
173223
return f()
174224
}
175225

226+
// Inspect makes a request to the layer's URI and examines the response for
227+
// useful information.
228+
func (a *RemoteFetchArena) inspect(ctx context.Context, desc *claircore.LayerDescription) (d details, err error) {
229+
ctx, span := tracer.Start(ctx, "RemoteFetchArena.inspect")
230+
defer func() {
231+
a.logger(desc).DebugContext(ctx, "inspected resource", "ok", err == nil, "details", &d)
232+
span.RecordError(err)
233+
span.End()
234+
}()
235+
span.SetStatus(codes.Error, "")
236+
237+
var req *http.Request
238+
var res *http.Response
239+
req, err = http.NewRequestWithContext(ctx, http.MethodGet, desc.URI, nil)
240+
if err != nil {
241+
return d, fmt.Errorf("fetcher: failed to construct request: %w", err)
242+
}
243+
req.Header = http.Header(desc.Headers).Clone()
244+
if req.Header == nil {
245+
req.Header = make(http.Header)
246+
}
247+
req.Header.Set(`claircore-reason`, `inspect`)
248+
req.Header.Set(`range`, `bytes=0-15`)
249+
res, err = a.wc.Do(req)
250+
if err != nil {
251+
return d, fmt.Errorf("fetcher: request failed: %w", err)
252+
}
253+
err = httputil.CheckResponse(res, http.StatusOK, http.StatusPartialContent)
254+
if err != nil {
255+
return d, fmt.Errorf("fetcher: %w", err)
256+
}
257+
head := make([]byte, 16)
258+
_, err = io.ReadFull(res.Body, head)
259+
_ = res.Body.Close()
260+
if err != nil {
261+
return d, fmt.Errorf("fetcher: unexpected read: %w", err)
262+
}
263+
264+
const (
265+
ctKey = `content-type`
266+
arKey = `accept-ranges`
267+
)
268+
ctVal := cmp.Or(res.Header.Get(ctKey), `application/octet-stream`)
269+
ct, _, err := mime.ParseMediaType(ctVal)
270+
if err != nil {
271+
return d, fmt.Errorf("fetcher: %w", err)
272+
}
273+
span.SetAttributes(
274+
semconv.HTTPResponseStatusCode(res.StatusCode),
275+
semconv.HTTPResponseBodySize(int(res.ContentLength)),
276+
semconv.HTTPResponseHeader(ctKey, ct),
277+
semconv.HTTPResponseHeader(arKey, res.Header.Get(arKey)),
278+
)
279+
if isOctetStream(ct) && zreader.DetectCompression(head) == zreader.KindNone {
280+
ct = `application/x-tar`
281+
}
282+
switch res.StatusCode {
283+
case http.StatusOK:
284+
d.ContentLength = res.ContentLength
285+
case http.StatusPartialContent:
286+
var cr httpreader.ContentRange
287+
if err := cr.Parse(res.Header.Get(`content-range`)); err == nil {
288+
d.ContentLength = cr.Length
289+
}
290+
}
291+
d.Uncompressed = ct == "application/x-tar" || strings.HasSuffix(ct, ".tar")
292+
d.RangeOK = res.Header.Get(arKey) == `bytes` || res.StatusCode == http.StatusPartialContent
293+
294+
span.SetStatus(codes.Ok, "")
295+
return d, nil
296+
}
297+
298+
var _ slog.LogValuer = (*details)(nil)
299+
300+
// Details is the useful information reported by [RemoteFetchArena.inspect].
301+
type details struct {
302+
// The content length, if known.
303+
ContentLength int64
304+
// The resource reports to be known non-compressed contents.
305+
Uncompressed bool
306+
// The server reports supporting "bytes" via the "Accept-Ranges" header.
307+
RangeOK bool
308+
}
309+
310+
// LogValue implements [slog.LogValuer].
311+
func (d *details) LogValue() slog.Value {
312+
return slog.GroupValue(
313+
slog.Int64("content-length", d.ContentLength),
314+
slog.Bool("uncompressed", d.Uncompressed),
315+
slog.Bool("range-ok", d.RangeOK),
316+
)
317+
}
318+
319+
// Logger returns a [*slog.Logger] with predefined attributes.
320+
func (a *RemoteFetchArena) logger(desc *claircore.LayerDescription) *slog.Logger {
321+
return slog.With("arena", a.root.Name(), "layer", desc.Digest, "uri", desc.URI)
322+
}
323+
176324
// FetchFileForCache is the inner function used inside the [cache.Live].
177325
//
178326
// Because we know we're the only concurrent call that's dealing with this blob,
179327
// we can be a bit more lax.
180-
func (a *RemoteFetchArena) fetchFileForCache(ctx context.Context, desc *claircore.LayerDescription) (*os.File, error) {
181-
log := slog.With("arena", a.root.Name(), "layer", desc.Digest, "uri", desc.URI)
328+
func (a *RemoteFetchArena) fetchFileForCache(ctx context.Context, desc *claircore.LayerDescription, _ details) (*os.File, error) {
329+
log := a.logger(desc)
182330
ctx, span := tracer.Start(ctx, "RemoteFetchArena.fetchFileForCache")
183331
defer span.End()
184332
span.SetStatus(codes.Error, "")
@@ -210,6 +358,7 @@ func (a *RemoteFetchArena) fetchFileForCache(ctx context.Context, desc *claircor
210358
URL: url,
211359
Header: http.Header(desc.Headers).Clone(),
212360
}).WithContext(ctx)
361+
req.Header.Set(`claircore-reason`, `fetch`)
213362
resp, err := a.wc.Do(req)
214363
if err != nil {
215364
return nil, fmt.Errorf("fetcher: request failed: %w", err)
@@ -235,10 +384,13 @@ func (a *RemoteFetchArena) fetchFileForCache(ctx context.Context, desc *claircor
235384
}
236385
defer zr.Close()
237386
// Look at the content-type and optionally fix it up.
238-
ct := resp.Header.Get("content-type")
387+
ct, _, err := mime.ParseMediaType(resp.Header.Get("content-type"))
388+
if err != nil {
389+
return nil, fmt.Errorf("fetcher: %w", err)
390+
}
239391
log.DebugContext(ctx, "reported content-type", "content-type", ct)
240392
span.SetAttributes(payloadType(ct), payloadCompression(kind))
241-
if ct == "" || ct == "text/plain" || ct == "binary/octet-stream" || ct == "application/octet-stream" {
393+
if ct == "" || ct == "text/plain" || isOctetStream(ct) {
242394
switch kind {
243395
case zreader.KindGzip:
244396
ct = "application/gzip"
@@ -420,3 +572,14 @@ func (p *FetchProxy) Close() error {
420572
}
421573
return errors.Join(errs...)
422574
}
575+
576+
// IsOctetStream tests if the incoming media type string is an "octet-stream"
577+
// type.
578+
//
579+
// Reports false for strings that are not of the form "<type>/<subtype>". For
580+
// interoperability purposes, the nonstandard "binary" type is accepted.
581+
func isOctetStream(ct string) bool {
582+
return reOctetStream.MatchString(ct)
583+
}
584+
585+
var reOctetStream = regexp.MustCompile(`^(application|binary)/octet-stream$`)

0 commit comments

Comments
 (0)