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 }