Skip to content

Commit 1196816

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 896dfc3 commit 1196816

3 files changed

Lines changed: 352 additions & 133 deletions

File tree

internal/httpreader/reader.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,14 @@ var (
8888

8989
// ReadAt implements [io.ReaderAt].
9090
func (r *Reader) ReadAt(b []byte, off int64) (int, error) {
91-
if len(b) == 0 {
91+
switch {
92+
case len(b) == 0:
93+
// Nothing to read.
9294
return 0, nil
95+
case off >= r.size:
96+
// Impossible to read anything, so copy [os.File] behavior.
97+
// Use ≥ because it's comparing an index and a length.
98+
return 0, io.EOF
9399
}
94100

95101
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, r.res, nil)
@@ -105,6 +111,8 @@ func (r *Reader) ReadAt(b []byte, off int64) (int, error) {
105111
switch res.StatusCode {
106112
case http.StatusPartialContent: // OK
107113
case http.StatusOK:
114+
// NOTE(hank) I've seen servers in the wild that just send the file if
115+
// the range starts at 0, even if they support range requests.
108116
if off != 0 {
109117
return 0, errWithStatus(srvBotch(req.Header.Get(`range`)), res.StatusCode)
110118
}

libindex/fetcher.go

Lines changed: 158 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,24 @@ import (
99
"fmt"
1010
"io"
1111
"log/slog"
12+
"mime"
1213
"net/http"
1314
"net/url"
1415
"os"
16+
"regexp"
1517
"runtime"
1618
"strings"
1719

1820
"go.opentelemetry.io/otel/attribute"
1921
"go.opentelemetry.io/otel/codes"
22+
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
2023
"go.opentelemetry.io/otel/trace"
2124
"golang.org/x/sync/errgroup"
2225

2326
"github.com/quay/claircore"
2427
"github.com/quay/claircore/indexer"
2528
"github.com/quay/claircore/internal/cache"
29+
"github.com/quay/claircore/internal/httpreader"
2630
"github.com/quay/claircore/internal/httputil"
2731
"github.com/quay/claircore/internal/wart"
2832
"github.com/quay/claircore/internal/zreader"
@@ -125,6 +129,43 @@ func (a *RemoteFetchArena) fetchInto(ctx context.Context, l *claircore.Layer, cl
125129
span.End()
126130
}()
127131

132+
var d details
133+
d, err = a.inspect(ctx, desc)
134+
if err != nil {
135+
return err
136+
}
137+
138+
HTTPReader:
139+
switch { // Switch for a single case to be able to break.
140+
case d.Uncompressed:
141+
var opts []httpreader.Option
142+
if d.ContentLength > 0 {
143+
opts = append(opts, httpreader.WithSize(d.ContentLength))
144+
}
145+
if len(desc.Headers) != 0 {
146+
opts = append(opts, httpreader.WithHeaders(desc.Headers))
147+
}
148+
var rd *httpreader.Reader
149+
rd, err = httpreader.New(ctx, a.wc, desc.URI, opts...)
150+
switch {
151+
case err == nil:
152+
if err = l.Init(ctx, desc, rd); err != nil {
153+
return err
154+
}
155+
*cl = closeFunc(func() (err error) {
156+
err = errors.Join(l.Close(), rd.Close())
157+
return err
158+
})
159+
a.logger(desc).DebugContext(ctx, "using httpreader")
160+
return nil
161+
case errors.Is(err, errors.ErrUnsupported):
162+
err = nil
163+
break HTTPReader
164+
default:
165+
return err
166+
}
167+
}
168+
128169
// NB This is not closed on purpose. The [io.Closer] populated by this
129170
// function holds the pointer until that function is cleaned up. Once
130171
// nothing has a copy of this [*os.File], the runtime will run all the
@@ -135,19 +176,20 @@ func (a *RemoteFetchArena) fetchInto(ctx context.Context, l *claircore.Layer, cl
135176
var spool *os.File
136177
spool, err = a.files.Get(ctx, key, func(ctx context.Context, _ string) (*os.File, error) {
137178
cacheHit = false
138-
return a.fetchFileForCache(ctx, desc)
179+
return a.fetchFileForCache(ctx, desc, d)
139180
})
140181
if err != nil {
141182
return err
142183
}
184+
var f *os.File
143185
// This is an owned, independent descriptor for the passed [*os.File].
144-
f, err := reopen(a.root, spool)
186+
f, err = reopen(a.root, spool)
145187
if err != nil {
146188
return err
147189
}
148190

149191
// If this succeeds, "f" is now owned by "l"
150-
if err := l.Init(ctx, desc, f); err != nil {
192+
if err = l.Init(ctx, desc, f); err != nil {
151193
return errors.Join(err, f.Close())
152194
}
153195
*cl = closeFunc(func() (err error) {
@@ -173,12 +215,106 @@ func (f closeFunc) Close() error {
173215
return f()
174216
}
175217

218+
// Inspect makes a request to the layer's URI and examines the response for
219+
// useful information.
220+
func (a *RemoteFetchArena) inspect(ctx context.Context, desc *claircore.LayerDescription) (d details, err error) {
221+
ctx, span := tracer.Start(ctx, "RemoteFetchArena.inspect")
222+
defer func() {
223+
a.logger(desc).DebugContext(ctx, "inspected resource", "ok", err == nil, "details", &d)
224+
span.RecordError(err)
225+
span.End()
226+
}()
227+
span.SetStatus(codes.Error, "")
228+
229+
var req *http.Request
230+
var res *http.Response
231+
req, err = http.NewRequestWithContext(ctx, http.MethodGet, desc.URI, nil)
232+
if err != nil {
233+
return d, fmt.Errorf("fetcher: failed to construct request: %w", err)
234+
}
235+
req.Header = http.Header(desc.Headers).Clone()
236+
req.Header.Set(`claircore-reason`, `inspect`)
237+
req.Header.Set(`range`, `bytes=0-15`)
238+
res, err = a.wc.Do(req)
239+
if err != nil {
240+
return d, fmt.Errorf("fetcher: request failed: %w", err)
241+
}
242+
err = httputil.CheckResponse(res, http.StatusOK, http.StatusPartialContent)
243+
if err != nil {
244+
return d, fmt.Errorf("fetcher: %w", err)
245+
}
246+
head := make([]byte, 16)
247+
_, err = io.ReadFull(res.Body, head)
248+
_ = res.Body.Close()
249+
if err != nil {
250+
return d, fmt.Errorf("fetcher: unexpected read: %w", err)
251+
}
252+
253+
const (
254+
ctKey = `content-type`
255+
arKey = `accept-ranges`
256+
)
257+
ct, _, err := mime.ParseMediaType(res.Header.Get(ctKey))
258+
if err != nil {
259+
return d, fmt.Errorf("fetcher: %w", err)
260+
}
261+
span.SetAttributes(
262+
semconv.HTTPResponseStatusCode(res.StatusCode),
263+
semconv.HTTPResponseBodySize(int(res.ContentLength)),
264+
semconv.HTTPResponseHeader(ctKey, ct),
265+
semconv.HTTPResponseHeader(arKey, res.Header.Get(arKey)),
266+
)
267+
if isOctetStream(ct) && zreader.DetectCompression(head) == zreader.KindNone {
268+
ct = `application/x-tar`
269+
}
270+
switch res.StatusCode {
271+
case http.StatusOK:
272+
d.ContentLength = res.ContentLength
273+
case http.StatusPartialContent:
274+
var cr httpreader.ContentRange
275+
if err := cr.Parse(res.Header.Get(`content-range`)); err == nil {
276+
d.ContentLength = cr.Length
277+
}
278+
}
279+
d.Uncompressed = ct == "application/x-tar" || strings.HasSuffix(ct, ".tar")
280+
d.RangeOK = res.Header.Get(arKey) == `bytes` || res.StatusCode == http.StatusPartialContent
281+
282+
span.SetStatus(codes.Ok, "")
283+
return d, nil
284+
}
285+
286+
var _ slog.LogValuer = (*details)(nil)
287+
288+
// Details is the useful information reported by [RemoteFetchArena.inspect].
289+
type details struct {
290+
// The content length, if known.
291+
ContentLength int64
292+
// The resource reports to be known non-compressed contents.
293+
Uncompressed bool
294+
// The server reports supporting "bytes" via the "Accept-Ranges" header.
295+
RangeOK bool
296+
}
297+
298+
// LogValue implements [slog.LogValuer].
299+
func (d *details) LogValue() slog.Value {
300+
return slog.GroupValue(
301+
slog.Int64("content-length", d.ContentLength),
302+
slog.Bool("uncompressed", d.Uncompressed),
303+
slog.Bool("range-ok", d.RangeOK),
304+
)
305+
}
306+
307+
// Logger returns a [*slog.Logger] with predefined attributes.
308+
func (a *RemoteFetchArena) logger(desc *claircore.LayerDescription) *slog.Logger {
309+
return slog.With("arena", a.root.Name(), "layer", desc.Digest, "uri", desc.URI)
310+
}
311+
176312
// FetchFileForCache is the inner function used inside the [cache.Live].
177313
//
178314
// Because we know we're the only concurrent call that's dealing with this blob,
179315
// 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)
316+
func (a *RemoteFetchArena) fetchFileForCache(ctx context.Context, desc *claircore.LayerDescription, _ details) (*os.File, error) {
317+
log := a.logger(desc)
182318
ctx, span := tracer.Start(ctx, "RemoteFetchArena.fetchFileForCache")
183319
defer span.End()
184320
span.SetStatus(codes.Error, "")
@@ -210,6 +346,7 @@ func (a *RemoteFetchArena) fetchFileForCache(ctx context.Context, desc *claircor
210346
URL: url,
211347
Header: http.Header(desc.Headers).Clone(),
212348
}).WithContext(ctx)
349+
req.Header.Set(`claircore-reason`, `fetch`)
213350
resp, err := a.wc.Do(req)
214351
if err != nil {
215352
return nil, fmt.Errorf("fetcher: request failed: %w", err)
@@ -235,10 +372,13 @@ func (a *RemoteFetchArena) fetchFileForCache(ctx context.Context, desc *claircor
235372
}
236373
defer zr.Close()
237374
// Look at the content-type and optionally fix it up.
238-
ct := resp.Header.Get("content-type")
375+
ct, _, err := mime.ParseMediaType(resp.Header.Get("content-type"))
376+
if err != nil {
377+
return nil, fmt.Errorf("fetcher: %w", err)
378+
}
239379
log.DebugContext(ctx, "reported content-type", "content-type", ct)
240380
span.SetAttributes(payloadType(ct), payloadCompression(kind))
241-
if ct == "" || ct == "text/plain" || ct == "binary/octet-stream" || ct == "application/octet-stream" {
381+
if ct == "" || ct == "text/plain" || isOctetStream(ct) {
242382
switch kind {
243383
case zreader.KindGzip:
244384
ct = "application/gzip"
@@ -420,3 +560,14 @@ func (p *FetchProxy) Close() error {
420560
}
421561
return errors.Join(errs...)
422562
}
563+
564+
// IsOctetStream tests if the incoming media type string is an "octet-stream"
565+
// type.
566+
//
567+
// Reports false for strings that are not of the form "<type>/<subtype>". For
568+
// interoperability purposes, the nonstandard "binary" type is accepted.
569+
func isOctetStream(ct string) bool {
570+
return reOctetStream.MatchString(ct)
571+
}
572+
573+
var reOctetStream = regexp.MustCompile(`^(application|binary)/octet-stream$`)

0 commit comments

Comments
 (0)