From dd4b483c03693152d98dc4bc6731f47cbe991d88 Mon Sep 17 00:00:00 2001 From: Jeff Carter Date: Tue, 14 Jul 2026 17:23:18 -0400 Subject: [PATCH] Add oci.Interface implementation that allows for configuring multiple oci.Interface implementations behind a selector function. Signed-off-by: Jeff Carter --- ocifilter/selector.go | 121 ++++++++++++++++++++++++++++++++++++ ocifilter/selector_test.go | 122 +++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 ocifilter/selector.go create mode 100644 ocifilter/selector_test.go diff --git a/ocifilter/selector.go b/ocifilter/selector.go new file mode 100644 index 0000000..cf98e63 --- /dev/null +++ b/ocifilter/selector.go @@ -0,0 +1,121 @@ +// Copyright 2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocifilter + +import ( + "context" + "io" + "iter" + + "github.com/docker/oci" +) + +// Selector returns a registry that delegates each operation to the registry +// returned by selectRegistry. The selector is passed the repository name for +// the operation, and that name is passed unchanged to the selected registry. +// The selector may be called concurrently and must be safe for concurrent use. +// +// For [oci.Writer.MountBlob], the registry is selected using the destination +// repository, toRepo; both fromRepo and toRepo are then passed unchanged to +// that registry. Whether a mount across repositories with different storage +// backends succeeds is determined by the selected registry. +// +// [oci.Extension.Repositories] returns an error wrapping [oci.ErrUnsupported], +// because it has no repository name with which to select a registry. +// +// Selector does not validate selectRegistry. It panics when an operation is +// attempted if selectRegistry is nil or returns a nil registry. +func Selector(selectRegistry func(repo string) oci.Interface) oci.Interface { + return &selectorRegistry{ + selectRegistry: selectRegistry, + } +} + +type selectorRegistry struct { + // Embed Funcs so that methods added to oci.Interface in the future default + // to ErrUnsupported until routing semantics are explicitly implemented. + *oci.Funcs + selectRegistry func(repo string) oci.Interface +} + +var _ oci.Interface = (*selectorRegistry)(nil) + +func (r *selectorRegistry) GetBlob(ctx context.Context, repo string, digest oci.Digest) (oci.BlobReader, error) { + return r.selectRegistry(repo).GetBlob(ctx, repo, digest) +} + +func (r *selectorRegistry) GetBlobRange(ctx context.Context, repo string, digest oci.Digest, offset0, offset1 int64) (oci.BlobReader, error) { + return r.selectRegistry(repo).GetBlobRange(ctx, repo, digest, offset0, offset1) +} + +func (r *selectorRegistry) GetManifest(ctx context.Context, repo string, digest oci.Digest) (oci.BlobReader, error) { + return r.selectRegistry(repo).GetManifest(ctx, repo, digest) +} + +func (r *selectorRegistry) GetTag(ctx context.Context, repo string, tagName string) (oci.BlobReader, error) { + return r.selectRegistry(repo).GetTag(ctx, repo, tagName) +} + +func (r *selectorRegistry) ResolveBlob(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, error) { + return r.selectRegistry(repo).ResolveBlob(ctx, repo, digest) +} + +func (r *selectorRegistry) ResolveManifest(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, error) { + return r.selectRegistry(repo).ResolveManifest(ctx, repo, digest) +} + +func (r *selectorRegistry) ResolveTag(ctx context.Context, repo string, tagName string) (oci.Descriptor, error) { + return r.selectRegistry(repo).ResolveTag(ctx, repo, tagName) +} + +func (r *selectorRegistry) PushBlob(ctx context.Context, repo string, desc oci.Descriptor, rd io.Reader) (oci.Descriptor, error) { + return r.selectRegistry(repo).PushBlob(ctx, repo, desc, rd) +} + +func (r *selectorRegistry) PushBlobChunked(ctx context.Context, repo string, chunkSize int) (oci.BlobWriter, error) { + return r.selectRegistry(repo).PushBlobChunked(ctx, repo, chunkSize) +} + +func (r *selectorRegistry) PushBlobChunkedResume(ctx context.Context, repo, id string, offset int64, chunkSize int) (oci.BlobWriter, error) { + return r.selectRegistry(repo).PushBlobChunkedResume(ctx, repo, id, offset, chunkSize) +} + +func (r *selectorRegistry) MountBlob(ctx context.Context, fromRepo, toRepo string, digest oci.Digest) (oci.Descriptor, error) { + return r.selectRegistry(toRepo).MountBlob(ctx, fromRepo, toRepo, digest) +} + +func (r *selectorRegistry) PushManifest(ctx context.Context, repo string, contents []byte, mediaType string, params *oci.PushManifestParameters) (oci.Descriptor, error) { + return r.selectRegistry(repo).PushManifest(ctx, repo, contents, mediaType, params) +} + +func (r *selectorRegistry) DeleteBlob(ctx context.Context, repo string, digest oci.Digest) error { + return r.selectRegistry(repo).DeleteBlob(ctx, repo, digest) +} + +func (r *selectorRegistry) DeleteManifest(ctx context.Context, repo string, digest oci.Digest) error { + return r.selectRegistry(repo).DeleteManifest(ctx, repo, digest) +} + +func (r *selectorRegistry) DeleteTag(ctx context.Context, repo string, name string) error { + return r.selectRegistry(repo).DeleteTag(ctx, repo, name) +} + +func (r *selectorRegistry) Tags(ctx context.Context, repo string, params *oci.TagsParameters) iter.Seq2[string, error] { + return r.selectRegistry(repo).Tags(ctx, repo, params) +} + +func (r *selectorRegistry) Referrers(ctx context.Context, repo string, digest oci.Digest, params *oci.ReferrersParameters) iter.Seq2[oci.Descriptor, error] { + return r.selectRegistry(repo).Referrers(ctx, repo, digest, params) +} diff --git a/ocifilter/selector_test.go b/ocifilter/selector_test.go new file mode 100644 index 0000000..8f89179 --- /dev/null +++ b/ocifilter/selector_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ocifilter + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/docker/oci" + "github.com/stretchr/testify/require" +) + +func ExampleSelector() { + local := &oci.Funcs{ + DeleteTag_: func(_ context.Context, repo, tag string) error { + fmt.Printf("local: %s:%s\n", repo, tag) + return nil + }, + } + remote := &oci.Funcs{ + DeleteTag_: func(_ context.Context, repo, tag string) error { + fmt.Printf("remote: %s:%s\n", repo, tag) + return nil + }, + } + r := Selector(func(repo string) oci.Interface { + namespace, _, _ := strings.Cut(repo, "/") + if namespace == "local" { + return local + } + return remote + }) + + _ = r.DeleteTag(context.Background(), "local/example", "latest") + _ = r.DeleteTag(context.Background(), "shared/example", "latest") + + // Output: + // local: local/example:latest + // remote: shared/example:latest +} + +func TestSelector(t *testing.T) { + ctx := context.Background() + var calls []string + r0 := &oci.Funcs{ + ResolveTag_: func(_ context.Context, repo, tag string) (oci.Descriptor, error) { + calls = append(calls, "r0:"+repo+":"+tag) + return oci.Descriptor{Size: 1}, nil + }, + } + r1 := &oci.Funcs{ + ResolveTag_: func(_ context.Context, repo, tag string) (oci.Descriptor, error) { + calls = append(calls, "r1:"+repo+":"+tag) + return oci.Descriptor{Size: 2}, nil + }, + } + r := Selector(func(repo string) oci.Interface { + if repo == "local/foo" { + return r0 + } + return r1 + }) + + desc, err := r.ResolveTag(ctx, "local/foo", "latest") + require.NoError(t, err) + require.Equal(t, int64(1), desc.Size) + + desc, err = r.ResolveTag(ctx, "other/foo", "latest") + require.NoError(t, err) + require.Equal(t, int64(2), desc.Size) + require.Equal(t, []string{ + "r0:local/foo:latest", + "r1:other/foo:latest", + }, calls) +} + +func TestSelectorMountUsesDestinationRepository(t *testing.T) { + ctx := context.Background() + var gotFrom, gotTo string + destination := &oci.Funcs{ + MountBlob_: func(_ context.Context, fromRepo, toRepo string, _ oci.Digest) (oci.Descriptor, error) { + gotFrom, gotTo = fromRepo, toRepo + return oci.Descriptor{Size: 42}, nil + }, + } + r := Selector(func(repo string) oci.Interface { + if repo == "destination/repo" { + return destination + } + return (*oci.Funcs)(nil) + }) + + desc, err := r.MountBlob(ctx, "source/repo", "destination/repo", "") + require.NoError(t, err) + require.Equal(t, int64(42), desc.Size) + require.Equal(t, "source/repo", gotFrom) + require.Equal(t, "destination/repo", gotTo) +} + +func TestSelectorRepositoriesUnsupported(t *testing.T) { + r := Selector(func(string) oci.Interface { + t.Fatal("selector unexpectedly called") + return nil + }) + + _, err := oci.All(r.Repositories(context.Background(), "")) + require.ErrorIs(t, err, oci.ErrUnsupported) +}