Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions pkg/remote/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -157,19 +158,28 @@ 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)
if err != nil {
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
}
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 25 additions & 6 deletions pkg/remote/oci_cache_entry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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))
Expand All @@ -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) {},
Expand Down Expand Up @@ -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)
}