package cache import ( "errors" "fmt" "io" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" "edge-infra.dev/pkg/f8n/warehouse/oci" "edge-infra.dev/pkg/f8n/warehouse/oci/cache/providers/memory" "edge-infra.dev/pkg/f8n/warehouse/oci/layer" whremote "edge-infra.dev/pkg/f8n/warehouse/oci/remote" ) // [Cache] is the interface for a pull-through cache for Warehouse artifacts with support // for in-memory and disk caching layers. type Cache interface { Get(name.Reference, ...GetOption) (oci.Artifact, error) Exists(v1.Hash) bool Add(oci.Artifact) (oci.Artifact, error) Len() int } // [LazyCache] is a cache for Warehouse artifacts with support for lazily evaluating OCI Artifacts // by falling back from in-memory to network access type LazyCache struct { tags map[string]v1.Hash puller *remote.Puller memory *memory.ArcCache recorder Recorder } // Recorder defines callbacks that are triggered by the cache. // // NOTE: This allows clean integration with metrics collectors without taking on // the massive dependency graph of the Prometheus package. type Recorder interface { RecordGet(hit bool, provider string, objType string) // TODO consider recording evictions } var ( // ErrNotFound error returned when the requested content does not exist in the cache. ErrNotFound = errors.New("content not found") // ErrInterfaceConversion ErrInterfaceConversion = errors.New("failed to convert cached object to requested type") ) // New return a new instance of the Warehouse [Cache]. func New(opts ...Option) (*LazyCache, error) { var ( mcache *memory.ArcCache options = makeOptions(opts...) ) if options.memoryCacheSize > 0 { memorycache, err := memory.New(options.memoryCacheSize) if err != nil { return nil, err } mcache = memorycache } return &LazyCache{ tags: map[string]v1.Hash{}, memory: mcache, recorder: options.recorder, puller: options.puller, }, nil } // Get attempts to retrieve an artifact by reference from the configured caches, // fetching from the remote registry if not found. func (w *LazyCache) Get(ref name.Reference, opts ...GetOption) (oci.Artifact, error) { options := makeGetOptions(opts...) if w.puller != nil { options.remoteOpts = append(options.remoteOpts, remote.Reuse(w.puller)) } h, err := w.resolveRef(ref, options.resolveTag, options.remoteOpts...) if err != nil { return nil, err } if a, err := w.getArtifact(h); err == nil { return a, nil } else if !errors.Is(err, ErrNotFound) { return nil, fmt.Errorf("error getting cached artifact: %v", err) } return w.getRemote(ref, options.remoteOpts...) } // Exists checks if the artifact with the given hash exists in the cache func (w *LazyCache) Exists(h v1.Hash) bool { return w.memory.Exists(h) } // Add inserts an artifact into the [Cache] and returns a version // wrapped with a caching accessor. Further operations should use the // returned Artifact func (w *LazyCache) Add(a oci.Artifact) (oci.Artifact, error) { h, err := a.Digest() if err != nil { return nil, err } var cached oci.Artifact switch a := a.(type) { case oci.Unwrapper: return w.Add(a.Unwrap()) case v1.ImageIndex: cached = ImageIndex(a, w) case v1.Image: cached = Image(a, w) default: return nil, oci.ErrInvalidArtifact } w.memory.Add(h, cached) return cached, nil } // Len returns the number of artifacts in the cache. func (w *LazyCache) Len() int { if w.memory != nil { return w.memory.Len() } return 0 } // resolveRef converts ref into a digest. If ref contains a digest, it is returned. If ref contains a tag, // the digest is either fetched from the registry or retrieved from a local cache, depending on fetchTag. // The tag will always be fetched if there is no cached result func (w *LazyCache) resolveRef(ref name.Reference, fetchTag bool, remoteOpts ...remote.Option) (v1.Hash, error) { var h v1.Hash switch r := ref.(type) { case name.Digest: hash, err := v1.NewHash(r.DigestStr()) if err != nil { return v1.Hash{}, err } h = hash case name.Tag: resolved, ok := w.tags[r.String()] if fetchTag || !ok { desc, err := remote.Head(r, remoteOpts...) if err != nil { return v1.Hash{}, err } h = desc.Digest } else { h = resolved } w.tags[r.String()] = h default: return v1.Hash{}, fmt.Errorf("invalid ref. ref must either be a Digest or Tag, but was %T", ref) } return h, nil } // getRemote fetches a reference and stores it in the cache func (w *LazyCache) getRemote(ref name.Reference, opts ...remote.Option) (oci.Artifact, error) { a, err := whremote.Get(ref, opts...) if err != nil { return nil, err } return w.Add(a) } // recordGet increments cache get metric via the record using the given dimensions // nolint:unparam // memory always passed here, kept open for compatibility with existing metrics func (w *LazyCache) recordGet(hit bool, provider string, objType string) { if w.recorder != nil { w.recorder.RecordGet(hit, provider, objType) } } func (w *LazyCache) getArtifact(h v1.Hash) (oci.Artifact, error) { var obj any var hit bool if w.memory != nil { obj, hit = w.memory.Get(h) if !hit { w.recordGet(hit, "memory", "artifact") return nil, ErrNotFound } } var artifact oci.Artifact switch a := obj.(type) { case oci.Unwrapper: artifact = a.Unwrap() case v1.ImageIndex: artifact = a case v1.Image: artifact = a default: return nil, ErrInterfaceConversion } w.recordGet(hit, "memory", "artifact") return artifact, nil } func (w *LazyCache) putBlob(h v1.Hash, blob []byte) { w.memory.Add(h, blob) } func (w *LazyCache) getLayer(h v1.Hash) (layer.Layer, error) { var obj any var hit bool if w.memory != nil { obj, hit = w.memory.Get(h) if !hit { w.recordGet(hit, "memory", "layer") return nil, ErrNotFound } } l, ok := obj.(layer.Layer) if !ok { return nil, ErrInterfaceConversion } w.recordGet(hit, "memory", "layer") return l, nil } func (w *LazyCache) putLayer(l layer.Layer) (layer.Layer, error) { h, err := l.Digest() if err != nil { return nil, err } rc, err := l.Uncompressed() if err != nil { return nil, err } data, err := io.ReadAll(rc) if err != nil { return nil, err } cached := Layer(l, w, data) w.memory.Add(h, cached) return cached, nil } func (w *LazyCache) getImage(h v1.Hash) (v1.Image, error) { var obj any var hit bool if w.memory != nil { obj, hit = w.memory.Get(h) if !hit { w.recordGet(hit, "memory", "image") return nil, ErrNotFound } } img, ok := obj.(v1.Image) if !ok { return nil, ErrInterfaceConversion } w.recordGet(hit, "memory", "image") return img, nil } func (w *LazyCache) putImage(img v1.Image) (v1.Image, error) { h, err := img.Digest() if err != nil { return nil, err } cached := Image(img, w) w.memory.Add(h, cached) return cached, nil } func (w *LazyCache) getIndex(h v1.Hash) (v1.ImageIndex, error) { var obj any var hit bool if w.memory != nil { obj, hit = w.memory.Get(h) if !hit { w.recordGet(hit, "memory", "index") return nil, ErrNotFound } } ii, ok := obj.(v1.ImageIndex) if !ok { return nil, ErrInterfaceConversion } w.recordGet(hit, "memory", "index") return ii, nil } func (w *LazyCache) putIndex(ii v1.ImageIndex) (v1.ImageIndex, error) { h, err := ii.Digest() if err != nil { return nil, err } cached := ImageIndex(ii, w) w.memory.Add(h, cached) return cached, nil }