diff --git a/pkg/cli/push.go b/pkg/cli/push.go index bfb619abb6..b548d50fa6 100644 --- a/pkg/cli/push.go +++ b/pkg/cli/push.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "strings" "time" @@ -15,6 +16,7 @@ import ( "github.com/replicate/cog/pkg/global" "github.com/replicate/cog/pkg/http" "github.com/replicate/cog/pkg/image" + "github.com/replicate/cog/pkg/registry" "github.com/replicate/cog/pkg/util/console" ) @@ -77,10 +79,13 @@ func push(cmd *cobra.Command, args []string) error { replicatePrefix := fmt.Sprintf("%s/", global.ReplicateRegistryHost) if strings.HasPrefix(imageName, replicatePrefix) { - if err := docker.ManifestInspect(ctx, imageName); err != nil && strings.Contains(err.Error(), `"code":"NAME_UNKNOWN"`) { - err = fmt.Errorf("Unable to find Replicate existing model for %s. Go to replicate.com and create a new model before pushing.", imageName) - logClient.EndPush(ctx, err, logCtx) - return err + if _, err := registry.NewClient().Inspect(ctx, imageName, nil); err != nil { + if errors.Is(err, registry.NotFoundError) { + // TODO[md]: can we create a new model on the fly? + err = fmt.Errorf("Unable to find Replicate existing model for %s. Go to replicate.com and create a new model before pushing.", imageName) + logClient.EndPush(ctx, err, logCtx) + return err + } } } else { if buildLocalImage { diff --git a/pkg/docker/docker_client_test.go b/pkg/docker/docker_client_test.go index 72c19b6b48..d5abbc919a 100644 --- a/pkg/docker/docker_client_test.go +++ b/pkg/docker/docker_client_test.go @@ -1,23 +1,16 @@ package docker import ( - "fmt" - "math/rand" - "net" - "strconv" "testing" - "time" - "github.com/docker/docker/api/types/container" - "github.com/docker/go-connections/nat" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" - testregistry "github.com/testcontainers/testcontainers-go/modules/registry" "github.com/replicate/cog/pkg/docker/command" "github.com/replicate/cog/pkg/docker/dockertest" + "github.com/replicate/cog/pkg/registry_testhelpers" ) func TestDockerClient(t *testing.T) { @@ -52,26 +45,6 @@ func (s *DockerClientSuite) assertNoImageExists(t *testing.T, imageRef string) { assert.Nil(t, inspect, "Image should not exist") } -// pickFreePort returns a TCP port in [min,max] that's free *right now*. -// There's still a small race between closing the listener and Docker grabbing -// the port, but it's good enough for test code. -func pickFreePort(minPort, maxPort int) (int, error) { - if minPort < 1024 || maxPort > 99999 || minPort > maxPort { - return 0, fmt.Errorf("invalid port range") - } - - rng := rand.New(rand.NewSource(time.Now().UnixNano())) // #nosec G404 - using math/rand is fine for test port selection - for tries := 0; tries < 20; tries++ { // avoid infinite loops - p := rng.Intn(maxPort-minPort+1) + minPort - l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) - if err == nil { - l.Close() - return p, nil // looks free - } - } - return 0, fmt.Errorf("could not find free port in range %d-%d", minPort, maxPort) -} - func (s *DockerClientSuite) runImageInspectTests(t *testing.T) { t.Run("ExistingLocalImage", func(t *testing.T) { t.Parallel() @@ -97,22 +70,7 @@ func (s *DockerClientSuite) runImageInspectTests(t *testing.T) { } func (s *DockerClientSuite) runPullTests(t *testing.T) { - registryContainer, err := testregistry.Run( - t.Context(), - "registry:2", - testcontainers.WithHostConfigModifier(func(hostConfig *container.HostConfig) { - // docker only considers localhost:1 through localhost:9999 as insecure. testcontainers - // picks higher ports by default, so we need to pick one ourselves to allow insecure access - // without modifying the daemon config. - port, err := pickFreePort(1024, 9999) - require.NoError(t, err, "Failed to pick free port") - hostConfig.PortBindings = map[nat.Port][]nat.PortBinding{ - nat.Port("5000/tcp"): {{HostIP: "0.0.0.0", HostPort: strconv.Itoa(port)}}, - } - }), - ) - defer testcontainers.CleanupContainer(t, registryContainer) - require.NoError(t, err, "Failed to start registry container") + testRegistry := registry_testhelpers.StartTestRegistry(t) // TODO[md]: add tests for the following permutations: // - remote reference exists/not exists @@ -120,7 +78,7 @@ func (s *DockerClientSuite) runPullTests(t *testing.T) { // - force pull true/false t.Run("RemoteImageExists", func(t *testing.T) { - imageRef := dockertest.ImageRefWithRegistry(t, registryContainer.RegistryName, "") + imageRef := testRegistry.ImageRefForTest(t, "") s.dockerHelper.LoadImageFixture(t, "alpine", imageRef) s.dockerHelper.MustPushImage(t, imageRef) @@ -141,7 +99,7 @@ func (s *DockerClientSuite) runPullTests(t *testing.T) { }) t.Run("RemoteReferenceNotFound", func(t *testing.T) { - imageRef := dockertest.ImageRefWithRegistry(t, registryContainer.RegistryName, "") + imageRef := testRegistry.ImageRefForTest(t, "") s.assertNoImageExists(t, imageRef) @@ -155,7 +113,7 @@ func (s *DockerClientSuite) runPullTests(t *testing.T) { t.Run("InvalidAuth", func(t *testing.T) { t.Skip("skip auth tests until we're using the docker engine since we can't set auth on the host without side effects") - imageRef := dockertest.ImageRefWithRegistry(t, registryContainer.RegistryName, "") + imageRef := testRegistry.ImageRefForTest(t, "") s.assertNoImageExists(t, imageRef) diff --git a/pkg/docker/manifest_inspect.go b/pkg/docker/manifest_inspect.go deleted file mode 100644 index 55fad0f77f..0000000000 --- a/pkg/docker/manifest_inspect.go +++ /dev/null @@ -1,28 +0,0 @@ -package docker - -import ( - "context" - "os/exec" - "strings" - - "github.com/replicate/cog/pkg/util/console" -) - -func ManifestInspect(ctx context.Context, image string) error { - cmd := exec.CommandContext(ctx, "docker", "manifest", "inspect", image) - var out strings.Builder - cmd.Stdout = &out - cmd.Stderr = &out - - console.Debug("$ " + strings.Join(cmd.Args, " ")) - err := cmd.Run() - - if err != nil { - output := out.String() - if strings.Contains(output, "no such manifest") || strings.Contains(output, "manifest unknown") || strings.Contains(output, "not found") { - return nil - } - return err - } - return nil -} diff --git a/pkg/registry/client.go b/pkg/registry/client.go new file mode 100644 index 0000000000..cc1e19a7b6 --- /dev/null +++ b/pkg/registry/client.go @@ -0,0 +1,278 @@ +package registry + +import ( + "context" + "errors" + "fmt" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +var NotFoundError = errors.New("image reference not found") + +type Client interface { + Inspect(ctx context.Context, imageRef string, platform *Platform) (*ManifestResult, error) + GetImage(ctx context.Context, imageRef string, platform *Platform) (v1.Image, error) +} + +type Platform struct { + OS string + Architecture string + Variant string +} + +type ManifestResult struct { + SchemaVersion int64 + MediaType string + + Manifests []PlatformManifest + Layers []string + Config string +} + +func (m *ManifestResult) IsIndex() bool { + return m.MediaType == string(types.OCIImageIndex) || m.MediaType == string(types.DockerManifestList) +} + +func (m *ManifestResult) IsSinglePlatform() bool { + return !m.IsIndex() +} + +type PlatformManifest struct { + Digest string + OS string + Architecture string + Variant string +} + +type defaultClient struct{} + +func NewClient() Client { + return &defaultClient{} +} + +func (c *defaultClient) Inspect(ctx context.Context, imageRef string, platform *Platform) (*ManifestResult, error) { + ref, err := name.ParseReference(imageRef, name.Insecure) + if err != nil { + return nil, fmt.Errorf("parsing reference: %w", err) + } + + desc, err := remote.Get(ref, + remote.WithContext(ctx), + remote.WithAuthFromKeychain(authn.DefaultKeychain), + // TODO[md]: map platform to remote.WithPlatform if necessary: + // remote.WithPlatform(...) + ) + if err != nil { + if checkError(err, transport.ManifestUnknownErrorCode, transport.NameUnknownErrorCode) { + return nil, NotFoundError + } + + return nil, fmt.Errorf("fetching descriptor: %w", err) + } + + mediaType := desc.Descriptor.MediaType + + if platform == nil { + switch mediaType { + case types.OCIImageIndex, types.DockerManifestList: + idx, err := desc.ImageIndex() + if err != nil { + return nil, fmt.Errorf("loading image index: %w", err) + } + indexManifest, err := idx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("getting index manifest: %w", err) + } + result := &ManifestResult{ + SchemaVersion: indexManifest.SchemaVersion, + MediaType: string(mediaType), + } + for _, m := range indexManifest.Manifests { + result.Manifests = append(result.Manifests, PlatformManifest{ + Digest: m.Digest.String(), + OS: m.Platform.OS, + Architecture: m.Platform.Architecture, + Variant: m.Platform.Variant, + }) + } + return result, nil + + case types.OCIManifestSchema1, types.DockerManifestSchema2: + img, err := desc.Image() + if err != nil { + return nil, fmt.Errorf("loading image: %w", err) + } + manifest, err := img.Manifest() + if err != nil { + return nil, fmt.Errorf("getting manifest: %w", err) + } + result := &ManifestResult{ + SchemaVersion: manifest.SchemaVersion, + MediaType: string(mediaType), + Config: manifest.Config.Digest.String(), + } + for _, layer := range manifest.Layers { + result.Layers = append(result.Layers, layer.Digest.String()) + } + return result, nil + default: + return nil, fmt.Errorf("unsupported media type: %s", mediaType) + } + } + + // platform is set, we expect a manifest list or error + if mediaType != types.OCIImageIndex && mediaType != types.DockerManifestList { + return nil, fmt.Errorf("image is not a manifest list but platform was specified") + } + + idx, err := desc.ImageIndex() + if err != nil { + return nil, fmt.Errorf("loading image index: %w", err) + } + indexManifest, err := idx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("getting index manifest: %w", err) + } + + var matchedDigest string + for _, m := range indexManifest.Manifests { + if m.Platform.OS == platform.OS && + m.Platform.Architecture == platform.Architecture && + m.Platform.Variant == platform.Variant { + matchedDigest = m.Digest.String() + break + } + } + + if matchedDigest == "" { + return nil, fmt.Errorf("platform not found in manifest list") + } + + digestRef, err := name.NewDigest(ref.Context().Name() + "@" + matchedDigest) + if err != nil { + return nil, fmt.Errorf("creating digest ref: %w", err) + } + manifestDesc, err := remote.Get(digestRef, + remote.WithContext(ctx), + remote.WithAuthFromKeychain(authn.DefaultKeychain), + ) + if err != nil { + return nil, fmt.Errorf("fetching platform manifest: %w", err) + } + img, err := manifestDesc.Image() + if err != nil { + return nil, fmt.Errorf("loading platform image: %w", err) + } + manifest, err := img.Manifest() + if err != nil { + return nil, fmt.Errorf("getting manifest: %w", err) + } + result := &ManifestResult{ + SchemaVersion: manifest.SchemaVersion, + MediaType: string(manifestDesc.Descriptor.MediaType), + Config: manifest.Config.Digest.String(), + } + for _, layer := range manifest.Layers { + result.Layers = append(result.Layers, layer.Digest.String()) + } + return result, nil +} + +func (c *defaultClient) GetImage(ctx context.Context, imageRef string, platform *Platform) (v1.Image, error) { + ref, err := name.ParseReference(imageRef, name.Insecure) + if err != nil { + return nil, fmt.Errorf("parsing reference: %w", err) + } + + desc, err := remote.Get(ref, + remote.WithContext(ctx), + remote.WithAuthFromKeychain(authn.DefaultKeychain), + ) + if err != nil { + return nil, fmt.Errorf("fetching descriptor: %w", err) + } + + mediaType := desc.Descriptor.MediaType + + // If no platform is specified and it's a single image, return it directly + if platform == nil { + switch mediaType { + case types.OCIManifestSchema1, types.DockerManifestSchema2: + return desc.Image() + case types.OCIImageIndex, types.DockerManifestList: + return nil, fmt.Errorf("platform must be specified for multi-platform image") + default: + return nil, fmt.Errorf("unsupported media type: %s", mediaType) + } + } + + // For platform-specific requests, we need to handle manifest lists + if mediaType != types.OCIImageIndex && mediaType != types.DockerManifestList { + return nil, fmt.Errorf("image is not a manifest list but platform was specified") + } + + idx, err := desc.ImageIndex() + if err != nil { + return nil, fmt.Errorf("loading image index: %w", err) + } + + indexManifest, err := idx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("getting index manifest: %w", err) + } + + // Find the matching platform manifest + var matchedDigest string + for _, m := range indexManifest.Manifests { + if m.Platform.OS == platform.OS && + m.Platform.Architecture == platform.Architecture && + m.Platform.Variant == platform.Variant { + matchedDigest = m.Digest.String() + break + } + } + + if matchedDigest == "" { + return nil, fmt.Errorf("platform not found in manifest list") + } + + // Get the image for the matched digest + digestRef, err := name.NewDigest(ref.Context().Name() + "@" + matchedDigest) + if err != nil { + return nil, fmt.Errorf("creating digest ref: %w", err) + } + + manifestDesc, err := remote.Get(digestRef, + remote.WithContext(ctx), + remote.WithAuthFromKeychain(authn.DefaultKeychain), + ) + if err != nil { + return nil, fmt.Errorf("fetching platform manifest: %w", err) + } + + return manifestDesc.Image() +} + +func checkError(err error, codes ...transport.ErrorCode) bool { + if err == nil { + return false + } + + var e *transport.Error + if errors.As(err, &e) { + for _, diagnosticErr := range e.Errors { + for _, code := range codes { + if diagnosticErr.Code == code { + return true + } + } + } + } + return false +} diff --git a/pkg/registry/client_test.go b/pkg/registry/client_test.go new file mode 100644 index 0000000000..50c547562a --- /dev/null +++ b/pkg/registry/client_test.go @@ -0,0 +1,77 @@ +package registry + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/replicate/cog/pkg/registry_testhelpers" +) + +func TestInspect(t *testing.T) { + if testing.Short() { + // TODO[md]: this is a hack to skip the test in GitHub Actions because + // because macos runners don't have rootless docker. this should get added back + // and be part of a normal integration suite we run on all target platforms + t.Skip("skipping integration tests") + } + + registry := registry_testhelpers.StartTestRegistry(t) + + t.Run("it returns an index for multi-platform images when a platform isn't provided", func(t *testing.T) { + imageRef := registry.ImageRef("alpine:latest") + + client := NewClient() + resp, err := client.Inspect(t.Context(), imageRef, nil) + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.IsIndex(), "expected index") + json.NewEncoder(os.Stdout).Encode(resp) + }) + + t.Run("it returns a single platform image when a platform is provided", func(t *testing.T) { + imageRef := registry.ImageRef("alpine:latest") + client := NewClient() + resp, err := client.Inspect(t.Context(), imageRef, &Platform{OS: "linux", Architecture: "amd64"}) + require.NoError(t, err) + require.NotNil(t, resp) + assert.False(t, resp.IsIndex(), "expected single platform image") + assert.True(t, resp.IsSinglePlatform(), "expected single platform image") + json.NewEncoder(os.Stdout).Encode(resp) + }) + + t.Run("when a repo does not exist", func(t *testing.T) { + imageRef := registry.ImageRef("i-do-not-exist:latest") + client := NewClient() + resp, err := client.Inspect(t.Context(), imageRef, nil) + assert.ErrorIs(t, err, NotFoundError, "expected not found error") + assert.Nil(t, resp) + }) + + t.Run("when a repo with a slashdoes not exist", func(t *testing.T) { + imageRef := registry.ImageRef("i-do-not-exist/with-a-slash:latest") + client := NewClient() + resp, err := client.Inspect(t.Context(), imageRef, nil) + assert.ErrorIs(t, err, NotFoundError, "expected not found error") + assert.Nil(t, resp) + }) + + t.Run("when the repo exists but the tag does not", func(t *testing.T) { + imageRef := registry.ImageRef("alpine:not-found") + client := NewClient() + resp, err := client.Inspect(t.Context(), imageRef, nil) + assert.ErrorIs(t, err, NotFoundError, "expected not found error") + assert.Nil(t, resp) + }) + + t.Run("when the repo and tag exist but platform does not", func(t *testing.T) { + imageRef := registry.ImageRef("alpine:latest") + client := NewClient() + resp, err := client.Inspect(t.Context(), imageRef, &Platform{OS: "windows", Architecture: "i386"}) + assert.ErrorContains(t, err, "platform not found") + assert.Nil(t, resp) + }) +} diff --git a/pkg/registry_testhelpers/registry_container.go b/pkg/registry_testhelpers/registry_container.go new file mode 100644 index 0000000000..c64e0e2094 --- /dev/null +++ b/pkg/registry_testhelpers/registry_container.go @@ -0,0 +1,77 @@ +package registry_testhelpers + +import ( + "fmt" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/registry" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/replicate/cog/pkg/util" +) + +// StartTestRegistry starts a test registry container on a random local port populated +// with image data from the testdata/docker directory. It returns a RegistryContainer +// that can be used to inspect the registry and generate absolute image references. It will +// automatically be cleaned when the test finishes. +// This is safe to run concurrently across multiple tests. +func StartTestRegistry(t *testing.T) *RegistryContainer { + t.Helper() + + _, filename, _, _ := runtime.Caller(0) + testdataDir := filepath.Join(filepath.Dir(filename), "testdata", "docker") + + registryContainer, err := registry.Run( + t.Context(), + "registry:3", + testcontainers.WithFiles(testcontainers.ContainerFile{ + HostFilePath: testdataDir, + ContainerFilePath: "/var/lib/registry/", + FileMode: 0o755, + }), + testcontainers.WithWaitStrategy( + wait.ForHTTP("/v2/").WithPort("5000/tcp"). + WithStartupTimeout(10*time.Second), + ), + testcontainers.WithHostConfigModifier(func(hostConfig *container.HostConfig) { + // docker only considers localhost:1 through localhost:9999 as insecure. testcontainers + // picks higher ports by default, so we need to pick one ourselves to allow insecure access + // without modifying the daemon config. + port, err := util.PickFreePort(1024, 9999) + require.NoError(t, err, "Failed to pick free port") + hostConfig.PortBindings = map[nat.Port][]nat.PortBinding{ + nat.Port("5000/tcp"): {{HostIP: "0.0.0.0", HostPort: strconv.Itoa(port)}}, + } + }), + ) + defer testcontainers.CleanupContainer(t, registryContainer) + require.NoError(t, err, "Failed to start registry container") + + return &RegistryContainer{Container: registryContainer} +} + +type RegistryContainer struct { + Container *registry.RegistryContainer +} + +func (c *RegistryContainer) ImageRef(ref string) string { + return path.Join(c.Container.RegistryName, ref) +} + +func (c *RegistryContainer) ImageRefForTest(t *testing.T, label string) string { + if label == "" { + label = fmt.Sprintf("test-%d", time.Now().Unix()) + } + repo := strings.ToLower(t.Name()) + return c.ImageRef(fmt.Sprintf("%s:%s", repo, label)) +} diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/1c/1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474/data b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/1c/1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474/data new file mode 100644 index 0000000000..83d310da45 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/1c/1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474/data @@ -0,0 +1,25 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b", + "size": 581 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870", + "size": 3642247 + } + ], + "annotations": { + "com.docker.official-images.bashbrew.arch": "amd64", + "org.opencontainers.image.base.name": "scratch", + "org.opencontainers.image.created": "2025-02-14T03:28:36Z", + "org.opencontainers.image.revision": "17fe3d1e2d2cbf54d745139eab749c252e35b883", + "org.opencontainers.image.source": "https://github.com/alpinelinux/docker-alpine.git#17fe3d1e2d2cbf54d745139eab749c252e35b883:x86_64", + "org.opencontainers.image.url": "https://hub.docker.com/_/alpine", + "org.opencontainers.image.version": "3.21.3" + } +} \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/6e/6e771e15690e2fabf2332d3a3b744495411d6e0b00b2aea64419b58b0066cf81/data b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/6e/6e771e15690e2fabf2332d3a3b744495411d6e0b00b2aea64419b58b0066cf81/data new file mode 100644 index 0000000000..2787addb0c Binary files /dev/null and b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/6e/6e771e15690e2fabf2332d3a3b744495411d6e0b00b2aea64419b58b0066cf81/data differ diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/75/757d680068d77be46fd1ea20fb21db16f150468c5e7079a08a2e4705aec096ac/data b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/75/757d680068d77be46fd1ea20fb21db16f150468c5e7079a08a2e4705aec096ac/data new file mode 100644 index 0000000000..4305be55e0 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/75/757d680068d77be46fd1ea20fb21db16f150468c5e7079a08a2e4705aec096ac/data @@ -0,0 +1,25 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:8d591b0b7dea080ea3be9e12ae563eebf9869168ffced1cb25b2470a3d9fe15e", + "size": 597 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:6e771e15690e2fabf2332d3a3b744495411d6e0b00b2aea64419b58b0066cf81", + "size": 3993029 + } + ], + "annotations": { + "com.docker.official-images.bashbrew.arch": "arm64v8", + "org.opencontainers.image.base.name": "scratch", + "org.opencontainers.image.created": "2025-02-14T03:28:36Z", + "org.opencontainers.image.revision": "17fe3d1e2d2cbf54d745139eab749c252e35b883", + "org.opencontainers.image.source": "https://github.com/alpinelinux/docker-alpine.git#17fe3d1e2d2cbf54d745139eab749c252e35b883:aarch64", + "org.opencontainers.image.url": "https://hub.docker.com/_/alpine", + "org.opencontainers.image.version": "3.21.3" + } +} \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/8d/8d591b0b7dea080ea3be9e12ae563eebf9869168ffced1cb25b2470a3d9fe15e/data b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/8d/8d591b0b7dea080ea3be9e12ae563eebf9869168ffced1cb25b2470a3d9fe15e/data new file mode 100644 index 0000000000..235cc090c5 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/8d/8d591b0b7dea080ea3be9e12ae563eebf9869168ffced1cb25b2470a3d9fe15e/data @@ -0,0 +1 @@ +{"architecture":"arm64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh"],"WorkingDir":"/"},"created":"2025-02-14T03:28:36Z","history":[{"created":"2025-02-14T03:28:36Z","created_by":"ADD alpine-minirootfs-3.21.3-aarch64.tar.gz / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-02-14T03:28:36Z","created_by":"CMD [\"/bin/sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:a16e98724c05975ee8c40d8fe389c3481373d34ab20a1cf52ea2accc43f71f4c"]},"variant":"v8"} \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/9a/9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5/data b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/9a/9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5/data new file mode 100644 index 0000000000..7fae0cd729 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/9a/9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5/data @@ -0,0 +1 @@ +{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":1022,"digest":"sha256:1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474","annotations":{"com.docker.official-images.bashbrew.arch":"amd64","org.opencontainers.image.base.name":"scratch","org.opencontainers.image.created":"2025-02-14T18:27:58Z","org.opencontainers.image.revision":"17fe3d1e2d2cbf54d745139eab749c252e35b883","org.opencontainers.image.source":"https://github.com/alpinelinux/docker-alpine.git#17fe3d1e2d2cbf54d745139eab749c252e35b883:x86_64","org.opencontainers.image.url":"https://hub.docker.com/_/alpine","org.opencontainers.image.version":"3.21.3"},"platform":{"architecture":"amd64","os":"linux"}},{"mediaType":"application/vnd.oci.image.manifest.v1+json","size":1025,"digest":"sha256:757d680068d77be46fd1ea20fb21db16f150468c5e7079a08a2e4705aec096ac","annotations":{"com.docker.official-images.bashbrew.arch":"arm64v8","org.opencontainers.image.base.name":"scratch","org.opencontainers.image.created":"2025-02-14T18:27:49Z","org.opencontainers.image.revision":"17fe3d1e2d2cbf54d745139eab749c252e35b883","org.opencontainers.image.source":"https://github.com/alpinelinux/docker-alpine.git#17fe3d1e2d2cbf54d745139eab749c252e35b883:aarch64","org.opencontainers.image.url":"https://hub.docker.com/_/alpine","org.opencontainers.image.version":"3.21.3"},"platform":{"architecture":"arm64","os":"linux"}}]} \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/ad/aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b/data b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/ad/aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b/data new file mode 100644 index 0000000000..d11a27900b --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/ad/aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b/data @@ -0,0 +1 @@ +{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh"],"WorkingDir":"/"},"created":"2025-02-14T03:28:36Z","history":[{"created":"2025-02-14T03:28:36Z","created_by":"ADD alpine-minirootfs-3.21.3-x86_64.tar.gz / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-02-14T03:28:36Z","created_by":"CMD [\"/bin/sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350"]}} \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/f1/f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870/data b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/f1/f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870/data new file mode 100644 index 0000000000..eed4c54f26 Binary files /dev/null and b/pkg/registry_testhelpers/testdata/docker/registry/v2/blobs/sha256/f1/f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870/data differ diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/6e771e15690e2fabf2332d3a3b744495411d6e0b00b2aea64419b58b0066cf81/link b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/6e771e15690e2fabf2332d3a3b744495411d6e0b00b2aea64419b58b0066cf81/link new file mode 100644 index 0000000000..c8a1a43347 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/6e771e15690e2fabf2332d3a3b744495411d6e0b00b2aea64419b58b0066cf81/link @@ -0,0 +1 @@ +sha256:6e771e15690e2fabf2332d3a3b744495411d6e0b00b2aea64419b58b0066cf81 \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/8d591b0b7dea080ea3be9e12ae563eebf9869168ffced1cb25b2470a3d9fe15e/link b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/8d591b0b7dea080ea3be9e12ae563eebf9869168ffced1cb25b2470a3d9fe15e/link new file mode 100644 index 0000000000..50be1a3c98 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/8d591b0b7dea080ea3be9e12ae563eebf9869168ffced1cb25b2470a3d9fe15e/link @@ -0,0 +1 @@ +sha256:8d591b0b7dea080ea3be9e12ae563eebf9869168ffced1cb25b2470a3d9fe15e \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b/link b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b/link new file mode 100644 index 0000000000..6142ac4b32 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b/link @@ -0,0 +1 @@ +sha256:aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870/link b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870/link new file mode 100644 index 0000000000..5f52dc5f71 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_layers/sha256/f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870/link @@ -0,0 +1 @@ +sha256:f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870 \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/revisions/sha256/1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474/link b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/revisions/sha256/1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474/link new file mode 100644 index 0000000000..f82567c376 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/revisions/sha256/1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474/link @@ -0,0 +1 @@ +sha256:1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474 \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/revisions/sha256/757d680068d77be46fd1ea20fb21db16f150468c5e7079a08a2e4705aec096ac/link b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/revisions/sha256/757d680068d77be46fd1ea20fb21db16f150468c5e7079a08a2e4705aec096ac/link new file mode 100644 index 0000000000..9569b77907 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/revisions/sha256/757d680068d77be46fd1ea20fb21db16f150468c5e7079a08a2e4705aec096ac/link @@ -0,0 +1 @@ +sha256:757d680068d77be46fd1ea20fb21db16f150468c5e7079a08a2e4705aec096ac \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/revisions/sha256/9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5/link b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/revisions/sha256/9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5/link new file mode 100644 index 0000000000..b2cfb5bd34 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/revisions/sha256/9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5/link @@ -0,0 +1 @@ +sha256:9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5 \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/tags/latest/current/link b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/tags/latest/current/link new file mode 100644 index 0000000000..b2cfb5bd34 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/tags/latest/current/link @@ -0,0 +1 @@ +sha256:9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5 \ No newline at end of file diff --git a/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/tags/latest/index/sha256/9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5/link b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/tags/latest/index/sha256/9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5/link new file mode 100644 index 0000000000..b2cfb5bd34 --- /dev/null +++ b/pkg/registry_testhelpers/testdata/docker/registry/v2/repositories/alpine/_manifests/tags/latest/index/sha256/9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5/link @@ -0,0 +1 @@ +sha256:9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5 \ No newline at end of file diff --git a/pkg/util/files/files.go b/pkg/util/files/files.go index a927a63082..8de34771d6 100644 --- a/pkg/util/files/files.go +++ b/pkg/util/files/files.go @@ -19,6 +19,17 @@ func Exists(path string) (bool, error) { } } +func IsEmpty(path string) (bool, error) { + entries, err := os.ReadDir(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return true, nil + } + return false, err + } + return len(entries) == 0, nil +} + func IsDir(path string) (bool, error) { file, err := os.Stat(path) if err != nil { diff --git a/pkg/util/net.go b/pkg/util/net.go new file mode 100644 index 0000000000..e82c17da82 --- /dev/null +++ b/pkg/util/net.go @@ -0,0 +1,29 @@ +package util + +import ( + "fmt" + "math/rand" + "net" + "time" +) + +// PickFreePort returns a TCP port in [min,max] that's not in use on the 127.0.0.1 interface. +// Note that there's a small chance of a race condition when a port is considered free at the +// time of the call, but not free when something tries to use it. This is good enough for dev +// and test code though. +func PickFreePort(minPort, maxPort int) (int, error) { + if minPort < 1024 || maxPort > 99999 || minPort > maxPort { + return 0, fmt.Errorf("invalid port range") + } + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) // #nosec G404 - using math/rand is fine for test port selection + for range 20 { // avoid infinite loops + p := rng.Intn(maxPort-minPort+1) + minPort + l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) + if err == nil { + l.Close() + return p, nil // looks free + } + } + return 0, fmt.Errorf("could not find free port in range %d-%d", minPort, maxPort) +} diff --git a/tools/test-registry-util/README.md b/tools/test-registry-util/README.md new file mode 100644 index 0000000000..76c11f2363 --- /dev/null +++ b/tools/test-registry-util/README.md @@ -0,0 +1,67 @@ +# `test-registry-util` + +A tool for creating and inspecting a local registry for testing. + +## Purpose + +We have a lot of intricate image manipulation code that needs to be tested. Mocks are't great for this because we need to make sure the code works with actual data. This tool helps setup real data for a test registry. + +## Usage + +Image data is stored in `pkg/registry_testhelpers/testdata` and matches the structore expected by `distribution/distribution`. + +During tests an ephemeral registry is spun up on a random local port, populated with the image data, and turn down when the test finishes. + +### Booting a registry in a test: + +```go +import "github.com/replicate/cog/pkg/registry_testhelpers" + +func TestMyFunction(t *testing.T) { + registryContainer := registry_testhelpers.StartTestRegistry(ctx) + image := registryContainer.ImageRef("alpine:latest") + + // use image as a real image reference +} +``` +### Inspect the current images in the registry: + +```bash +go run ./tools/test-registry-util catalog +``` +will print something like: + +``` +alpine:latest application/vnd.oci.image.index.v1+json + index -> sha256:9a0ff41dccad7a96f324a4655a715c623ed3511c7336361ffa9dadcecbdb99e5 + linux/amd64 -> sha256:1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474 + linux/arm64 -> sha256:757d680068d77be46fd1ea20fb21db16f150468c5e7079a08a2e4705aec096ac +python:3.10 application/vnd.oci.image.manifest.v1+json + single platform image -> sha256:f33bb19d5a518ba7e0353b6da48d58a04ef674de0bab0810e4751230ea1d4b19 +``` + +You can then use these images in your tests using referenes like: + +- `localhost:/alpine:latest` to get a multi-platform index +- `localhost:/alpine:latest` with platform `linux/amd64` to get a single image from a multi-platform index +- `localhost:/alpine:latest@sha256:1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474` to get a specific image +- `localhost:/python:3.10` to get a single-platform image + + +### Initialize a new registry storage + +To create a new directory of images, run: + +``` +go run ./tools/test-registry-util init +``` + +This will download all the images specified in `main.go` and save them to `pkg/registry_testhelpers/testdata`. + +### Run a registry + +This is just a convenience to inspect a registry outside of a test. + +``` +go run ./tools/test-registry-util run +``` diff --git a/tools/test-registry-util/main.go b/tools/test-registry-util/main.go new file mode 100644 index 0000000000..33466dc9a9 --- /dev/null +++ b/tools/test-registry-util/main.go @@ -0,0 +1,337 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/spf13/cobra" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/registry" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/replicate/cog/pkg/util/files" +) + +// images to download and push to the registry. Keep the images sizes small since they're stored in git. +// For reference, the `alpine:latest` image for `linux/amd64` ~3.5MB compressed. +var images = []struct { + Image string + Platforms []string + SinglePlatform string +}{ + { + Image: "alpine:latest", + Platforms: []string{ + "linux/amd64", + "linux/arm64", + }, + }, +} + +// relative to the root of the repo +var destinationDir string = "pkg/registry_testhelpers/testdata" + +func main() { + rootCmd := &cobra.Command{ + Use: "test-registry-util", + } + rootCmd.PersistentFlags().StringVar(&destinationDir, "storage-dir", destinationDir, "path to the directory where the registry will store its data") + + rootCmd.AddCommand( + &cobra.Command{ + Use: "init", + RunE: func(cmd *cobra.Command, args []string) error { + return runAndInit(cmd.Context(), destinationDir) + }, + }, + ) + rootCmd.AddCommand( + &cobra.Command{ + Use: "catalog", + RunE: func(cmd *cobra.Command, args []string) error { + return runAndCatalog(cmd.Context(), destinationDir) + }, + }, + ) + + rootCmd.AddCommand( + &cobra.Command{ + Use: "run", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer cancel() + + c, port, err := startRegistryTC(cmd.Context(), destinationDir) + if err != nil { + return err + } + defer func() { + if err := c.Terminate(cmd.Context()); err != nil { + fmt.Println("Failed to terminate registry:", err) + } + }() + + fmt.Println("Registry running at", fmt.Sprintf("localhost:%d", port)) + + <-ctx.Done() + return nil + }, + }, + ) + + if err := rootCmd.Execute(); err != nil { + fmt.Println("Failed to run:", err) + os.Exit(1) + } +} + +func runAndInit(ctx context.Context, dstDir string) error { + if empty, err := files.IsEmpty(dstDir); err != nil { + return fmt.Errorf("failed to check if destination directory is empty: %w", err) + } else if !empty { + return fmt.Errorf("destination directory %s is not empty", dstDir) + } + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + tmpDir, err := os.MkdirTemp("", "test-registry-") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + reg, hostPort, err := startRegistryTC(ctx, tmpDir) + if err != nil { + return err + } + defer func() { + if err := reg.Terminate(ctx); err != nil { + fmt.Println("Failed to terminate registry:", err) + } + }() + + addr := fmt.Sprintf("localhost:%d", hostPort) + for _, src := range images { + destRepo := fmt.Sprintf("%s/%s", addr, strings.Split(src.Image, ":")[0]) // e.g. localhost:5000/alpine + tagPart := strings.Split(src.Image, ":")[1] + + if src.SinglePlatform != "" { + osArch := strings.SplitN(src.SinglePlatform, "/", 2) + plat := v1.Platform{OS: osArch[0], Architecture: osArch[1]} + + // Pull source image for specified platform + srcRef, err := name.ParseReference(src.Image) + if err != nil { + return fmt.Errorf("parse reference: %w", err) + } + srcImg, err := remote.Image(srcRef, remote.WithPlatform(plat), remote.WithContext(ctx)) + if err != nil { + return err + } + + // Push with desired tag + destRef, err := name.ParseReference(fmt.Sprintf("%s:%s", destRepo, tagPart), name.Insecure) + if err != nil { + return fmt.Errorf("parse reference: %w", err) + } + if err := remote.Write(destRef, srcImg, + remote.WithContext(ctx), remote.WithAuth(authn.Anonymous)); err != nil { + return fmt.Errorf("write %s: %w", destRef, err) + } + fmt.Printf("✅ pushed single-platform image %s\n", destRef.Name()) + continue + } + + var idx v1.ImageIndex = mutate.IndexMediaType(empty.Index, types.OCIImageIndex) // start empty + + for _, platStr := range src.Platforms { + osArch := strings.SplitN(platStr, "/", 2) + plat := v1.Platform{OS: osArch[0], Architecture: osArch[1]} + + // 1. pull source manifest for this platform + srcRef, err := name.ParseReference(src.Image) + if err != nil { + return fmt.Errorf("parse reference: %w", err) + } + srcImg, err := remote.Image(srcRef, remote.WithPlatform(plat), remote.WithContext(ctx)) + if err != nil { + return err + } + + // 2. push it *by digest* into the new registry + digest, _ := srcImg.Digest() + destDigestRef, err := name.ParseReference(fmt.Sprintf("%s@%s", destRepo, digest.String()), name.Insecure) + if err != nil { + return fmt.Errorf("parse reference: %w", err) + } + if err := remote.Write(destDigestRef, srcImg, + remote.WithContext(ctx), remote.WithAuth(authn.Anonymous)); err != nil { + return fmt.Errorf("write %s: %w", destDigestRef, err) + } + + // 3. add it to the (soon‑to‑be) index + idx = mutate.AppendManifests(idx, + mutate.IndexAddendum{Add: srcImg, Descriptor: v1.Descriptor{Platform: &plat}}) + + fmt.Printf("✅ pushed %s for %s/%s\n", destDigestRef.Name(), plat.OS, plat.Architecture) + } + + // 4. push the assembled index and tag it + indexTag, err := name.ParseReference(fmt.Sprintf("%s:%s", destRepo, tagPart), name.Insecure) + if err != nil { + return fmt.Errorf("parse reference: %w", err) + } + if err := remote.WriteIndex(indexTag, idx, + remote.WithContext(ctx), remote.WithAuth(authn.Anonymous)); err != nil { + return fmt.Errorf("write index %s: %w", indexTag, err) + } + fmt.Printf("🏷️ tagged multi-arch index %s\n", indexTag.Name()) + } + + fmt.Println("Copying registry data to", dstDir) + if err := os.CopyFS(dstDir, os.DirFS(tmpDir)); err != nil { + return fmt.Errorf("failed to copy registry data: %w", err) + } + + if err := catalog(ctx, addr); err != nil { + return fmt.Errorf("catalog tree: %w", err) + } + + return nil +} + +func runAndCatalog(ctx context.Context, dir string) error { + dir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + reg, _, err := startRegistryTC(ctx, dir) + if err != nil { + return err + } + defer func() { + if err := reg.Terminate(ctx); err != nil { + fmt.Println("Failed to terminate registry:", err) + } + }() + + if err := catalog(ctx, reg.RegistryName); err != nil { + return fmt.Errorf("catalog: %w", err) + } + + return nil +} + +func catalog(ctx context.Context, addr string) error { + opts := []remote.Option{ + remote.WithContext(ctx), + remote.WithAuth(authn.Anonymous), // local registry + } + + reg, err := name.NewRegistry(addr, name.Insecure) + if err != nil { + return fmt.Errorf("new registry: %w", err) + } + + // first, list all repositories + repos, err := remote.Catalog(ctx, reg, opts...) + if err != nil { + return err + } + + for _, repoName := range repos { + repo := reg.Repo(repoName) + + // second, list all tags + tagNames, err := remote.List(repo, opts...) + if err != nil { + return err + } + + for _, tagName := range tagNames { + // third, get the manifest + ref, err := name.ParseReference(fmt.Sprintf("%s/%s:%s", addr, repoName, tagName)) + if err != nil { + return fmt.Errorf("parse reference: %w", err) + } + desc, err := remote.Get(ref, opts...) + if err != nil { + return err + } + + repoTag := fmt.Sprintf("%s:%s", ref.Context().RepositoryStr(), ref.Identifier()) + + switch mt := desc.Descriptor.MediaType; mt { + case types.OCIImageIndex, types.DockerManifestList: + + fmt.Printf("%s %s\n index -> %s\n", repoTag, mt, desc.Descriptor.Digest) + + idx, _ := desc.ImageIndex() + im, _ := idx.IndexManifest() + for _, m := range im.Manifests { + fmt.Printf(" %s -> %s\n", + m.Platform.String(), + m.Digest, + ) + } + + default: // single‑platform image + fmt.Printf("%s %s\n single platform image -> %s\n", repoTag, mt, desc.Descriptor.Digest) + } + } + } + return nil + +} + +func startRegistryTC(ctx context.Context, dir string) (*registry.RegistryContainer, int, error) { + dir, err := filepath.Abs(dir) + if err != nil { + return nil, 0, fmt.Errorf("failed to get absolute path: %w", err) + } + + reg, err := registry.Run(ctx, + "registry:3", + testcontainers.WithHostConfigModifier(func(hostConfig *container.HostConfig) { + hostConfig.Mounts = []mount.Mount{ + { + Type: "bind", + Source: dir, + Target: "/var/lib/registry", + }, + } + }), + testcontainers.WithWaitStrategy( + wait.ForHTTP("/v2/").WithPort("5000/tcp"). + WithStartupTimeout(10*time.Second), + ), + ) + if err != nil { + return nil, 0, fmt.Errorf("start registry: %w", err) + } + + port, err := reg.MappedPort(ctx, "5000/tcp") + if err != nil { + if err := reg.Terminate(ctx); err != nil { + fmt.Println("Failed to terminate registry:", err) + } + return nil, 0, fmt.Errorf("mapped port: %w", err) + } + return reg, port.Int(), nil +}