From fff71fce3f193368a8844dac9631f31c6b7c590b Mon Sep 17 00:00:00 2001 From: Tobias Schlatter Date: Thu, 5 Nov 2026 09:30:21 +0100 Subject: [PATCH] feat(oci): directly load from cache if possible Fixes #14460. If an oci artifact is sha-pinned and we have it in the cache, don't even talk to the registry. The primary use case is operational safety in case of registry failure: This allows to still work with a compose config that `includes: oci://` if it has been pulled before. This aligns the behavior with docker itself: images are kept locally, so it is not necessary for the registry to be up to, say, recreate an existing container with a modified environment. Signed-off-by: Tobias Schlatter --- pkg/remote/oci.go | 26 +++++++++++++++++++------ pkg/remote/oci_cache_entry_test.go | 31 ++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/pkg/remote/oci.go b/pkg/remote/oci.go index 1108dff1a0..b30270d7c1 100644 --- a/pkg/remote/oci.go +++ b/pkg/remote/oci.go @@ -33,6 +33,7 @@ import ( "github.com/containerd/containerd/v2/core/remotes" "github.com/distribution/reference" "github.com/docker/cli/cli/command" + "github.com/opencontainers/go-digest" spec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/docker/compose/v5/internal/desktop" @@ -157,6 +158,20 @@ func (g *ociRemoteLoader) pullComposeArtifact(ctx context.Context, path string) return "", err } + cache, err := cacheDir() + if err != nil { + return "", fmt.Errorf("initializing remote resource cache: %w", err) + } + + // if the ref has a digest eagerly look in the local cache. + // (still works if the registry is down) + if ref, ok := ref.(reference.Digested); ok { + local := localOCIPath(cache, ref.Digest()) + if cacheEntryIsComplete(local) { + return local, nil + } + } + resolver := oci.NewResolver(g.authProvider(), g.httpTransport(ctx), g.insecureRegistries...) descriptor, content, err := oci.Get(ctx, resolver, ref) @@ -164,12 +179,7 @@ func (g *ociRemoteLoader) pullComposeArtifact(ctx context.Context, path string) return "", fmt.Errorf("failed to pull OCI resource %q: %w", ref, err) } - cache, err := cacheDir() - if err != nil { - return "", fmt.Errorf("initializing remote resource cache: %w", err) - } - - local := filepath.Join(cache, descriptor.Digest.Hex()) + local := localOCIPath(cache, descriptor.Digest) if cacheEntryIsComplete(local) { return local, nil } @@ -330,6 +340,10 @@ func (g *ociRemoteLoader) pullComposeFiles(ctx context.Context, local string, ma return nil } +func localOCIPath(cache string, dig digest.Digest) string { + return filepath.Join(cache, dig.Hex()) +} + func writeComposeFile(layer spec.Descriptor, i int, local string, content []byte) error { file := "compose.yaml" if _, ok := layer.Annotations["com.docker.compose.extends"]; ok { diff --git a/pkg/remote/oci_cache_entry_test.go b/pkg/remote/oci_cache_entry_test.go index 188cd13e44..8ce027cb1b 100644 --- a/pkg/remote/oci_cache_entry_test.go +++ b/pkg/remote/oci_cache_entry_test.go @@ -61,7 +61,7 @@ func newCacheTestRegistry(t *testing.T) *cacheTestRegistry { } // serve starts the registry and returns its host, which the loader of the test must be told is insecure. -func (r *cacheTestRegistry) serve(t *testing.T) string { +func (r *cacheTestRegistry) serve(t *testing.T) (string, *httptest.Server) { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { switch { @@ -80,7 +80,7 @@ func (r *cacheTestRegistry) serve(t *testing.T) string { } })) t.Cleanup(server.Close) - return server.Listener.Addr().String() + return server.Listener.Addr().String(), server } func (r *cacheTestRegistry) ref(host string) string { @@ -108,7 +108,7 @@ func setCacheRoot(t *testing.T) string { // as complete, so that a directory which is not one is not taken for an entry. func TestOCILoaderCacheEntryIsComplete(t *testing.T) { registry := newCacheTestRegistry(t) - host := registry.serve(t) + host, _ := registry.serve(t) cache := setCacheRoot(t) file, err := newCacheTestLoader(host).Load(t.Context(), registry.ref(host)) @@ -139,7 +139,7 @@ func TestOCILoaderCacheEntryIsComplete(t *testing.T) { // entry written by a release which did not mark them looks like a complete one. func TestOCILoaderExtractsAgainWhatIsNotACompleteEntry(t *testing.T) { registry := newCacheTestRegistry(t) - host := registry.serve(t) + host, _ := registry.serve(t) for name, compose := range map[string]string{ "an interrupted extraction": "services:\n app:\n ima", @@ -171,7 +171,7 @@ func TestOCILoaderExtractsAgainWhatIsNotACompleteEntry(t *testing.T) { func TestOCILoaderFailedPullLeavesNothingBehind(t *testing.T) { registry := newCacheTestRegistry(t) registry.blob = []byte("services:\n app:\n image: alp1ne\n") // as long as the layer, not the same - host := registry.serve(t) + host, _ := registry.serve(t) cache := setCacheRoot(t) _, err := newCacheTestLoader(host).Load(t.Context(), registry.ref(host)) @@ -190,7 +190,7 @@ func TestOCILoaderFailedPullLeavesNothingBehind(t *testing.T) { // of the cache once the runs are over. func TestOCILoaderConcurrentRunsLeaveOneCompleteEntry(t *testing.T) { registry := newCacheTestRegistry(t) - host := registry.serve(t) + host, _ := registry.serve(t) for name, seed := range map[string]func(t *testing.T, entry string){ "no entry": func(*testing.T, string) {}, @@ -253,3 +253,22 @@ func TestMoveCacheEntryDoesNotRestoreADirectoryWhichIsNotAnEntry(t *testing.T) { assert.NilError(t, err) assert.Equal(t, len(entries), 0, "neither the directory nor anything aside is left") } + +// Ensure that we can load a sha-pinned oci artifact from the cache only. +// +// This is crucial for operational safety: If the registry is down, this still +// allows to do basic operations. +func TestOCILoaderNoRegistryIfCached(t *testing.T) { + registry := newCacheTestRegistry(t) + host, server := registry.serve(t) + setCacheRoot(t) + + _, err := newCacheTestLoader(host).Load(t.Context(), registry.ref(host)) + assert.NilError(t, err) + + server.Close() + + // Note: recreate a loader: we want to test on-disk cache, not in-memory cache. + _, err = newCacheTestLoader(host).Load(t.Context(), registry.ref(host)) + assert.NilError(t, err) +}